feat: add props system and refactor asset library architecture
This commit is contained in:
@@ -96,7 +96,8 @@ export function useProject(projectId: string) {
|
||||
novelPromotionData: {
|
||||
...prev.novelPromotionData,
|
||||
characters: assets.characters || [],
|
||||
locations: assets.locations || []
|
||||
locations: assets.locations || [],
|
||||
props: assets.props || [],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import AssetsStage from './AssetsStage'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { useProjectAssets } from '@/lib/query/hooks'
|
||||
import { useAssets } from '@/lib/query/hooks'
|
||||
import JSZip from 'jszip'
|
||||
import { logError as _logError } from '@/lib/logging/core'
|
||||
|
||||
@@ -30,37 +30,49 @@ export default function AssetLibrary({
|
||||
const t = useTranslations('assets')
|
||||
|
||||
// 获取项目资产数据用于下载
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
const { data: assets = [] } = useAssets({
|
||||
scope: 'project',
|
||||
projectId,
|
||||
})
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
const characters = assets?.characters ?? []
|
||||
const locations = assets?.locations ?? []
|
||||
|
||||
// 收集所有有效图片
|
||||
const imageEntries: Array<{ filename: string; url: string }> = []
|
||||
|
||||
// 角色图片
|
||||
for (const character of characters) {
|
||||
for (const appearance of character.appearances ?? []) {
|
||||
const url = appearance.imageUrl
|
||||
for (const asset of assets) {
|
||||
if (asset.kind !== 'character') continue
|
||||
for (const variant of asset.variants) {
|
||||
const selectedRender = variant.renders.find((render) => render.isSelected) ?? variant.renders[0]
|
||||
const url = selectedRender?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = appearance.appearanceIndex === 0
|
||||
const safeName = asset.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = variant.index === 0
|
||||
? `characters/${safeName}.jpg`
|
||||
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
|
||||
: `characters/${safeName}_appearance${variant.index}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
|
||||
// 场景图片:取已选中的那张
|
||||
for (const location of locations) {
|
||||
const selectedImage = location.images?.find(img => img.isSelected) ?? location.images?.[0]
|
||||
const url = selectedImage?.imageUrl
|
||||
for (const asset of assets) {
|
||||
if (asset.kind !== 'location') continue
|
||||
const selectedVariant = asset.variants.find((variant) => variant.renders[0]?.isSelected) ?? asset.variants[0]
|
||||
const url = selectedVariant?.renders[0]?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const safeName = asset.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
imageEntries.push({ filename: `locations/${safeName}.jpg`, url })
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
if (asset.kind !== 'prop') continue
|
||||
const selectedVariant = asset.variants.find((variant) => variant.renders[0]?.isSelected) ?? asset.variants[0]
|
||||
const url = selectedVariant?.renders[0]?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = asset.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
imageEntries.push({ filename: `props/${safeName}.jpg`, url })
|
||||
}
|
||||
|
||||
if (imageEntries.length === 0) {
|
||||
alert(t('assetLibrary.downloadEmpty'))
|
||||
return
|
||||
|
||||
@@ -16,14 +16,21 @@ import { useTranslations } from 'next-intl'
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
// 移除了 useRouter 导入,因为不再需要在组件中操作 URL
|
||||
import { Character, CharacterAppearance } from '@/types/project'
|
||||
import { Character, CharacterAppearance, NovelPromotionClip } from '@/types/project'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import {
|
||||
useAssetActions,
|
||||
useGenerateProjectCharacterImage,
|
||||
useGenerateProjectLocationImage,
|
||||
useProjectAssets,
|
||||
useAssets,
|
||||
useRefreshProjectAssets,
|
||||
useEpisodes,
|
||||
useEpisodeData,
|
||||
} from '@/lib/query/hooks'
|
||||
import {
|
||||
getAllClipsAssets,
|
||||
fuzzyMatchLocation,
|
||||
} from './script-view/clip-asset-utils'
|
||||
|
||||
// Hooks
|
||||
import { useCharacterActions } from './assets/hooks/useCharacterActions'
|
||||
@@ -40,6 +47,7 @@ import { useAssetsImageEdit } from './assets/hooks/useAssetsImageEdit'
|
||||
import CharacterSection from './assets/CharacterSection'
|
||||
import LocationSection from './assets/LocationSection'
|
||||
import AssetToolbar from './assets/AssetToolbar'
|
||||
import AssetFilterBar, { type AssetKindFilter } from './assets/AssetFilterBar'
|
||||
import AssetsStageStatusOverlays from './assets/AssetsStageStatusOverlays'
|
||||
import UnconfirmedProfilesSection from './assets/UnconfirmedProfilesSection'
|
||||
import AssetsStageModals from './assets/AssetsStageModals'
|
||||
@@ -62,11 +70,27 @@ export default function AssetsStage({
|
||||
triggerGlobalAnalyze = false,
|
||||
onGlobalAnalyzeComplete
|
||||
}: AssetsStageProps) {
|
||||
// 🔥 V6.5 重构:直接订阅缓存,消除 props drilling
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
// 🔧 使用 useMemo 稳定引用,防止 useCallback/useEffect 依赖问题
|
||||
const characters = useMemo(() => assets?.characters ?? [], [assets?.characters])
|
||||
const locations = useMemo(() => assets?.locations ?? [], [assets?.locations])
|
||||
const { data: assets = [] } = useAssets({
|
||||
scope: 'project',
|
||||
projectId,
|
||||
})
|
||||
const characters = useMemo(
|
||||
() => assets.filter((asset) => asset.kind === 'character'),
|
||||
[assets],
|
||||
)
|
||||
const locations = useMemo(
|
||||
() => assets.filter((asset) => asset.kind === 'location'),
|
||||
[assets],
|
||||
)
|
||||
const props = useMemo(
|
||||
() => assets.filter((asset) => asset.kind === 'prop'),
|
||||
[assets],
|
||||
)
|
||||
const propAssetActions = useAssetActions({
|
||||
scope: 'project',
|
||||
projectId,
|
||||
kind: 'prop',
|
||||
})
|
||||
// 🔥 使用 React Query 刷新,替代 onRefresh prop
|
||||
const refreshAssets = useRefreshProjectAssets(projectId)
|
||||
const onRefresh = useCallback(() => { refreshAssets() }, [refreshAssets])
|
||||
@@ -77,7 +101,7 @@ export default function AssetsStage({
|
||||
|
||||
// 🔥 内部图片生成函数 - 使用 mutation hooks 实现乐观更新
|
||||
const handleGenerateImage = useCallback(async (
|
||||
type: 'character' | 'location',
|
||||
type: 'character' | 'location' | 'prop',
|
||||
id: string,
|
||||
appearanceId?: string,
|
||||
count?: number,
|
||||
@@ -86,18 +110,84 @@ export default function AssetsStage({
|
||||
await generateCharacterImage.mutateAsync({ characterId: id, appearanceId, count })
|
||||
} else if (type === 'location') {
|
||||
await generateLocationImage.mutateAsync({ locationId: id, count })
|
||||
} else if (type === 'prop') {
|
||||
await propAssetActions.generate({ id, count })
|
||||
}
|
||||
}, [generateCharacterImage, generateLocationImage])
|
||||
}, [generateCharacterImage, generateLocationImage, propAssetActions])
|
||||
|
||||
const t = useTranslations('assets')
|
||||
// 计算资产总数
|
||||
const totalAppearances = characters.reduce((sum, char) => sum + (char.appearances?.length || 0), 0)
|
||||
const totalAppearances = characters.reduce((sum, character) => sum + character.variants.length, 0)
|
||||
const totalLocations = locations.length
|
||||
const totalAssets = totalAppearances + totalLocations
|
||||
const totalProps = props.length
|
||||
const totalAssets = totalAppearances + totalLocations + totalProps
|
||||
|
||||
// 本地 UI 状态
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'warning' | 'error' } | null>(null)
|
||||
const [kindFilter, setKindFilter] = useState<AssetKindFilter>('all')
|
||||
const [episodeFilter, setEpisodeFilter] = useState<string | null>(null)
|
||||
|
||||
// 获取剧集列表
|
||||
const { episodes } = useEpisodes(projectId)
|
||||
const episodeOptions = useMemo(
|
||||
() => episodes.map((ep) => ({ id: ep.id, episodeNumber: ep.episodeNumber, name: ep.name })),
|
||||
[episodes],
|
||||
)
|
||||
|
||||
// 分集筛选:获取选中集的 clips,解析出该集的资产名称
|
||||
const { data: episodeData } = useEpisodeData(projectId, episodeFilter)
|
||||
const episodeClips = useMemo(() => {
|
||||
if (!episodeFilter || !episodeData) return null
|
||||
return ((episodeData as { clips?: NovelPromotionClip[] }).clips) ?? null
|
||||
}, [episodeFilter, episodeData])
|
||||
|
||||
// 按分集筛选资产 ID 集合
|
||||
const episodeAssetIds = useMemo(() => {
|
||||
if (!episodeClips) return null // null 表示不筛选
|
||||
const { allCharNames, allLocNames, allPropNames } = getAllClipsAssets(episodeClips)
|
||||
|
||||
const charIds = new Set(
|
||||
characters
|
||||
.filter((c) => {
|
||||
const aliases = c.name.split('/').map((a) => a.trim())
|
||||
return aliases.some((alias) => allCharNames.has(alias)) || allCharNames.has(c.name)
|
||||
})
|
||||
.map((c) => c.id),
|
||||
)
|
||||
const locIds = new Set(
|
||||
locations
|
||||
.filter((l) => Array.from(allLocNames).some((clipLocName) => fuzzyMatchLocation(clipLocName, l.name)))
|
||||
.map((l) => l.id),
|
||||
)
|
||||
const propIds = new Set(
|
||||
props
|
||||
.filter((p) => Array.from(allPropNames).some((clipPropName) => clipPropName.toLowerCase() === p.name.toLowerCase()))
|
||||
.map((p) => p.id),
|
||||
)
|
||||
|
||||
return { charIds, locIds, propIds }
|
||||
}, [episodeClips, characters, locations, props])
|
||||
|
||||
// 最终展示的资产列表(先按分集、再按类型筛选)
|
||||
const filteredCharacters = useMemo(
|
||||
() => episodeAssetIds ? characters.filter((c) => episodeAssetIds.charIds.has(c.id)) : characters,
|
||||
[characters, episodeAssetIds],
|
||||
)
|
||||
const filteredLocations = useMemo(
|
||||
() => episodeAssetIds ? locations.filter((l) => episodeAssetIds.locIds.has(l.id)) : locations,
|
||||
[locations, episodeAssetIds],
|
||||
)
|
||||
const filteredProps = useMemo(
|
||||
() => episodeAssetIds ? props.filter((p) => episodeAssetIds.propIds.has(p.id)) : props,
|
||||
[props, episodeAssetIds],
|
||||
)
|
||||
|
||||
// 筛选后的计数
|
||||
const filteredAppearances = filteredCharacters.reduce((sum, character) => sum + character.variants.length, 0)
|
||||
const filteredLocCount = filteredLocations.length
|
||||
const filteredPropCount = filteredProps.length
|
||||
const filteredTotal = filteredAppearances + filteredLocCount + filteredPropCount
|
||||
|
||||
// 辅助:获取角色形象
|
||||
const getAppearances = (character: Character): CharacterAppearance[] => {
|
||||
@@ -146,6 +236,7 @@ export default function AssetsStage({
|
||||
isGlobalCopyInFlight,
|
||||
handleCopyFromGlobal,
|
||||
handleCopyLocationFromGlobal,
|
||||
handleCopyPropFromGlobal,
|
||||
handleVoiceSelectFromHub,
|
||||
handleConfirmCopyFromGlobal,
|
||||
handleCloseCopyPicker,
|
||||
@@ -179,6 +270,17 @@ export default function AssetsStage({
|
||||
projectId,
|
||||
showToast
|
||||
})
|
||||
const {
|
||||
handleDeleteLocation: handleDeleteProp,
|
||||
handleSelectLocationImage: handleSelectPropImage,
|
||||
handleConfirmLocationSelection: handleConfirmPropSelection,
|
||||
handleRegenerateSingleLocation: handleRegenerateSingleProp,
|
||||
handleRegenerateLocationGroup: handleRegeneratePropGroup,
|
||||
} = useLocationActions({
|
||||
projectId,
|
||||
assetType: 'prop',
|
||||
showToast,
|
||||
})
|
||||
|
||||
// TTS/音色
|
||||
const {
|
||||
@@ -195,20 +297,26 @@ export default function AssetsStage({
|
||||
const {
|
||||
editingAppearance,
|
||||
editingLocation,
|
||||
editingProp,
|
||||
showAddCharacter,
|
||||
showAddLocation,
|
||||
showAddProp,
|
||||
imageEditModal,
|
||||
characterImageEditModal,
|
||||
setShowAddCharacter,
|
||||
setShowAddLocation,
|
||||
setShowAddProp,
|
||||
handleEditAppearance,
|
||||
handleEditLocation,
|
||||
handleEditProp,
|
||||
handleOpenLocationImageEdit,
|
||||
handleOpenCharacterImageEdit,
|
||||
closeEditingAppearance,
|
||||
closeEditingLocation,
|
||||
closeEditingProp,
|
||||
closeAddCharacter,
|
||||
closeAddLocation,
|
||||
closeAddProp,
|
||||
closeImageEditModal,
|
||||
closeCharacterImageEditModal
|
||||
} = useAssetModals({
|
||||
@@ -279,6 +387,7 @@ export default function AssetsStage({
|
||||
totalAssets={totalAssets}
|
||||
totalAppearances={totalAppearances}
|
||||
totalLocations={totalLocations}
|
||||
totalProps={totalProps}
|
||||
isBatchSubmitting={isBatchSubmitting}
|
||||
isAnalyzingAssets={isAnalyzingAssets}
|
||||
isGlobalAnalyzing={isGlobalAnalyzing}
|
||||
@@ -286,6 +395,21 @@ export default function AssetsStage({
|
||||
onGenerateAll={handleGenerateAllImages}
|
||||
onRegenerateAll={handleRegenerateAllImages}
|
||||
onGlobalAnalyze={handleGlobalAnalyze}
|
||||
episodeId={episodeFilter}
|
||||
onEpisodeChange={setEpisodeFilter}
|
||||
episodes={episodeOptions}
|
||||
/>
|
||||
|
||||
{/* 资产筛选栏 */}
|
||||
<AssetFilterBar
|
||||
kindFilter={kindFilter}
|
||||
onKindFilterChange={setKindFilter}
|
||||
counts={{
|
||||
all: filteredTotal,
|
||||
character: filteredAppearances,
|
||||
location: filteredLocCount,
|
||||
prop: filteredPropCount,
|
||||
}}
|
||||
/>
|
||||
|
||||
<UnconfirmedProfilesSection
|
||||
@@ -304,53 +428,83 @@ export default function AssetsStage({
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
/>
|
||||
|
||||
{/* 角色资产区块 */}
|
||||
<CharacterSection
|
||||
projectId={projectId}
|
||||
focusCharacterId={focusCharacterId}
|
||||
focusCharacterRequestId={focusCharacterRequestId}
|
||||
activeTaskKeys={activeTaskKeys}
|
||||
onClearTaskKey={clearTransientTaskKey}
|
||||
onRegisterTransientTaskKey={registerTransientTaskKey}
|
||||
isAnalyzingAssets={isAnalyzingAssets}
|
||||
onAddCharacter={() => setShowAddCharacter(true)}
|
||||
onDeleteCharacter={handleDeleteCharacter}
|
||||
onDeleteAppearance={handleDeleteAppearance}
|
||||
onEditAppearance={handleEditAppearance}
|
||||
handleGenerateImage={handleGenerateImage}
|
||||
onSelectImage={handleSelectCharacterImage}
|
||||
onConfirmSelection={handleConfirmSelection}
|
||||
onRegenerateSingle={handleRegenerateSingleCharacter}
|
||||
onRegenerateGroup={handleRegenerateCharacterGroup}
|
||||
onUndo={handleUndoCharacter}
|
||||
onImageClick={setPreviewImage}
|
||||
onImageEdit={(charId, appIdx, imgIdx, name) => handleOpenCharacterImageEdit(charId, appIdx, imgIdx, name)}
|
||||
onVoiceChange={(characterId, customVoiceUrl) => handleVoiceChange(characterId, 'custom', characterId, customVoiceUrl)}
|
||||
onVoiceDesign={handleOpenVoiceDesign}
|
||||
onVoiceSelectFromHub={handleVoiceSelectFromHub}
|
||||
onCopyFromGlobal={handleCopyFromGlobal}
|
||||
getAppearances={getAppearances}
|
||||
/>
|
||||
|
||||
{/* 场景资产区块 */}
|
||||
<LocationSection
|
||||
projectId={projectId}
|
||||
activeTaskKeys={activeTaskKeys}
|
||||
onClearTaskKey={clearTransientTaskKey}
|
||||
onRegisterTransientTaskKey={registerTransientTaskKey}
|
||||
onAddLocation={() => setShowAddLocation(true)}
|
||||
onDeleteLocation={handleDeleteLocation}
|
||||
onEditLocation={handleEditLocation}
|
||||
handleGenerateImage={handleGenerateImage}
|
||||
onSelectImage={handleSelectLocationImage}
|
||||
onConfirmSelection={handleConfirmLocationSelection}
|
||||
onRegenerateSingle={handleRegenerateSingleLocation}
|
||||
onRegenerateGroup={handleRegenerateLocationGroup}
|
||||
onUndo={handleUndoLocation}
|
||||
onImageClick={setPreviewImage}
|
||||
onImageEdit={(locId, imgIdx) => handleOpenLocationImageEdit(locId, imgIdx)}
|
||||
onCopyFromGlobal={handleCopyLocationFromGlobal}
|
||||
/>
|
||||
{(kindFilter === 'all' || kindFilter === 'character') && (
|
||||
<CharacterSection
|
||||
key="character"
|
||||
projectId={projectId}
|
||||
focusCharacterId={focusCharacterId}
|
||||
focusCharacterRequestId={focusCharacterRequestId}
|
||||
activeTaskKeys={activeTaskKeys}
|
||||
onClearTaskKey={clearTransientTaskKey}
|
||||
onRegisterTransientTaskKey={registerTransientTaskKey}
|
||||
isAnalyzingAssets={isAnalyzingAssets}
|
||||
onAddCharacter={() => setShowAddCharacter(true)}
|
||||
onDeleteCharacter={handleDeleteCharacter}
|
||||
onDeleteAppearance={handleDeleteAppearance}
|
||||
onEditAppearance={handleEditAppearance}
|
||||
handleGenerateImage={handleGenerateImage}
|
||||
onSelectImage={handleSelectCharacterImage}
|
||||
onConfirmSelection={handleConfirmSelection}
|
||||
onRegenerateSingle={handleRegenerateSingleCharacter}
|
||||
onRegenerateGroup={handleRegenerateCharacterGroup}
|
||||
onUndo={handleUndoCharacter}
|
||||
onImageClick={setPreviewImage}
|
||||
onImageEdit={(charId, appIdx, imgIdx, name) => handleOpenCharacterImageEdit(charId, appIdx, imgIdx, name)}
|
||||
onVoiceChange={(characterId, customVoiceUrl) => handleVoiceChange(characterId, 'custom', characterId, customVoiceUrl)}
|
||||
onVoiceDesign={handleOpenVoiceDesign}
|
||||
onVoiceSelectFromHub={handleVoiceSelectFromHub}
|
||||
onCopyFromGlobal={handleCopyFromGlobal}
|
||||
getAppearances={getAppearances}
|
||||
filterIds={episodeAssetIds?.charIds ?? null}
|
||||
/>
|
||||
)}
|
||||
{(kindFilter === 'all' || kindFilter === 'location') && (
|
||||
<LocationSection
|
||||
key="location"
|
||||
projectId={projectId}
|
||||
activeTaskKeys={activeTaskKeys}
|
||||
onClearTaskKey={clearTransientTaskKey}
|
||||
onRegisterTransientTaskKey={registerTransientTaskKey}
|
||||
onAddLocation={() => setShowAddLocation(true)}
|
||||
onDeleteLocation={handleDeleteLocation}
|
||||
onEditLocation={handleEditLocation}
|
||||
handleGenerateImage={handleGenerateImage}
|
||||
onSelectImage={handleSelectLocationImage}
|
||||
onConfirmSelection={handleConfirmLocationSelection}
|
||||
onRegenerateSingle={handleRegenerateSingleLocation}
|
||||
onRegenerateGroup={handleRegenerateLocationGroup}
|
||||
onUndo={handleUndoLocation}
|
||||
onImageClick={setPreviewImage}
|
||||
onImageEdit={(locId, imgIdx) => handleOpenLocationImageEdit(locId, imgIdx)}
|
||||
onCopyFromGlobal={handleCopyLocationFromGlobal}
|
||||
filterIds={episodeAssetIds?.locIds ?? null}
|
||||
/>
|
||||
)}
|
||||
{(kindFilter === 'all' || kindFilter === 'prop') && (
|
||||
<LocationSection
|
||||
key="prop"
|
||||
projectId={projectId}
|
||||
assetType="prop"
|
||||
activeTaskKeys={activeTaskKeys}
|
||||
onClearTaskKey={clearTransientTaskKey}
|
||||
onRegisterTransientTaskKey={registerTransientTaskKey}
|
||||
onAddLocation={() => setShowAddProp(true)}
|
||||
onDeleteLocation={handleDeleteProp}
|
||||
onEditLocation={handleEditProp}
|
||||
handleGenerateImage={handleGenerateImage}
|
||||
onSelectImage={handleSelectPropImage}
|
||||
onConfirmSelection={handleConfirmPropSelection}
|
||||
onRegenerateSingle={handleRegenerateSingleProp}
|
||||
onRegenerateGroup={handleRegeneratePropGroup}
|
||||
onUndo={(propId) => {
|
||||
void propAssetActions.revertRender({ id: propId }).catch(() => undefined)
|
||||
}}
|
||||
onImageClick={setPreviewImage}
|
||||
onImageEdit={() => undefined}
|
||||
onCopyFromGlobal={handleCopyPropFromGlobal}
|
||||
filterIds={episodeAssetIds?.propIds ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AssetsStageModals
|
||||
projectId={projectId}
|
||||
@@ -368,8 +522,10 @@ export default function AssetsStage({
|
||||
handleConfirmProfile={handleConfirmProfile}
|
||||
closeEditingAppearance={closeEditingAppearance}
|
||||
closeEditingLocation={closeEditingLocation}
|
||||
closeEditingProp={closeEditingProp}
|
||||
closeAddCharacter={closeAddCharacter}
|
||||
closeAddLocation={closeAddLocation}
|
||||
closeAddProp={closeAddProp}
|
||||
closeImageEditModal={closeImageEditModal}
|
||||
closeCharacterImageEditModal={closeCharacterImageEditModal}
|
||||
isConfirmingCharacter={isConfirmingCharacter}
|
||||
@@ -379,8 +535,10 @@ export default function AssetsStage({
|
||||
characterImageEditModal={characterImageEditModal}
|
||||
editingAppearance={editingAppearance}
|
||||
editingLocation={editingLocation}
|
||||
editingProp={editingProp}
|
||||
showAddCharacter={showAddCharacter}
|
||||
showAddLocation={showAddLocation}
|
||||
showAddProp={showAddProp}
|
||||
voiceDesignCharacter={voiceDesignCharacter}
|
||||
editingProfile={editingProfile}
|
||||
copyFromGlobalTarget={copyFromGlobalTarget}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { SegmentedControl } from '@/components/ui/SegmentedControl'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────
|
||||
|
||||
export type AssetKindFilter = 'all' | 'character' | 'location' | 'prop'
|
||||
|
||||
interface AssetFilterBarProps {
|
||||
/** Current kind filter */
|
||||
kindFilter: AssetKindFilter
|
||||
onKindFilterChange: (value: AssetKindFilter) => void
|
||||
/** Asset counts for display */
|
||||
counts: {
|
||||
all: number
|
||||
character: number
|
||||
location: number
|
||||
prop: number
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────
|
||||
|
||||
export default function AssetFilterBar({
|
||||
kindFilter,
|
||||
onKindFilterChange,
|
||||
counts,
|
||||
}: AssetFilterBarProps) {
|
||||
const t = useTranslations('assets')
|
||||
|
||||
const segmentOptions = [
|
||||
{ value: 'all' as const, label: `${t('filterBar.all')} (${counts.all})` },
|
||||
{ value: 'character' as const, label: `${t('stage.characters')} (${counts.character})` },
|
||||
{ value: 'location' as const, label: `${t('stage.locations')} (${counts.location})` },
|
||||
{ value: 'prop' as const, label: `${t('stage.props')} (${counts.prop})` },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 glass-surface rounded-xl">
|
||||
<SegmentedControl
|
||||
options={segmentOptions}
|
||||
value={kindFilter}
|
||||
onChange={onKindFilterChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRefreshProjectAssets, useProjectAssets, useProjectData } from '@/lib/query/hooks'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
@@ -13,11 +14,18 @@ import { logError as _logError } from '@/lib/logging/core'
|
||||
* 从 AssetsStage.tsx 提取,负责批量操作和刷新按钮
|
||||
*/
|
||||
|
||||
interface EpisodeOption {
|
||||
id: string
|
||||
episodeNumber: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface AssetToolbarProps {
|
||||
projectId: string
|
||||
totalAssets: number
|
||||
totalAppearances: number
|
||||
totalLocations: number
|
||||
totalProps: number
|
||||
isBatchSubmitting: boolean
|
||||
isAnalyzingAssets: boolean
|
||||
isGlobalAnalyzing?: boolean
|
||||
@@ -25,6 +33,128 @@ interface AssetToolbarProps {
|
||||
onGenerateAll: () => void
|
||||
onRegenerateAll: () => void
|
||||
onGlobalAnalyze?: () => void
|
||||
/** Episode filter */
|
||||
episodeId: string | null
|
||||
onEpisodeChange: (episodeId: string | null) => void
|
||||
episodes: EpisodeOption[]
|
||||
}
|
||||
|
||||
// ─── 剧集筛选 Chip ────────────────────────────────────
|
||||
|
||||
function EpisodeChip({
|
||||
episodeId,
|
||||
onEpisodeChange,
|
||||
episodes,
|
||||
}: {
|
||||
episodeId: string | null
|
||||
onEpisodeChange: (id: string | null) => void
|
||||
episodes: EpisodeOption[]
|
||||
}) {
|
||||
const t = useTranslations('assets')
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
const selectedEpisode = episodes.find((ep) => ep.id === episodeId)
|
||||
const label = selectedEpisode ? selectedEpisode.name : t('filterBar.allEpisodes')
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
setMenuPos({
|
||||
top: rect.bottom + 6,
|
||||
left: rect.left,
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
updatePosition()
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
triggerRef.current?.contains(e.target as Node) ||
|
||||
menuRef.current?.contains(e.target as Node)
|
||||
) return
|
||||
setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [open, updatePosition])
|
||||
|
||||
const handleSelect = (id: string | null) => {
|
||||
setOpen(false)
|
||||
onEpisodeChange(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[13px] font-medium transition-all duration-200 cursor-pointer border ${
|
||||
episodeId
|
||||
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] border-[var(--glass-tone-info-fg)]/20'
|
||||
: 'bg-[#f2f2f7] dark:bg-[#2c2c2e] text-[var(--glass-text-secondary)] border-[var(--glass-stroke-base)] hover:bg-[#e8e8ed] dark:hover:bg-[#3a3a3c]'
|
||||
}`}
|
||||
>
|
||||
<AppIcon name="film" className="w-3.5 h-3.5" />
|
||||
<span>{label}</span>
|
||||
{episodeId ? (
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => { e.stopPropagation(); onEpisodeChange(null) }}
|
||||
className="ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full hover:bg-[var(--glass-tone-info-fg)]/20 transition-colors"
|
||||
>
|
||||
<AppIcon name="close" className="w-3 h-3" />
|
||||
</span>
|
||||
) : (
|
||||
<AppIcon
|
||||
name="chevronDown"
|
||||
className={`w-3 h-3 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{open && menuPos && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[9999] min-w-[180px] max-h-[320px] overflow-y-auto py-1.5 rounded-xl bg-white dark:bg-[#2c2c2e] shadow-[0_8px_32px_rgba(0,0,0,0.12),0_2px_8px_rgba(0,0,0,0.08)] border border-[var(--glass-stroke-base)] animate-in fade-in-0 zoom-in-95 duration-150"
|
||||
style={{ top: menuPos.top, left: menuPos.left }}
|
||||
>
|
||||
{/* All episodes option */}
|
||||
<button
|
||||
onClick={() => handleSelect(null)}
|
||||
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm transition-colors cursor-pointer ${
|
||||
!episodeId
|
||||
? 'text-[var(--glass-tone-info-fg)] bg-[var(--glass-tone-info-bg)] font-medium'
|
||||
: 'text-[var(--glass-text-primary)] hover:bg-[var(--glass-bg-muted)]'
|
||||
}`}
|
||||
>
|
||||
<AppIcon name="folderOpen" className="w-4 h-4 text-[var(--glass-text-tertiary)]" />
|
||||
<span>{t('filterBar.allEpisodes')}</span>
|
||||
</button>
|
||||
{/* Divider */}
|
||||
<div className="mx-3 my-1 border-t border-[var(--glass-stroke-base)]" />
|
||||
{/* Episode list */}
|
||||
{episodes.map((ep) => (
|
||||
<button
|
||||
key={ep.id}
|
||||
onClick={() => handleSelect(ep.id)}
|
||||
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm transition-colors cursor-pointer ${
|
||||
episodeId === ep.id
|
||||
? 'text-[var(--glass-tone-info-fg)] bg-[var(--glass-tone-info-bg)] font-medium'
|
||||
: 'text-[var(--glass-text-primary)] hover:bg-[var(--glass-bg-muted)]'
|
||||
}`}
|
||||
>
|
||||
<AppIcon name="film" className="w-4 h-4 text-[var(--glass-text-tertiary)]" />
|
||||
<span>{ep.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AssetToolbar({
|
||||
@@ -32,13 +162,17 @@ export default function AssetToolbar({
|
||||
totalAssets,
|
||||
totalAppearances,
|
||||
totalLocations,
|
||||
totalProps,
|
||||
isBatchSubmitting,
|
||||
isAnalyzingAssets,
|
||||
isGlobalAnalyzing = false,
|
||||
batchProgress,
|
||||
onGenerateAll,
|
||||
onRegenerateAll,
|
||||
onGlobalAnalyze
|
||||
onGlobalAnalyze,
|
||||
episodeId,
|
||||
onEpisodeChange,
|
||||
episodes,
|
||||
}: AssetToolbarProps) {
|
||||
const onRefresh = useRefreshProjectAssets(projectId)
|
||||
const t = useTranslations('assets')
|
||||
@@ -59,6 +193,7 @@ export default function AssetToolbar({
|
||||
const handleDownloadAll = async () => {
|
||||
const characters = assets?.characters ?? []
|
||||
const locations = assets?.locations ?? []
|
||||
const props = assets?.props ?? []
|
||||
|
||||
const imageEntries: Array<{ filename: string; url: string }> = []
|
||||
|
||||
@@ -84,6 +219,14 @@ export default function AssetToolbar({
|
||||
imageEntries.push({ filename: `locations/${safeName}.jpg`, url })
|
||||
}
|
||||
|
||||
for (const prop of props) {
|
||||
const selectedImage = prop.images?.find((img: { isSelected: boolean; imageUrl: string | null }) => img.isSelected) ?? prop.images?.[0]
|
||||
const url = selectedImage?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = prop.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
imageEntries.push({ filename: `props/${safeName}.jpg`, url })
|
||||
}
|
||||
|
||||
if (imageEntries.length === 0) {
|
||||
alert(t('assetLibrary.downloadEmpty'))
|
||||
return
|
||||
@@ -129,8 +272,16 @@ export default function AssetToolbar({
|
||||
<AppIcon name="diamond" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
{t("toolbar.assetManagement")}
|
||||
</span>
|
||||
{/* 剧集筛选 chip */}
|
||||
{episodes.length > 0 && (
|
||||
<EpisodeChip
|
||||
episodeId={episodeId}
|
||||
onEpisodeChange={onEpisodeChange}
|
||||
episodes={episodes}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-[var(--glass-text-tertiary)]">
|
||||
{t("toolbar.assetCount", { total: totalAssets, appearances: totalAppearances, locations: totalLocations })}
|
||||
{t("toolbar.assetCount", { total: totalAssets, appearances: totalAppearances, locations: totalLocations, props: totalProps })}
|
||||
</span>
|
||||
{/* 全局资产分析按钮 */}
|
||||
{onGlobalAnalyze && (
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
CharacterEditModal,
|
||||
LocationCreationModal,
|
||||
LocationEditModal,
|
||||
PropCreationModal,
|
||||
PropEditModal,
|
||||
} from '@/components/shared/assets'
|
||||
import GlobalAssetPicker from '@/components/shared/assets/GlobalAssetPicker'
|
||||
import type { CharacterProfileData } from '@/types/character-profile'
|
||||
@@ -29,6 +31,13 @@ interface EditingLocationState {
|
||||
description: string
|
||||
}
|
||||
|
||||
interface EditingPropState {
|
||||
propId: string
|
||||
propName: string
|
||||
summary: string
|
||||
variantId?: string
|
||||
}
|
||||
|
||||
interface LocationImageEditModalState {
|
||||
locationName: string
|
||||
}
|
||||
@@ -52,7 +61,7 @@ interface AssetsStageModalsProps {
|
||||
projectId: string
|
||||
onRefresh: () => void
|
||||
onClosePreview: () => void
|
||||
handleGenerateImage: (type: 'character' | 'location', id: string, appearanceId?: string) => Promise<void>
|
||||
handleGenerateImage: (type: 'character' | 'location' | 'prop', id: string, appearanceId?: string) => Promise<void>
|
||||
handleUpdateAppearanceDescription: (newDescription: string) => Promise<void>
|
||||
handleUpdateLocationDescription: (newDescription: string) => Promise<void>
|
||||
handleLocationImageEdit: (modifyPrompt: string, extraImageUrls?: string[]) => Promise<void>
|
||||
@@ -64,8 +73,10 @@ interface AssetsStageModalsProps {
|
||||
handleConfirmProfile: (characterId: string, updatedProfileData?: CharacterProfileData) => Promise<void>
|
||||
closeEditingAppearance: () => void
|
||||
closeEditingLocation: () => void
|
||||
closeEditingProp: () => void
|
||||
closeAddCharacter: () => void
|
||||
closeAddLocation: () => void
|
||||
closeAddProp: () => void
|
||||
closeImageEditModal: () => void
|
||||
closeCharacterImageEditModal: () => void
|
||||
isConfirmingCharacter: (characterId: string) => boolean
|
||||
@@ -75,8 +86,10 @@ interface AssetsStageModalsProps {
|
||||
characterImageEditModal: CharacterImageEditModalState | null
|
||||
editingAppearance: EditingAppearanceState | null
|
||||
editingLocation: EditingLocationState | null
|
||||
editingProp: EditingPropState | null
|
||||
showAddCharacter: boolean
|
||||
showAddLocation: boolean
|
||||
showAddProp: boolean
|
||||
voiceDesignCharacter: VoiceDesignCharacterState | null
|
||||
editingProfile: EditingProfileState | null
|
||||
copyFromGlobalTarget: GlobalCopyTarget | null
|
||||
@@ -99,8 +112,10 @@ export default function AssetsStageModals({
|
||||
handleConfirmProfile,
|
||||
closeEditingAppearance,
|
||||
closeEditingLocation,
|
||||
closeEditingProp,
|
||||
closeAddCharacter,
|
||||
closeAddLocation,
|
||||
closeAddProp,
|
||||
closeImageEditModal,
|
||||
closeCharacterImageEditModal,
|
||||
isConfirmingCharacter,
|
||||
@@ -110,8 +125,10 @@ export default function AssetsStageModals({
|
||||
characterImageEditModal,
|
||||
editingAppearance,
|
||||
editingLocation,
|
||||
editingProp,
|
||||
showAddCharacter,
|
||||
showAddLocation,
|
||||
showAddProp,
|
||||
voiceDesignCharacter,
|
||||
editingProfile,
|
||||
copyFromGlobalTarget,
|
||||
@@ -192,6 +209,18 @@ export default function AssetsStageModals({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAddProp && (
|
||||
<PropCreationModal
|
||||
mode="project"
|
||||
projectId={projectId}
|
||||
onClose={closeAddProp}
|
||||
onSuccess={() => {
|
||||
closeAddProp()
|
||||
onRefresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{voiceDesignCharacter && (
|
||||
<VoiceDesignDialog
|
||||
isOpen={!!voiceDesignCharacter}
|
||||
@@ -203,6 +232,19 @@ export default function AssetsStageModals({
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingProp && (
|
||||
<PropEditModal
|
||||
mode="project"
|
||||
propId={editingProp.propId}
|
||||
propName={editingProp.propName}
|
||||
summary={editingProp.summary}
|
||||
variantId={editingProp.variantId}
|
||||
projectId={projectId}
|
||||
onClose={closeEditingProp}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingProfile && (
|
||||
<CharacterProfileDialog
|
||||
isOpen={!!editingProfile}
|
||||
|
||||
@@ -47,6 +47,8 @@ interface CharacterSectionProps {
|
||||
onCopyFromGlobal: (characterId: string) => void // 🆕 从资产中心复制
|
||||
// 辅助函数
|
||||
getAppearances: (character: Character) => CharacterAppearance[]
|
||||
/** 分集筛选:仅显示指定 ID 的角色,null 表示显示全部 */
|
||||
filterIds?: Set<string> | null
|
||||
}
|
||||
|
||||
export default function CharacterSection({
|
||||
@@ -74,7 +76,8 @@ export default function CharacterSection({
|
||||
onVoiceDesign,
|
||||
onVoiceSelectFromHub,
|
||||
onCopyFromGlobal,
|
||||
getAppearances
|
||||
getAppearances,
|
||||
filterIds = null,
|
||||
}: CharacterSectionProps) {
|
||||
const t = useTranslations('assets')
|
||||
const analyzingAssetsState = isAnalyzingAssets
|
||||
@@ -86,9 +89,12 @@ export default function CharacterSection({
|
||||
})
|
||||
: null
|
||||
|
||||
// 🔥 V6.5 重构:直接订阅缓存,消除 props drilling
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
const characters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters])
|
||||
const allCharacters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters])
|
||||
const characters: Character[] = useMemo(
|
||||
() => filterIds ? allCharacters.filter((c) => filterIds.has(c.id)) : allCharacters,
|
||||
[allCharacters, filterIds],
|
||||
)
|
||||
const [highlightedCharacterId, setHighlightedCharacterId] = useState<string | null>(null)
|
||||
const scrollAnimationRef = useRef<number | null>(null)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface LocationCardProps {
|
||||
location: Location
|
||||
assetType?: 'location' | 'prop'
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onRegenerate: (count?: number) => void
|
||||
@@ -40,6 +41,7 @@ interface LocationCardProps {
|
||||
|
||||
export default function LocationCard({
|
||||
location,
|
||||
assetType = 'location',
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
@@ -56,6 +58,7 @@ export default function LocationCard({
|
||||
// 🔥 使用 mutation
|
||||
const uploadImage = useUploadProjectLocationImage(projectId)
|
||||
const t = useTranslations('assets')
|
||||
const assetKey = assetType === 'prop' ? 'prop' : 'location'
|
||||
const { count: generationCount, setCount: setGenerationCount } = useImageGenerationCount('location')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [pendingUploadIndex, setPendingUploadIndex] = useState<number | undefined>(undefined)
|
||||
@@ -218,7 +221,7 @@ export default function LocationCard({
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="w-6 h-6 rounded hover:bg-[var(--glass-tone-danger-bg)] flex items-center justify-center transition-colors"
|
||||
title={t('location.delete')}
|
||||
title={t(`${assetKey}.delete`)}
|
||||
>
|
||||
<AppIcon name="trash" className="w-4 h-4 text-[var(--glass-tone-danger-fg)]" />
|
||||
</button>
|
||||
@@ -305,7 +308,7 @@ export default function LocationCard({
|
||||
? 'bg-[var(--glass-tone-success-fg)] hover:bg-[var(--glass-tone-success-fg)]'
|
||||
: 'bg-[var(--glass-bg-surface-strong)] hover:bg-[var(--glass-bg-surface)]'
|
||||
}`}
|
||||
title={isTaskRunning ? t('image.regenerateStuck') : t('location.regenerateImage')}
|
||||
title={isTaskRunning ? t('image.regenerateStuck') : t(`${assetKey}.regenerateImage`)}
|
||||
>
|
||||
{isGroupTaskRunning ? (
|
||||
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-white" />
|
||||
@@ -329,25 +332,25 @@ export default function LocationCard({
|
||||
const compactHeaderActions = (
|
||||
<>
|
||||
{onCopyFromGlobal && (
|
||||
<button
|
||||
onClick={onCopyFromGlobal}
|
||||
<button
|
||||
onClick={onCopyFromGlobal}
|
||||
className="flex-shrink-0 w-5 h-5 rounded hover:bg-[var(--glass-tone-info-bg)] flex items-center justify-center transition-colors"
|
||||
title={t('character.copyFromGlobal')}
|
||||
>
|
||||
<AppIcon name="copy" className="w-3.5 h-3.5 text-[var(--glass-tone-info-fg)]" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onEdit}
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="flex-shrink-0 w-5 h-5 rounded hover:bg-[var(--glass-bg-muted)] flex items-center justify-center transition-colors"
|
||||
title={t('location.edit')}
|
||||
title={t(`${assetKey}.edit`)}
|
||||
>
|
||||
<AppIcon name="edit" className="w-3.5 h-3.5 text-[var(--glass-text-secondary)]" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex-shrink-0 w-5 h-5 rounded hover:bg-[var(--glass-tone-danger-bg)] flex items-center justify-center transition-colors"
|
||||
title={t('location.delete')}
|
||||
title={t(`${assetKey}.delete`)}
|
||||
>
|
||||
<AppIcon name="trash" className="w-3.5 h-3.5 text-[var(--glass-tone-danger-fg)]" />
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslations } from 'next-intl'
|
||||
* 🔥 V6.5 重构:内部直接订阅 useProjectAssets,消除 props drilling
|
||||
*/
|
||||
|
||||
import { Location } from '@/types/project'
|
||||
import { Location, Prop } from '@/types/project'
|
||||
import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'
|
||||
import LocationCard from './LocationCard'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
@@ -17,13 +17,14 @@ import { AppIcon } from '@/components/ui/icons'
|
||||
interface LocationSectionProps {
|
||||
// 🔥 V6.5 删除:locations prop - 现在内部直接订阅
|
||||
projectId: string
|
||||
assetType?: 'location' | 'prop'
|
||||
activeTaskKeys: Set<string>
|
||||
onClearTaskKey: (key: string) => void
|
||||
onRegisterTransientTaskKey: (key: string) => void
|
||||
// 回调函数
|
||||
onAddLocation: () => void
|
||||
onDeleteLocation: (locationId: string) => void
|
||||
onEditLocation: (location: Location) => void
|
||||
onEditLocation: (location: Location | Prop) => void
|
||||
// 🔥 V6.6 重构:重命名为 handleGenerateImage
|
||||
handleGenerateImage: (type: 'character' | 'location', id: string, appearanceId?: string, count?: number) => Promise<void>
|
||||
onSelectImage: (locationId: string, imageIndex: number | null) => void
|
||||
@@ -34,11 +35,14 @@ interface LocationSectionProps {
|
||||
onImageClick: (imageUrl: string) => void
|
||||
onImageEdit: (locationId: string, imageIndex: number, locationName: string) => void
|
||||
onCopyFromGlobal: (locationId: string) => void // 🆕 从资产中心复制
|
||||
/** 分集筛选:仅显示指定 ID 的场景/道具,null 表示显示全部 */
|
||||
filterIds?: Set<string> | null
|
||||
}
|
||||
|
||||
export default function LocationSection({
|
||||
// 🔥 V6.5 删除:locations prop - 现在内部直接订阅
|
||||
projectId,
|
||||
assetType = 'location',
|
||||
activeTaskKeys,
|
||||
onClearTaskKey,
|
||||
onRegisterTransientTaskKey,
|
||||
@@ -53,13 +57,17 @@ export default function LocationSection({
|
||||
onUndo,
|
||||
onImageClick,
|
||||
onImageEdit,
|
||||
onCopyFromGlobal
|
||||
onCopyFromGlobal,
|
||||
filterIds = null,
|
||||
}: LocationSectionProps) {
|
||||
const t = useTranslations('assets')
|
||||
|
||||
// 🔥 V6.5 重构:直接订阅缓存,消除 props drilling
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
const locations: Location[] = assets?.locations ?? []
|
||||
const allLocations: Array<Location | Prop> = assetType === 'prop'
|
||||
? assets?.props ?? []
|
||||
: assets?.locations ?? []
|
||||
const locations = filterIds ? allLocations.filter((l) => filterIds.has(l.id)) : allLocations
|
||||
const assetKey = assetType === 'prop' ? 'prop' : 'location'
|
||||
|
||||
return (
|
||||
<div className="glass-surface p-6">
|
||||
@@ -68,16 +76,20 @@ export default function LocationSection({
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]">
|
||||
<AppIcon name="imageLandscape" className="h-5 w-5" />
|
||||
</span>
|
||||
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">{t("stage.locationAssets")}</h3>
|
||||
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">
|
||||
{assetType === 'prop' ? t('stage.propAssets') : t("stage.locationAssets")}
|
||||
</h3>
|
||||
<span className="text-sm text-[var(--glass-text-tertiary)] bg-[var(--glass-bg-muted)]/50 px-2 py-1 rounded-lg">
|
||||
{t("stage.locationCounts", { count: locations.length })}
|
||||
{assetType === 'prop'
|
||||
? t('stage.propCounts', { count: locations.length })
|
||||
: t("stage.locationCounts", { count: locations.length })}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddLocation}
|
||||
className="glass-btn-base glass-btn-primary flex items-center gap-2 px-4 py-2 font-medium"
|
||||
>
|
||||
+ {t("location.add")}
|
||||
+ {t(`${assetKey}.add`)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -86,6 +98,7 @@ export default function LocationSection({
|
||||
<LocationCard
|
||||
key={location.id}
|
||||
location={location}
|
||||
assetType={assetType}
|
||||
onEdit={() => onEditLocation(location)}
|
||||
onDelete={() => onDeleteLocation(location.id)}
|
||||
onRegenerate={(count) => {
|
||||
@@ -134,7 +147,7 @@ export default function LocationSection({
|
||||
activeTaskKeys={activeTaskKeys}
|
||||
onClearTaskKey={onClearTaskKey}
|
||||
projectId={projectId}
|
||||
onConfirmSelection={onConfirmSelection}
|
||||
onConfirmSelection={assetType === 'location' ? onConfirmSelection : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { CharacterAppearance } from '@/types/project'
|
||||
import { useProjectAssets, type Character, type Location } from '@/lib/query/hooks'
|
||||
import { useProjectAssets, type Character, type Location, type Prop } from '@/lib/query/hooks'
|
||||
|
||||
// 编辑弹窗状态类型
|
||||
interface EditingAppearance {
|
||||
@@ -27,6 +27,13 @@ interface EditingLocation {
|
||||
description: string
|
||||
}
|
||||
|
||||
interface EditingProp {
|
||||
propId: string
|
||||
propName: string
|
||||
summary: string
|
||||
variantId?: string
|
||||
}
|
||||
|
||||
interface ImageEditModal {
|
||||
locationId: string
|
||||
imageIndex: number
|
||||
@@ -51,6 +58,7 @@ export function useAssetModals({
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
const characters = assets?.characters ?? []
|
||||
const locations = assets?.locations ?? []
|
||||
const props = assets?.props ?? []
|
||||
|
||||
// 获取形象列表(内置实现)
|
||||
const getAppearances = useCallback((character: Character): CharacterAppearance[] => {
|
||||
@@ -61,9 +69,11 @@ export function useAssetModals({
|
||||
const [editingAppearance, setEditingAppearance] = useState<EditingAppearance | null>(null)
|
||||
// 场景编辑弹窗
|
||||
const [editingLocation, setEditingLocation] = useState<EditingLocation | null>(null)
|
||||
const [editingProp, setEditingProp] = useState<EditingProp | null>(null)
|
||||
// 新增弹窗
|
||||
const [showAddCharacter, setShowAddCharacter] = useState(false)
|
||||
const [showAddLocation, setShowAddLocation] = useState(false)
|
||||
const [showAddProp, setShowAddProp] = useState(false)
|
||||
// 图片编辑弹窗
|
||||
const [imageEditModal, setImageEditModal] = useState<ImageEditModal | null>(null)
|
||||
const [characterImageEditModal, setCharacterImageEditModal] = useState<CharacterImageEditModal | null>(null)
|
||||
@@ -126,6 +136,15 @@ export function useAssetModals({
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditProp = (prop: Prop) => {
|
||||
setEditingProp({
|
||||
propId: prop.id,
|
||||
propName: prop.name,
|
||||
summary: prop.summary || prop.images?.[0]?.description || '',
|
||||
variantId: prop.images?.[0]?.id,
|
||||
})
|
||||
}
|
||||
|
||||
// 打开场景图片编辑弹窗
|
||||
const handleOpenLocationImageEdit = (locationId: string, imageIndex: number) => {
|
||||
const location = locations.find(l => l.id === locationId)
|
||||
@@ -151,8 +170,10 @@ export function useAssetModals({
|
||||
// 关闭所有弹窗
|
||||
const closeEditingAppearance = () => setEditingAppearance(null)
|
||||
const closeEditingLocation = () => setEditingLocation(null)
|
||||
const closeEditingProp = () => setEditingProp(null)
|
||||
const closeAddCharacter = () => setShowAddCharacter(false)
|
||||
const closeAddLocation = () => setShowAddLocation(false)
|
||||
const closeAddProp = () => setShowAddProp(false)
|
||||
const closeImageEditModal = () => setImageEditModal(null)
|
||||
const closeCharacterImageEditModal = () => setCharacterImageEditModal(null)
|
||||
const closeAssetSettingModal = () => setShowAssetSettingModal(false)
|
||||
@@ -161,20 +182,25 @@ export function useAssetModals({
|
||||
// 🔥 暴露数据供组件使用
|
||||
characters,
|
||||
locations,
|
||||
props,
|
||||
getAppearances,
|
||||
// 状态
|
||||
editingAppearance,
|
||||
editingLocation,
|
||||
editingProp,
|
||||
showAddCharacter,
|
||||
showAddLocation,
|
||||
showAddProp,
|
||||
imageEditModal,
|
||||
characterImageEditModal,
|
||||
showAssetSettingModal,
|
||||
// Setters
|
||||
setEditingAppearance,
|
||||
setEditingLocation,
|
||||
setEditingProp,
|
||||
setShowAddCharacter,
|
||||
setShowAddLocation,
|
||||
setShowAddProp,
|
||||
setImageEditModal,
|
||||
setCharacterImageEditModal,
|
||||
setShowAssetSettingModal,
|
||||
@@ -183,13 +209,16 @@ export function useAssetModals({
|
||||
handleEditLocationDescription,
|
||||
handleEditAppearance,
|
||||
handleEditLocation,
|
||||
handleEditProp,
|
||||
handleOpenLocationImageEdit,
|
||||
handleOpenCharacterImageEdit,
|
||||
// Close helpers
|
||||
closeEditingAppearance,
|
||||
closeEditingLocation,
|
||||
closeEditingProp,
|
||||
closeAddCharacter,
|
||||
closeAddLocation,
|
||||
closeAddProp,
|
||||
closeImageEditModal,
|
||||
closeCharacterImageEditModal,
|
||||
closeAssetSettingModal
|
||||
|
||||
@@ -10,7 +10,7 @@ type ToastType = 'success' | 'warning' | 'error'
|
||||
type ShowToast = (message: string, type?: ToastType, duration?: number) => void
|
||||
|
||||
export type GlobalCopyTarget = {
|
||||
type: 'character' | 'location' | 'voice'
|
||||
type: 'character' | 'location' | 'prop' | 'voice'
|
||||
targetId: string
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ export function useAssetsCopyFromHub({ projectId, onRefresh, showToast }: UseAss
|
||||
setCopyFromGlobalTarget({ type: 'location', targetId: locationId })
|
||||
}, [])
|
||||
|
||||
const handleCopyPropFromGlobal = useCallback((propId: string) => {
|
||||
setCopyFromGlobalTarget({ type: 'prop', targetId: propId })
|
||||
}, [])
|
||||
|
||||
const handleVoiceSelectFromHub = useCallback((characterId: string) => {
|
||||
setCopyFromGlobalTarget({ type: 'voice', targetId: characterId })
|
||||
}, [])
|
||||
@@ -59,6 +63,8 @@ export function useAssetsCopyFromHub({ projectId, onRefresh, showToast }: UseAss
|
||||
? t('assetLibrary.copySuccessCharacter')
|
||||
: copyFromGlobalTarget.type === 'location'
|
||||
? t('assetLibrary.copySuccessLocation')
|
||||
: copyFromGlobalTarget.type === 'prop'
|
||||
? t('assetLibrary.copySuccessProp')
|
||||
: t('assetLibrary.copySuccessVoice')
|
||||
showToast(successMsg, 'success')
|
||||
setCopyFromGlobalTarget(null)
|
||||
@@ -77,6 +83,7 @@ export function useAssetsCopyFromHub({ projectId, onRefresh, showToast }: UseAss
|
||||
isGlobalCopyInFlight,
|
||||
handleCopyFromGlobal,
|
||||
handleCopyLocationFromGlobal,
|
||||
handleCopyPropFromGlobal,
|
||||
handleVoiceSelectFromHub,
|
||||
handleConfirmCopyFromGlobal,
|
||||
handleCloseCopyPicker,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTranslations } from 'next-intl'
|
||||
import { useCallback } from 'react'
|
||||
import { isAbortError } from '@/lib/error-utils'
|
||||
import {
|
||||
useAssetActions,
|
||||
useProjectAssets,
|
||||
useRefreshProjectAssets,
|
||||
useRegenerateSingleLocationImage,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
|
||||
interface UseLocationActionsProps {
|
||||
projectId: string
|
||||
assetType?: 'location' | 'prop'
|
||||
showToast?: (message: string, type: 'success' | 'warning' | 'error') => void
|
||||
}
|
||||
|
||||
@@ -38,12 +40,15 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
||||
|
||||
export function useLocationActions({
|
||||
projectId,
|
||||
assetType = 'location',
|
||||
showToast
|
||||
}: UseLocationActionsProps) {
|
||||
const t = useTranslations('assets')
|
||||
// 🔥 直接订阅缓存 - 消除 props drilling
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
const locations = assets?.locations ?? []
|
||||
const locations = assetType === 'prop' ? assets?.props ?? [] : assets?.locations ?? []
|
||||
const propActions = useAssetActions({ scope: 'project', projectId, kind: 'prop' })
|
||||
const assetKey = assetType === 'prop' ? 'prop' : 'location'
|
||||
|
||||
// 🔥 使用刷新函数 - mutations 完成后刷新缓存
|
||||
const refreshAssets = useRefreshProjectAssets(projectId)
|
||||
@@ -58,20 +63,28 @@ export function useLocationActions({
|
||||
|
||||
// 删除场景
|
||||
const handleDeleteLocation = useCallback(async (locationId: string) => {
|
||||
if (!confirm(t('location.deleteConfirm'))) return
|
||||
if (!confirm(t(`${assetKey}.deleteConfirm`))) return
|
||||
try {
|
||||
await deleteLocationMutation.mutateAsync(locationId)
|
||||
if (assetType === 'prop') {
|
||||
await propActions.remove(locationId)
|
||||
} else {
|
||||
await deleteLocationMutation.mutateAsync(locationId)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!isAbortError(error)) {
|
||||
alert(t('location.deleteFailed', { error: getErrorMessage(error, t('common.unknownError')) }))
|
||||
alert(t(`${assetKey}.deleteFailed`, { error: getErrorMessage(error, t('common.unknownError')) }))
|
||||
}
|
||||
}
|
||||
}, [deleteLocationMutation, t])
|
||||
}, [assetKey, assetType, deleteLocationMutation, propActions, t])
|
||||
|
||||
// 处理场景图片选择
|
||||
const handleSelectLocationImage = useCallback(async (locationId: string, imageIndex: number | null) => {
|
||||
try {
|
||||
await selectLocationImageMutation.mutateAsync({ locationId, imageIndex })
|
||||
if (assetType === 'prop') {
|
||||
await propActions.selectRender({ id: locationId, imageIndex })
|
||||
} else {
|
||||
await selectLocationImageMutation.mutateAsync({ locationId, imageIndex })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isAbortError(error)) {
|
||||
_ulogInfo('请求被中断(可能是页面刷新),后端仍在执行')
|
||||
@@ -79,10 +92,13 @@ export function useLocationActions({
|
||||
}
|
||||
alert(t('image.selectFailed', { error: getErrorMessage(error, t('common.unknownError')) }))
|
||||
}
|
||||
}, [selectLocationImageMutation, t])
|
||||
}, [assetType, propActions, selectLocationImageMutation, t])
|
||||
|
||||
// 确认选择并删除其他候选图片
|
||||
const handleConfirmLocationSelection = useCallback(async (locationId: string) => {
|
||||
if (assetType === 'prop') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await confirmLocationSelectionMutation.mutateAsync({ locationId })
|
||||
showToast?.(`✓ ${t('image.confirmSuccess')}`, 'success')
|
||||
@@ -93,31 +109,39 @@ export function useLocationActions({
|
||||
}
|
||||
showToast?.(t('image.confirmFailed', { error: getErrorMessage(error, t('common.unknownError')) }), 'error')
|
||||
}
|
||||
}, [confirmLocationSelectionMutation, showToast, t])
|
||||
}, [assetType, confirmLocationSelectionMutation, showToast, t])
|
||||
|
||||
// 单张重新生成场景图片 - 🔥 V6.7: 使用mutation hook
|
||||
const handleRegenerateSingleLocation = useCallback(async (locationId: string, imageIndex: number) => {
|
||||
try {
|
||||
await regenerateSingleImage.mutateAsync({ locationId, imageIndex })
|
||||
if (assetType === 'prop') {
|
||||
await propActions.generate({ id: locationId, imageIndex })
|
||||
} else {
|
||||
await regenerateSingleImage.mutateAsync({ locationId, imageIndex })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!isAbortError(error)) {
|
||||
alert(t('image.regenerateFailed', { error: getErrorMessage(error, t('common.unknownError')) }))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}, [regenerateSingleImage, t])
|
||||
}, [assetType, propActions, regenerateSingleImage, t])
|
||||
|
||||
// 整组重新生成场景图片 - 🔥 V6.7: 使用mutation hook
|
||||
const handleRegenerateLocationGroup = useCallback(async (locationId: string, count?: number) => {
|
||||
try {
|
||||
await regenerateGroup.mutateAsync({ locationId, count })
|
||||
if (assetType === 'prop') {
|
||||
await propActions.generate({ id: locationId, count })
|
||||
} else {
|
||||
await regenerateGroup.mutateAsync({ locationId, count })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!isAbortError(error)) {
|
||||
alert(t('image.regenerateFailed', { error: getErrorMessage(error, t('common.unknownError')) }))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}, [regenerateGroup, t])
|
||||
}, [assetType, propActions, regenerateGroup, t])
|
||||
|
||||
// 更新场景描述 - 🔥 保存到服务器
|
||||
const handleUpdateLocationDescription = useCallback(async (
|
||||
@@ -125,17 +149,30 @@ export function useLocationActions({
|
||||
newDescription: string
|
||||
) => {
|
||||
try {
|
||||
await updateLocationDescriptionMutation.mutateAsync({
|
||||
locationId,
|
||||
description: newDescription,
|
||||
})
|
||||
if (assetType === 'prop') {
|
||||
const prop = locations.find((item) => item.id === locationId)
|
||||
const firstImageId = prop?.images?.[0]?.id
|
||||
await propActions.update(locationId, {
|
||||
summary: newDescription,
|
||||
})
|
||||
if (firstImageId) {
|
||||
await propActions.updateVariant(locationId, firstImageId, {
|
||||
description: newDescription,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await updateLocationDescriptionMutation.mutateAsync({
|
||||
locationId,
|
||||
description: newDescription,
|
||||
})
|
||||
}
|
||||
refreshAssets()
|
||||
} catch (error: unknown) {
|
||||
if (!isAbortError(error)) {
|
||||
_ulogError('更新描述失败:', getErrorMessage(error, t('common.unknownError')))
|
||||
}
|
||||
}
|
||||
}, [refreshAssets, updateLocationDescriptionMutation, t])
|
||||
}, [assetType, locations, propActions, refreshAssets, updateLocationDescriptionMutation, t])
|
||||
|
||||
return {
|
||||
// 🔥 暴露 locations 供组件使用
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { Character, Location, CharacterAppearance } from '@/types/project'
|
||||
import type { Character, Location, Prop, CharacterAppearance } from '@/types/project'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'
|
||||
import { SpotlightCharCard, SpotlightLocationCard, getSelectedLocationImage } from './SpotlightCards'
|
||||
@@ -12,6 +12,7 @@ import { AppIcon } from '@/components/ui/icons'
|
||||
interface Clip {
|
||||
id: string
|
||||
location?: string | null
|
||||
props?: string | null
|
||||
}
|
||||
|
||||
interface ScriptViewAssetsPanelProps {
|
||||
@@ -21,11 +22,13 @@ interface ScriptViewAssetsPanelProps {
|
||||
setSelectedClipId: (clipId: string) => void
|
||||
characters: Character[]
|
||||
locations: Location[]
|
||||
props: Prop[]
|
||||
activeCharIds: string[]
|
||||
activeLocationIds: string[]
|
||||
activePropIds: string[]
|
||||
selectedAppearanceKeys: Set<string>
|
||||
onUpdateClipAssets: (
|
||||
type: 'character' | 'location',
|
||||
type: 'character' | 'location' | 'prop',
|
||||
action: 'add' | 'remove',
|
||||
id: string,
|
||||
optionLabel?: string,
|
||||
@@ -36,6 +39,7 @@ interface ScriptViewAssetsPanelProps {
|
||||
allAssetsHaveImages: boolean
|
||||
globalCharIds: string[]
|
||||
globalLocationIds: string[]
|
||||
globalPropIds: string[]
|
||||
missingAssetsCount: number
|
||||
onGenerateStoryboard?: () => void
|
||||
isSubmittingStoryboardBuild: boolean
|
||||
@@ -120,8 +124,10 @@ export default function ScriptViewAssetsPanel({
|
||||
setSelectedClipId,
|
||||
characters,
|
||||
locations,
|
||||
props,
|
||||
activeCharIds,
|
||||
activeLocationIds,
|
||||
activePropIds,
|
||||
selectedAppearanceKeys,
|
||||
onUpdateClipAssets,
|
||||
onOpenAssetLibrary,
|
||||
@@ -130,6 +136,7 @@ export default function ScriptViewAssetsPanel({
|
||||
allAssetsHaveImages,
|
||||
globalCharIds,
|
||||
globalLocationIds,
|
||||
globalPropIds,
|
||||
missingAssetsCount,
|
||||
onGenerateStoryboard,
|
||||
isSubmittingStoryboardBuild,
|
||||
@@ -141,6 +148,7 @@ export default function ScriptViewAssetsPanel({
|
||||
}: ScriptViewAssetsPanelProps) {
|
||||
const [showAddChar, setShowAddChar] = useState(false)
|
||||
const [showAddLoc, setShowAddLoc] = useState(false)
|
||||
const [showAddProp, setShowAddProp] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [initialAppearanceKeys, setInitialAppearanceKeys] = useState<Set<string>>(new Set())
|
||||
const [pendingAppearanceKeys, setPendingAppearanceKeys] = useState<Set<string>>(new Set())
|
||||
@@ -150,12 +158,17 @@ export default function ScriptViewAssetsPanel({
|
||||
const [initialLocationLabels, setInitialLocationLabels] = useState<Record<string, string>>({})
|
||||
const [isSavingCharacterSelection, setIsSavingCharacterSelection] = useState(false)
|
||||
const [isSavingLocationSelection, setIsSavingLocationSelection] = useState(false)
|
||||
const [pendingPropIds, setPendingPropIds] = useState<Set<string>>(new Set())
|
||||
const [isSavingPropSelection, setIsSavingPropSelection] = useState(false)
|
||||
const hasInitializedCharDraftRef = useRef(false)
|
||||
const hasInitializedLocDraftRef = useRef(false)
|
||||
const hasInitializedPropDraftRef = useRef(false)
|
||||
const charEditorTriggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const charEditorPopoverRef = useRef<HTMLDivElement | null>(null)
|
||||
const locEditorTriggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const locEditorPopoverRef = useRef<HTMLDivElement | null>(null)
|
||||
const propEditorTriggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const propEditorPopoverRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -232,7 +245,17 @@ export default function ScriptViewAssetsPanel({
|
||||
}, [activeLocationIds, assetViewMode, clips, locations, showAddLoc])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAddChar && !showAddLoc) return
|
||||
if (!showAddProp) {
|
||||
hasInitializedPropDraftRef.current = false
|
||||
return
|
||||
}
|
||||
if (hasInitializedPropDraftRef.current) return
|
||||
setPendingPropIds(new Set(activePropIds))
|
||||
hasInitializedPropDraftRef.current = true
|
||||
}, [activePropIds, showAddProp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAddChar && !showAddLoc && !showAddProp) return
|
||||
|
||||
const handlePointerDownOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
@@ -252,12 +275,21 @@ export default function ScriptViewAssetsPanel({
|
||||
setShowAddLoc(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddProp) {
|
||||
const isInPropPopover = propEditorPopoverRef.current?.contains(target)
|
||||
const isInPropTrigger = propEditorTriggerRef.current?.contains(target)
|
||||
if (!isInPropPopover && !isInPropTrigger) {
|
||||
setShowAddProp(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (showAddChar) setShowAddChar(false)
|
||||
if (showAddLoc) setShowAddLoc(false)
|
||||
if (showAddProp) setShowAddProp(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +299,7 @@ export default function ScriptViewAssetsPanel({
|
||||
document.removeEventListener('mousedown', handlePointerDownOutside, true)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showAddChar, showAddLoc])
|
||||
}, [showAddChar, showAddLoc, showAddProp])
|
||||
|
||||
const isAllClipsMode = assetViewMode === 'all'
|
||||
|
||||
@@ -288,6 +320,7 @@ export default function ScriptViewAssetsPanel({
|
||||
|
||||
const hasCharacterSelectionChanges = !setsEqual(initialAppearanceKeys, pendingAppearanceKeys) || hasCharacterLabelChanges
|
||||
const hasLocationSelectionChanges = !setsEqual(new Set(activeLocationIds), pendingLocationIds) || hasLocationLabelChanges
|
||||
const hasPropSelectionChanges = !setsEqual(new Set(activePropIds), pendingPropIds)
|
||||
|
||||
const handleConfirmCharacterSelection = async () => {
|
||||
if (isSavingCharacterSelection) return
|
||||
@@ -369,63 +402,89 @@ export default function ScriptViewAssetsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmPropSelection = async () => {
|
||||
if (isSavingPropSelection) return
|
||||
setIsSavingPropSelection(true)
|
||||
try {
|
||||
const currentIds = new Set(activePropIds)
|
||||
|
||||
for (const propId of currentIds) {
|
||||
if (pendingPropIds.has(propId)) continue
|
||||
await onUpdateClipAssets('prop', 'remove', propId)
|
||||
}
|
||||
|
||||
for (const propId of pendingPropIds) {
|
||||
if (currentIds.has(propId)) continue
|
||||
await onUpdateClipAssets('prop', 'add', propId)
|
||||
}
|
||||
|
||||
setShowAddProp(false)
|
||||
} finally {
|
||||
setIsSavingPropSelection(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col-span-12 lg:col-span-4 flex flex-col min-h-[300px] lg:h-full gap-4">
|
||||
<div className="flex flex-col gap-2 px-2">
|
||||
<div className="relative z-20 flex flex-col gap-2 px-2">
|
||||
<h2 className="text-xl font-bold text-[var(--glass-text-primary)] flex items-center gap-2">
|
||||
<span className="w-1.5 h-6 bg-[var(--glass-accent-from)] rounded-full" /> {tScript('inSceneAssets')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 custom-scrollbar">
|
||||
<button
|
||||
onClick={() => setAssetViewMode('all')}
|
||||
className={`glass-btn-base px-3 py-1.5 text-xs font-medium rounded-full whitespace-nowrap transition-all ${assetViewMode === 'all'
|
||||
? 'glass-btn-primary'
|
||||
: 'glass-btn-secondary text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{tScript('assetView.allClips')}
|
||||
</button>
|
||||
{clips.map((clip, idx) => (
|
||||
<div className="px-1 pt-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
key={clip.id}
|
||||
onClick={() => {
|
||||
setAssetViewMode(clip.id)
|
||||
setSelectedClipId(clip.id)
|
||||
}}
|
||||
className={`glass-btn-base px-3 py-1.5 text-xs font-medium rounded-full whitespace-nowrap transition-all ${assetViewMode === clip.id
|
||||
? 'glass-btn-primary'
|
||||
onClick={() => setAssetViewMode('all')}
|
||||
className={`glass-btn-base px-3 py-1.5 text-xs font-medium rounded-full whitespace-nowrap transition-all ${assetViewMode === 'all'
|
||||
? 'bg-gradient-to-br from-[var(--glass-accent-from)] to-[var(--glass-accent-to)] text-white shadow-none'
|
||||
: 'glass-btn-secondary text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{tScript('segment.title', { index: idx + 1 })}
|
||||
{tScript('assetView.allClips')}
|
||||
</button>
|
||||
))}
|
||||
{clips.map((clip, idx) => (
|
||||
<button
|
||||
key={clip.id}
|
||||
onClick={() => {
|
||||
setAssetViewMode(clip.id)
|
||||
setSelectedClipId(clip.id)
|
||||
}}
|
||||
className={`glass-btn-base px-3 py-1.5 text-xs font-medium rounded-full whitespace-nowrap transition-all ${assetViewMode === clip.id
|
||||
? 'bg-gradient-to-br from-[var(--glass-accent-from)] to-[var(--glass-accent-to)] text-white shadow-none'
|
||||
: 'glass-btn-secondary text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{tScript('segment.title', { index: idx + 1 })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 glass-surface-modal overflow-y-auto p-4 custom-scrollbar flex flex-col gap-6">
|
||||
{assetsLoading && characters.length === 0 && locations.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)] animate-pulse">
|
||||
<TaskStatusInline state={assetsLoadingState} />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative z-10 flex-1 min-h-0 glass-surface-modal overflow-hidden p-4 pr-3">
|
||||
<div className="flex h-full flex-col gap-6 overflow-y-auto pr-1 custom-scrollbar">
|
||||
{assetsLoading && characters.length === 0 && locations.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)] animate-pulse">
|
||||
<TaskStatusInline state={assetsLoadingState} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-bold text-[var(--glass-text-secondary)] flex items-center gap-2">
|
||||
{tScript('asset.activeCharacters')} ({characters.filter((c) => activeCharIds.includes(c.id)).reduce((sum, char) => sum + getSelectedAppearances(char).length, 0)})
|
||||
</h3>
|
||||
<button
|
||||
ref={charEditorTriggerRef}
|
||||
onClick={() => {
|
||||
setShowAddChar((prev) => !prev)
|
||||
setShowAddLoc(false)
|
||||
}}
|
||||
className="inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors"
|
||||
>
|
||||
<AppIcon name="edit" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-bold text-[var(--glass-text-secondary)] flex items-center gap-2">
|
||||
{tScript('asset.activeCharacters')} ({characters.filter((c) => activeCharIds.includes(c.id)).reduce((sum, char) => sum + getSelectedAppearances(char).length, 0)})
|
||||
</h3>
|
||||
<button
|
||||
ref={charEditorTriggerRef}
|
||||
onClick={() => {
|
||||
setShowAddChar((prev) => !prev)
|
||||
setShowAddLoc(false)
|
||||
setShowAddProp(false)
|
||||
}}
|
||||
className="inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors"
|
||||
>
|
||||
<AppIcon name="edit" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddChar && mounted && createPortal(
|
||||
<div ref={charEditorPopoverRef} className="fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl">
|
||||
@@ -528,58 +587,61 @@ export default function ScriptViewAssetsPanel({
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{activeCharIds.length === 0 ? (
|
||||
<div className="text-center text-[var(--glass-text-tertiary)] text-sm py-4">{tScript('screenplay.noCharacter')}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{characters
|
||||
.filter((c) => activeCharIds.includes(c.id))
|
||||
.flatMap((char) => {
|
||||
const selectedApps = getSelectedAppearances(char)
|
||||
if (selectedApps.length === 0) {
|
||||
return (
|
||||
<SpotlightCharCard
|
||||
key={`${char.id}-missing`}
|
||||
char={char}
|
||||
appearance={undefined}
|
||||
isActive={true}
|
||||
onClick={() => { }}
|
||||
onOpenAssetLibrary={onOpenAssetLibrary}
|
||||
onRemove={() => void onUpdateClipAssets('character', 'remove', char.id, tScript('asset.defaultAppearance'))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return selectedApps.map((appearance) => (
|
||||
<SpotlightCharCard
|
||||
key={`${char.id}-${appearance.id}`}
|
||||
char={char}
|
||||
appearance={appearance}
|
||||
isActive={true}
|
||||
onClick={() => { }}
|
||||
onOpenAssetLibrary={onOpenAssetLibrary}
|
||||
onRemove={() => void onUpdateClipAssets('character', 'remove', char.id, appearance.changeReason || tScript('asset.defaultAppearance'))}
|
||||
/>
|
||||
))
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-bold text-[var(--glass-text-secondary)]">{tScript('asset.activeLocations')} ({activeLocationIds.length})</h3>
|
||||
<button
|
||||
ref={locEditorTriggerRef}
|
||||
onClick={() => {
|
||||
setShowAddLoc((prev) => !prev)
|
||||
setShowAddChar(false)
|
||||
}}
|
||||
className="inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors"
|
||||
>
|
||||
<AppIcon name="edit" className="h-4 w-4" />
|
||||
</button>
|
||||
{activeCharIds.length === 0 ? (
|
||||
<div className="text-center text-[var(--glass-text-tertiary)] text-sm py-4">{tScript('screenplay.noCharacter')}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3 px-1 py-1">
|
||||
{characters
|
||||
.filter((c) => activeCharIds.includes(c.id))
|
||||
.flatMap((char) => {
|
||||
const selectedApps = getSelectedAppearances(char)
|
||||
if (selectedApps.length === 0) {
|
||||
return (
|
||||
<div key={`${char.id}-missing`} className="min-w-0">
|
||||
<SpotlightCharCard
|
||||
char={char}
|
||||
appearance={undefined}
|
||||
isActive={true}
|
||||
onClick={() => { }}
|
||||
onOpenAssetLibrary={onOpenAssetLibrary}
|
||||
onRemove={() => void onUpdateClipAssets('character', 'remove', char.id, tScript('asset.defaultAppearance'))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return selectedApps.map((appearance) => (
|
||||
<div key={`${char.id}-${appearance.id}`} className="min-w-0">
|
||||
<SpotlightCharCard
|
||||
char={char}
|
||||
appearance={appearance}
|
||||
isActive={true}
|
||||
onClick={() => { }}
|
||||
onOpenAssetLibrary={onOpenAssetLibrary}
|
||||
onRemove={() => void onUpdateClipAssets('character', 'remove', char.id, appearance.changeReason || tScript('asset.defaultAppearance'))}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-bold text-[var(--glass-text-secondary)]">{tScript('asset.activeLocations')} ({activeLocationIds.length})</h3>
|
||||
<button
|
||||
ref={locEditorTriggerRef}
|
||||
onClick={() => {
|
||||
setShowAddLoc((prev) => !prev)
|
||||
setShowAddChar(false)
|
||||
setShowAddProp(false)
|
||||
}}
|
||||
className="inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors"
|
||||
>
|
||||
<AppIcon name="edit" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddLoc && mounted && createPortal(
|
||||
<div ref={locEditorPopoverRef} className="fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl">
|
||||
<div className="shrink-0 text-xs text-[var(--glass-text-tertiary)]">{tCommon('edit')} · {tScript('asset.activeLocations')}</div>
|
||||
@@ -673,27 +735,131 @@ export default function ScriptViewAssetsPanel({
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{activeLocationIds.length === 0 ? (
|
||||
<div className="text-center text-[var(--glass-text-tertiary)] text-sm py-4">{tScript('screenplay.noLocation')}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{locations.filter((l) => activeLocationIds.includes(l.id)).map((loc) => (
|
||||
<SpotlightLocationCard
|
||||
key={loc.id}
|
||||
location={loc}
|
||||
isActive={true}
|
||||
onClick={() => { }}
|
||||
onOpenAssetLibrary={onOpenAssetLibrary}
|
||||
onRemove={() => void onUpdateClipAssets('location', 'remove', loc.id)}
|
||||
/>
|
||||
))}
|
||||
{activeLocationIds.length === 0 ? (
|
||||
<div className="text-center text-[var(--glass-text-tertiary)] text-sm py-4">{tScript('screenplay.noLocation')}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 px-1 py-1">
|
||||
{locations.filter((l) => activeLocationIds.includes(l.id)).map((loc) => (
|
||||
<div key={loc.id} className="min-w-0">
|
||||
<SpotlightLocationCard
|
||||
location={loc}
|
||||
isActive={true}
|
||||
onClick={() => { }}
|
||||
onOpenAssetLibrary={onOpenAssetLibrary}
|
||||
onRemove={() => void onUpdateClipAssets('location', 'remove', loc.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-bold text-[var(--glass-text-secondary)]">道具 ({activePropIds.length})</h3>
|
||||
<button
|
||||
ref={propEditorTriggerRef}
|
||||
onClick={() => {
|
||||
setShowAddProp((prev) => !prev)
|
||||
setShowAddChar(false)
|
||||
setShowAddLoc(false)
|
||||
}}
|
||||
className="inline-flex h-8 w-8 items-center justify-center text-[var(--glass-text-secondary)] hover:text-[var(--glass-tone-info-fg)] transition-colors"
|
||||
>
|
||||
<AppIcon name="edit" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddProp && mounted && createPortal(
|
||||
<div ref={propEditorPopoverRef} className="fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl">
|
||||
<div className="shrink-0 text-xs text-[var(--glass-text-tertiary)]">{tCommon('edit')} · 道具</div>
|
||||
<div className="mt-3 flex-1 min-h-0 overflow-y-auto pr-1 custom-scrollbar">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{props.map((prop) => {
|
||||
const isSelected = pendingPropIds.has(prop.id)
|
||||
const previewImage = getSelectedLocationImage(prop as unknown as Location)?.imageUrl || null
|
||||
return (
|
||||
<button
|
||||
key={prop.id}
|
||||
onClick={() => {
|
||||
setPendingPropIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (isSelected) {
|
||||
next.delete(prop.id)
|
||||
} else {
|
||||
next.add(prop.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`relative w-full overflow-hidden rounded-lg border-2 text-left transition-colors ${isSelected ? 'border-[var(--glass-stroke-success)]' : 'border-transparent hover:border-[var(--glass-stroke-focus)]'}`}
|
||||
>
|
||||
<div className="aspect-video bg-[var(--glass-bg-muted)]">
|
||||
{previewImage ? (
|
||||
<MediaImageWithLoading
|
||||
src={previewImage}
|
||||
alt={prop.name}
|
||||
containerClassName="h-full w-full"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="truncate px-2 py-1 text-xs font-medium text-[var(--glass-text-secondary)]">
|
||||
{prop.name}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="absolute right-1.5 top-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-[var(--glass-tone-success-fg)] text-white shadow-md">
|
||||
<AppIcon name="checkMicro" className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex shrink-0 items-center justify-end gap-2 border-t border-[var(--glass-stroke-base)] pt-3">
|
||||
<button
|
||||
onClick={() => setShowAddProp(false)}
|
||||
disabled={isSavingPropSelection}
|
||||
className="glass-btn-base glass-btn-secondary rounded-lg px-3 py-1.5 text-xs text-[var(--glass-text-secondary)]"
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleConfirmPropSelection()}
|
||||
disabled={isSavingPropSelection || !hasPropSelectionChanges}
|
||||
className="glass-btn-base glass-btn-primary rounded-lg px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{tCommon('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{activePropIds.length === 0 ? (
|
||||
<div className="text-center text-[var(--glass-text-tertiary)] text-sm py-4">当前片段未选择道具</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 px-1 py-1">
|
||||
{props.filter((prop) => activePropIds.includes(prop.id)).map((prop) => (
|
||||
<div key={prop.id} className="min-w-0">
|
||||
<SpotlightLocationCard
|
||||
location={prop as unknown as Location}
|
||||
isActive={true}
|
||||
onClick={() => { }}
|
||||
onOpenAssetLibrary={onOpenAssetLibrary}
|
||||
onRemove={() => void onUpdateClipAssets('prop', 'remove', prop.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 mb-4">
|
||||
{!allAssetsHaveImages && globalCharIds.length + globalLocationIds.length > 0 && (
|
||||
{!allAssetsHaveImages && globalCharIds.length + globalLocationIds.length + globalPropIds.length > 0 && (
|
||||
<div className="mb-3 p-4 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-2xl shadow-sm">
|
||||
<p className="text-sm font-medium text-[var(--glass-text-primary)]">{tScript('generate.missingAssets', { count: missingAssetsCount })}</p>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)] mt-0.5">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { logInfo as _ulogInfo } from '@/lib/logging/core'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Character, Location } from '@/types/project'
|
||||
import type { Character, Location, Prop } from '@/types/project'
|
||||
import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import {
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
} from './clip-asset-utils'
|
||||
import ScriptViewScriptPanel from './ScriptViewScriptPanel'
|
||||
import ScriptViewAssetsPanel from './ScriptViewAssetsPanel'
|
||||
import { reuseStringArrayIfEqual, reuseStringSetIfEqual } from './selection-sync'
|
||||
import {
|
||||
getPrimaryAppearance,
|
||||
getSelectedAppearances,
|
||||
processCharacterInClip,
|
||||
processLocationInClip,
|
||||
processPropInClip,
|
||||
} from './asset-state-utils'
|
||||
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
|
||||
|
||||
@@ -29,6 +31,7 @@ interface Clip {
|
||||
screenplay?: string | null
|
||||
characters: string | null
|
||||
location: string | null
|
||||
props: string | null
|
||||
}
|
||||
|
||||
interface Panel {
|
||||
@@ -90,9 +93,11 @@ export default function ScriptView({
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
const characters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters])
|
||||
const locations: Location[] = useMemo(() => assets?.locations ?? [], [assets?.locations])
|
||||
const props: Prop[] = useMemo(() => assets?.props ?? [], [assets?.props])
|
||||
|
||||
const [activeCharIds, setActiveCharIds] = useState<string[]>([])
|
||||
const [activeLocationIds, setActiveLocationIds] = useState<string[]>([])
|
||||
const [activePropIds, setActivePropIds] = useState<string[]>([])
|
||||
const [selectedAppearanceKeys, setSelectedAppearanceKeys] = useState<Set<string>>(new Set())
|
||||
|
||||
const isManuallyEditingRef = useRef(false)
|
||||
@@ -122,12 +127,14 @@ export default function ScriptView({
|
||||
|
||||
let charNames = new Set<string>()
|
||||
let locNames = new Set<string>()
|
||||
let propNames = new Set<string>()
|
||||
let charAppearanceSet = new Set<string>()
|
||||
|
||||
if (assetViewMode === 'all') {
|
||||
const all = getAllClipsAssets()
|
||||
charNames = all.allCharNames
|
||||
locNames = all.allLocNames
|
||||
propNames = all.allPropNames
|
||||
charAppearanceSet = all.allCharAppearanceSet
|
||||
} else {
|
||||
const clip = clips.find((c) => c.id === assetViewMode)
|
||||
@@ -135,6 +142,7 @@ export default function ScriptView({
|
||||
const parsed = parseClipAssets(clip)
|
||||
charNames = parsed.charNames
|
||||
locNames = parsed.locNames
|
||||
propNames = parsed.propNames
|
||||
charAppearanceSet = parsed.charAppearanceSet
|
||||
}
|
||||
}
|
||||
@@ -167,14 +175,18 @@ export default function ScriptView({
|
||||
const matchedLocIds = locations
|
||||
.filter((l) => Array.from(locNames).some((clipLocName) => fuzzyMatchLocation(clipLocName, l.name)))
|
||||
.map((l) => l.id)
|
||||
const matchedPropIds = props
|
||||
.filter((prop) => Array.from(propNames).some((clipPropName) => clipPropName.toLowerCase() === prop.name.toLowerCase()))
|
||||
.map((prop) => prop.id)
|
||||
|
||||
setActiveCharIds(matchedCharIds)
|
||||
setActiveLocationIds(matchedLocIds)
|
||||
setSelectedAppearanceKeys(newSelectedKeys)
|
||||
}, [assetViewMode, characters, clips, getAllClipsAssets, locations])
|
||||
setActiveCharIds((previous) => reuseStringArrayIfEqual(previous, matchedCharIds))
|
||||
setActiveLocationIds((previous) => reuseStringArrayIfEqual(previous, matchedLocIds))
|
||||
setActivePropIds((previous) => reuseStringArrayIfEqual(previous, matchedPropIds))
|
||||
setSelectedAppearanceKeys((previous) => reuseStringSetIfEqual(previous, newSelectedKeys))
|
||||
}, [assetViewMode, characters, clips, getAllClipsAssets, locations, props])
|
||||
|
||||
const handleUpdateClipAssets = async (
|
||||
type: 'character' | 'location',
|
||||
type: 'character' | 'location' | 'prop',
|
||||
action: 'add' | 'remove',
|
||||
id: string,
|
||||
optionLabel?: string,
|
||||
@@ -265,6 +277,42 @@ export default function ScriptView({
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'prop') {
|
||||
const targetProp = props.find((item) => item.id === id)
|
||||
if (!targetProp) return
|
||||
|
||||
if (isAllMode && action === 'remove') {
|
||||
for (const clip of clips) {
|
||||
const newValue = processPropInClip({
|
||||
clip,
|
||||
action: 'remove',
|
||||
targetProp,
|
||||
})
|
||||
if (newValue !== null) {
|
||||
await onClipUpdate(clip.id, { props: newValue })
|
||||
}
|
||||
}
|
||||
setActivePropIds(activePropIds.filter((propId) => propId !== id))
|
||||
return
|
||||
}
|
||||
|
||||
const clip = clips.find((c) => c.id === targetClipId)
|
||||
if (!clip) return
|
||||
|
||||
const newValue = processPropInClip({
|
||||
clip,
|
||||
action,
|
||||
targetProp,
|
||||
})
|
||||
const newActiveIds =
|
||||
action === 'add' ? [...activePropIds, id] : activePropIds.filter((propId) => propId !== id)
|
||||
setActivePropIds(newActiveIds)
|
||||
if (newValue !== null) {
|
||||
await onClipUpdate(targetClipId!, { props: newValue })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const targetLoc = locations.find((l) => l.id === id)
|
||||
if (!targetLoc) return
|
||||
|
||||
@@ -320,7 +368,7 @@ export default function ScriptView({
|
||||
}
|
||||
}
|
||||
|
||||
const { allCharNames: globalCharNames, allLocNames: globalLocNames } = getAllClipsAssets()
|
||||
const { allCharNames: globalCharNames, allLocNames: globalLocNames, allPropNames: globalPropNames } = getAllClipsAssets()
|
||||
|
||||
const globalCharIds = characters
|
||||
.filter((c) => {
|
||||
@@ -332,9 +380,13 @@ export default function ScriptView({
|
||||
const globalLocationIds = locations
|
||||
.filter((l) => Array.from(globalLocNames).some((clipLocName) => fuzzyMatchLocation(clipLocName, l.name)))
|
||||
.map((l) => l.id)
|
||||
const globalPropIds = props
|
||||
.filter((prop) => Array.from(globalPropNames).some((clipPropName) => clipPropName.toLowerCase() === prop.name.toLowerCase()))
|
||||
.map((prop) => prop.id)
|
||||
|
||||
const globalActiveChars = characters.filter((c) => globalCharIds.includes(c.id))
|
||||
const globalActiveLocations = locations.filter((l) => globalLocationIds.includes(l.id))
|
||||
const globalActiveProps = props.filter((prop) => globalPropIds.includes(prop.id))
|
||||
|
||||
const charsWithoutImage = globalActiveChars.filter((char) => {
|
||||
const appearance = getPrimaryAppearance(char)
|
||||
@@ -348,9 +400,15 @@ export default function ScriptView({
|
||||
: undefined) || loc.images?.find((img) => img.isSelected) || loc.images?.find((img) => img.imageUrl)
|
||||
return !image?.imageUrl
|
||||
})
|
||||
const propsWithoutImage = globalActiveProps.filter((prop) => {
|
||||
const image = (prop.selectedImageId
|
||||
? prop.images?.find((img) => img.id === prop.selectedImageId)
|
||||
: undefined) || prop.images?.find((img) => img.isSelected) || prop.images?.find((img) => img.imageUrl)
|
||||
return !image?.imageUrl
|
||||
})
|
||||
|
||||
const allAssetsHaveImages = charsWithoutImage.length === 0 && locationsWithoutImage.length === 0
|
||||
const missingAssetsCount = charsWithoutImage.length + locationsWithoutImage.length
|
||||
const allAssetsHaveImages = charsWithoutImage.length === 0 && locationsWithoutImage.length === 0 && propsWithoutImage.length === 0
|
||||
const missingAssetsCount = charsWithoutImage.length + locationsWithoutImage.length + propsWithoutImage.length
|
||||
|
||||
return (
|
||||
<div className="w-full grid grid-cols-12 gap-6 min-h-[400px] lg:h-[calc(100vh-180px)] animate-fadeIn">
|
||||
@@ -373,8 +431,10 @@ export default function ScriptView({
|
||||
setSelectedClipId={setSelectedClipId}
|
||||
characters={characters}
|
||||
locations={locations}
|
||||
props={props}
|
||||
activeCharIds={activeCharIds}
|
||||
activeLocationIds={activeLocationIds}
|
||||
activePropIds={activePropIds}
|
||||
selectedAppearanceKeys={selectedAppearanceKeys}
|
||||
onUpdateClipAssets={handleUpdateClipAssets}
|
||||
onOpenAssetLibrary={onOpenAssetLibrary}
|
||||
@@ -383,6 +443,7 @@ export default function ScriptView({
|
||||
allAssetsHaveImages={allAssetsHaveImages}
|
||||
globalCharIds={globalCharIds}
|
||||
globalLocationIds={globalLocationIds}
|
||||
globalPropIds={globalPropIds}
|
||||
missingAssetsCount={missingAssetsCount}
|
||||
onGenerateStoryboard={onGenerateStoryboard}
|
||||
isSubmittingStoryboardBuild={isSubmittingStoryboardBuild}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function SpotlightCharCard({
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
group relative rounded-xl cursor-pointer transition-all duration-500 ease-out
|
||||
group relative min-w-0 rounded-xl cursor-pointer transition-all duration-500 ease-out
|
||||
${isActive
|
||||
? 'opacity-100 scale-100 ring-2 ring-[var(--glass-focus-ring-strong)] shadow-[var(--glass-shadow-md)] bg-[var(--glass-bg-surface)]'
|
||||
: 'opacity-50 scale-95 grayscale hover:grayscale-0 hover:opacity-100 hover:scale-95 bg-[var(--glass-bg-muted)]'
|
||||
@@ -95,7 +95,7 @@ export function SpotlightCharCard({
|
||||
onRemove()
|
||||
}
|
||||
}}
|
||||
className="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 w-5 h-5 bg-[var(--glass-tone-danger-fg)] rounded-full text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all shadow-md hover:bg-[var(--glass-tone-danger-fg)] hover:scale-110 z-20"
|
||||
className="absolute right-2 top-2 h-5 w-5 rounded-full bg-[var(--glass-tone-danger-fg)] text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all shadow-md hover:bg-[var(--glass-tone-danger-fg)] hover:scale-110 z-20"
|
||||
title={tScript('asset.removeFromClip')}
|
||||
>
|
||||
<AppIcon name="closeSm" className="h-3 w-3" />
|
||||
@@ -209,7 +209,7 @@ export function SpotlightLocationCard({
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
group relative rounded-xl cursor-pointer transition-all duration-500 ease-out
|
||||
group relative min-w-0 rounded-xl cursor-pointer transition-all duration-500 ease-out
|
||||
${isActive
|
||||
? 'opacity-100 scale-100 ring-2 ring-[var(--glass-stroke-success)] shadow-[var(--glass-shadow-md)] bg-[var(--glass-bg-surface)]'
|
||||
: 'opacity-50 scale-95 grayscale hover:grayscale-0 hover:opacity-100 hover:scale-95 bg-[var(--glass-bg-muted)]'
|
||||
@@ -224,7 +224,7 @@ export function SpotlightLocationCard({
|
||||
onRemove()
|
||||
}
|
||||
}}
|
||||
className="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 w-5 h-5 bg-[var(--glass-tone-danger-fg)] rounded-full text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all shadow-md hover:bg-[var(--glass-tone-danger-fg)] hover:scale-110 z-20"
|
||||
className="absolute right-2 top-2 h-5 w-5 rounded-full bg-[var(--glass-tone-danger-fg)] text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all shadow-md hover:bg-[var(--glass-tone-danger-fg)] hover:scale-110 z-20"
|
||||
title={tScript('asset.removeFromClip')}
|
||||
>
|
||||
<AppIcon name="closeSm" className="h-3 w-3" />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Character, CharacterAppearance, Location } from '@/types/project'
|
||||
import type { Character, CharacterAppearance, Location, Prop } from '@/types/project'
|
||||
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
|
||||
|
||||
interface ClipLike {
|
||||
characters: string | null
|
||||
location: string | null
|
||||
props?: string | null
|
||||
}
|
||||
|
||||
export function getPrimaryAppearance(char: Character): CharacterAppearance | undefined {
|
||||
@@ -188,3 +189,34 @@ export function processLocationInClip(params: {
|
||||
|
||||
return newLocationNames.join(',')
|
||||
}
|
||||
|
||||
export function processPropInClip(params: {
|
||||
clip: ClipLike
|
||||
action: 'add' | 'remove'
|
||||
targetProp: Prop
|
||||
}): string | null {
|
||||
const { clip, action, targetProp } = params
|
||||
let currentNames: string[] = []
|
||||
if (clip.props) {
|
||||
try {
|
||||
const parsed = JSON.parse(clip.props)
|
||||
currentNames = Array.isArray(parsed)
|
||||
? parsed.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
|
||||
: []
|
||||
} catch {
|
||||
currentNames = clip.props.split(',').map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
const beforeLen = currentNames.length
|
||||
if (action === 'add') {
|
||||
if (currentNames.some((name) => name.toLowerCase() === targetProp.name.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return JSON.stringify([...currentNames, targetProp.name])
|
||||
}
|
||||
|
||||
const nextNames = currentNames.filter((name) => name.toLowerCase() !== targetProp.name.toLowerCase())
|
||||
if (nextNames.length === beforeLen) return null
|
||||
return nextNames.length > 0 ? JSON.stringify(nextNames) : null
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
type ClipAssetSource = {
|
||||
characters?: string | null
|
||||
location?: string | null
|
||||
props?: string | null
|
||||
}
|
||||
|
||||
export type ParsedClipAssets = {
|
||||
charNames: Set<string>
|
||||
locNames: Set<string>
|
||||
propNames: Set<string>
|
||||
charAppearanceSet: Set<string>
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@ export function fuzzyMatchLocation(clipLocName: string, libraryLocName: string):
|
||||
export function parseClipAssets(clip: ClipAssetSource): ParsedClipAssets {
|
||||
const charNames = new Set<string>()
|
||||
const locNames = new Set<string>()
|
||||
const propNames = new Set<string>()
|
||||
const charAppearanceSet = new Set<string>()
|
||||
|
||||
if (clip.characters) {
|
||||
@@ -80,20 +83,39 @@ export function parseClipAssets(clip: ClipAssetSource): ParsedClipAssets {
|
||||
}
|
||||
}
|
||||
|
||||
return { charNames, locNames, charAppearanceSet }
|
||||
if (clip.props) {
|
||||
try {
|
||||
const parsed = JSON.parse(clip.props)
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach((prop) => {
|
||||
const trimmed = typeof prop === 'string' ? prop.trim() : ''
|
||||
if (trimmed) propNames.add(trimmed)
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
clip.props.split(',').forEach((prop) => {
|
||||
const trimmed = prop.trim()
|
||||
if (trimmed) propNames.add(trimmed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { charNames, locNames, propNames, charAppearanceSet }
|
||||
}
|
||||
|
||||
export function getAllClipsAssets(clips: ClipAssetSource[]) {
|
||||
const allCharNames = new Set<string>()
|
||||
const allLocNames = new Set<string>()
|
||||
const allPropNames = new Set<string>()
|
||||
const allCharAppearanceSet = new Set<string>()
|
||||
|
||||
clips.forEach((clip) => {
|
||||
const { charNames, locNames, charAppearanceSet } = parseClipAssets(clip)
|
||||
const { charNames, locNames, propNames, charAppearanceSet } = parseClipAssets(clip)
|
||||
charNames.forEach(n => allCharNames.add(n))
|
||||
locNames.forEach(n => allLocNames.add(n))
|
||||
propNames.forEach(n => allPropNames.add(n))
|
||||
charAppearanceSet.forEach(k => allCharAppearanceSet.add(k))
|
||||
})
|
||||
|
||||
return { allCharNames, allLocNames, allCharAppearanceSet }
|
||||
return { allCharNames, allLocNames, allPropNames, allCharAppearanceSet }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export function reuseStringArrayIfEqual(previous: string[], next: string[]): string[] {
|
||||
if (previous.length !== next.length) {
|
||||
return next
|
||||
}
|
||||
|
||||
for (let index = 0; index < previous.length; index += 1) {
|
||||
if (previous[index] !== next[index]) {
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
return previous
|
||||
}
|
||||
|
||||
export function reuseStringSetIfEqual(previous: Set<string>, next: Set<string>): Set<string> {
|
||||
if (previous.size !== next.size) {
|
||||
return next
|
||||
}
|
||||
|
||||
for (const value of previous) {
|
||||
if (!next.has(value)) {
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
return previous
|
||||
}
|
||||
@@ -95,6 +95,7 @@ export function usePanelVariant({ projectId, episodeId, setLocalStoryboards }: U
|
||||
imageUrl: null,
|
||||
imageTaskRunning: true, // 🔥 显示加载状态
|
||||
characters: null,
|
||||
props: null,
|
||||
location: null,
|
||||
candidateImages: null,
|
||||
srtSegment: null,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { CharacterCard } from './CharacterCard'
|
||||
import { LocationCard } from './LocationCard'
|
||||
import { VoiceCard } from './VoiceCard'
|
||||
@@ -9,65 +10,14 @@ import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { SegmentedControl } from '@/components/ui/SegmentedControl'
|
||||
|
||||
|
||||
|
||||
interface Character {
|
||||
id: string
|
||||
name: string
|
||||
folderId: string | null
|
||||
customVoiceUrl: string | null
|
||||
appearances: Array<{
|
||||
id: string
|
||||
appearanceIndex: number
|
||||
changeReason: string
|
||||
description: string | null
|
||||
imageUrl: string | null
|
||||
imageUrls: string[]
|
||||
selectedIndex: number | null
|
||||
effectiveSelectedIndex?: number | null
|
||||
previousImageUrl: string | null
|
||||
previousImageUrls: string[]
|
||||
imageTaskRunning: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
interface Location {
|
||||
id: string
|
||||
name: string
|
||||
summary: string | null
|
||||
folderId: string | null
|
||||
images: Array<{
|
||||
id: string
|
||||
imageIndex: number
|
||||
description: string | null
|
||||
imageUrl: string | null
|
||||
previousImageUrl: string | null
|
||||
isSelected: boolean
|
||||
imageTaskRunning: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
interface Voice {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
voiceId: string | null
|
||||
voiceType: string
|
||||
customVoiceUrl: string | null
|
||||
voicePrompt: string | null
|
||||
gender: string | null
|
||||
language: string
|
||||
folderId: string | null
|
||||
}
|
||||
|
||||
import { groupAssetsByKind } from '@/lib/assets/grouping'
|
||||
import type { AssetSummary } from '@/lib/assets/contracts'
|
||||
interface AssetGridProps {
|
||||
characters: Character[]
|
||||
locations: Location[]
|
||||
voices: Voice[]
|
||||
assets: AssetSummary[]
|
||||
loading: boolean
|
||||
onAddCharacter: () => void
|
||||
onAddLocation: () => void
|
||||
onAddProp: () => void
|
||||
onAddVoice: () => void
|
||||
onDownloadAll?: () => void
|
||||
isDownloading?: boolean
|
||||
@@ -77,21 +27,111 @@ interface AssetGridProps {
|
||||
onVoiceDesign?: (characterId: string, characterName: string) => void
|
||||
onCharacterEdit?: (character: unknown, appearance: unknown) => void
|
||||
onLocationEdit?: (location: unknown, imageIndex: number) => void
|
||||
onPropEdit?: (prop: unknown, imageIndex: number) => void
|
||||
onVoiceSelect?: (characterId: string) => void
|
||||
}
|
||||
|
||||
// ─── 新建资产下拉菜单 ──────────────────────────────────
|
||||
function AddAssetDropdown({
|
||||
onAddCharacter,
|
||||
onAddLocation,
|
||||
onAddProp,
|
||||
onAddVoice,
|
||||
}: {
|
||||
onAddCharacter: () => void
|
||||
onAddLocation: () => void
|
||||
onAddProp: () => void
|
||||
onAddVoice: () => void
|
||||
}) {
|
||||
const t = useTranslations('assetHub')
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null)
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
setMenuPos({
|
||||
top: rect.bottom + 6,
|
||||
right: window.innerWidth - rect.right,
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
updatePosition()
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
triggerRef.current?.contains(e.target as Node) ||
|
||||
menuRef.current?.contains(e.target as Node)
|
||||
) return
|
||||
setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [open, updatePosition])
|
||||
|
||||
const handleSelect = (action: () => void) => {
|
||||
setOpen(false)
|
||||
action()
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: t('addCharacter'), icon: 'user' as const, action: onAddCharacter },
|
||||
{ label: t('addLocation'), icon: 'image' as const, action: onAddLocation },
|
||||
{ label: t('addProp'), icon: 'diamond' as const, action: onAddProp },
|
||||
{ label: t('addVoice'), icon: 'mic' as const, action: onAddVoice },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm flex items-center gap-1.5"
|
||||
>
|
||||
<AppIcon name="plus" className="w-4 h-4" />
|
||||
<span>{t('addAsset')}</span>
|
||||
<AppIcon
|
||||
name="chevronDown"
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && menuPos && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[9999] min-w-[160px] py-1.5 rounded-xl bg-white dark:bg-[#2c2c2e] shadow-[0_8px_32px_rgba(0,0,0,0.12),0_2px_8px_rgba(0,0,0,0.08)] border border-[var(--glass-stroke-base)] animate-in fade-in-0 zoom-in-95 duration-150"
|
||||
style={{ top: menuPos.top, right: menuPos.right }}
|
||||
>
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => handleSelect(item.action)}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--glass-text-primary)] hover:bg-[var(--glass-bg-muted)] transition-colors cursor-pointer"
|
||||
>
|
||||
<AppIcon name={item.icon} className="w-4 h-4 text-[var(--glass-text-tertiary)]" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 内联 SVG 图标
|
||||
const PlusIcon = ({ className }: { className?: string }) => (
|
||||
<AppIcon name="plus" className={className} />
|
||||
)
|
||||
|
||||
export function AssetGrid({
|
||||
characters,
|
||||
locations,
|
||||
voices,
|
||||
assets,
|
||||
loading,
|
||||
onAddCharacter,
|
||||
onAddLocation,
|
||||
onAddProp,
|
||||
onAddVoice,
|
||||
onDownloadAll,
|
||||
isDownloading,
|
||||
@@ -101,6 +141,7 @@ export function AssetGrid({
|
||||
onVoiceDesign,
|
||||
onCharacterEdit,
|
||||
onLocationEdit,
|
||||
onPropEdit,
|
||||
onVoiceSelect
|
||||
}: AssetGridProps) {
|
||||
const t = useTranslations('assetHub')
|
||||
@@ -114,12 +155,77 @@ export function AssetGrid({
|
||||
: null
|
||||
void _selectedFolderId
|
||||
|
||||
const [filter, setFilter] = useState<'all' | 'character' | 'location' | 'voice'>('all')
|
||||
const [sectionPage, setSectionPage] = useState<{ character: number; location: number; voice: number }>({
|
||||
const [filter, setFilter] = useState<'all' | 'character' | 'location' | 'prop' | 'voice'>('all')
|
||||
const [sectionPage, setSectionPage] = useState<{ character: number; location: number; prop: number; voice: number }>({
|
||||
character: 1,
|
||||
location: 1,
|
||||
prop: 1,
|
||||
voice: 1,
|
||||
})
|
||||
const groupedAssets = groupAssetsByKind(assets)
|
||||
const characters = groupedAssets.character.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
folderId: asset.folderId,
|
||||
customVoiceUrl: asset.voice.customVoiceUrl,
|
||||
appearances: asset.variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
appearanceIndex: variant.index,
|
||||
changeReason: variant.label,
|
||||
description: variant.description,
|
||||
imageUrl: variant.renders.find((render) => render.isSelected)?.imageUrl
|
||||
?? variant.renders[0]?.imageUrl
|
||||
?? null,
|
||||
imageUrls: variant.renders.map((render) => render.imageUrl ?? '').filter((value) => value.length > 0),
|
||||
selectedIndex: variant.selectionState.selectedRenderIndex,
|
||||
effectiveSelectedIndex: variant.selectionState.selectedRenderIndex,
|
||||
previousImageUrl: variant.renders[0]?.previousImageUrl ?? null,
|
||||
previousImageUrls: variant.renders.map((render) => render.previousImageUrl ?? '').filter((value) => value.length > 0),
|
||||
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning || variant.renders.some((render) => render.taskState.isRunning),
|
||||
})),
|
||||
}))
|
||||
const locations = groupedAssets.location.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
summary: asset.summary,
|
||||
folderId: asset.folderId,
|
||||
images: asset.variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
imageIndex: variant.index,
|
||||
description: variant.description,
|
||||
imageUrl: variant.renders[0]?.imageUrl ?? null,
|
||||
previousImageUrl: variant.renders[0]?.previousImageUrl ?? null,
|
||||
isSelected: variant.renders[0]?.isSelected ?? false,
|
||||
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning || variant.renders.some((render) => render.taskState.isRunning),
|
||||
})),
|
||||
}))
|
||||
const props = groupedAssets.prop.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
summary: asset.summary,
|
||||
folderId: asset.folderId,
|
||||
images: asset.variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
imageIndex: variant.index,
|
||||
description: variant.description,
|
||||
imageUrl: variant.renders[0]?.imageUrl ?? null,
|
||||
previousImageUrl: variant.renders[0]?.previousImageUrl ?? null,
|
||||
isSelected: variant.renders[0]?.isSelected ?? false,
|
||||
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning || variant.renders.some((render) => render.taskState.isRunning),
|
||||
})),
|
||||
}))
|
||||
const voices = groupedAssets.voice.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
description: asset.voiceMeta.description,
|
||||
voiceId: asset.voiceMeta.voiceId,
|
||||
voiceType: asset.voiceMeta.voiceType,
|
||||
customVoiceUrl: asset.voiceMeta.customVoiceUrl,
|
||||
voicePrompt: asset.voiceMeta.voicePrompt,
|
||||
gender: asset.voiceMeta.gender,
|
||||
language: asset.voiceMeta.language,
|
||||
folderId: asset.folderId,
|
||||
}))
|
||||
|
||||
const pageSize = 40
|
||||
const paginate = <T,>(rows: T[], page: number) => {
|
||||
@@ -133,15 +239,16 @@ export function AssetGrid({
|
||||
}
|
||||
}
|
||||
|
||||
const setPage = (type: 'character' | 'location' | 'voice', page: number) => {
|
||||
const setPage = (type: 'character' | 'location' | 'prop' | 'voice', page: number) => {
|
||||
setSectionPage((prev) => ({ ...prev, [type]: page }))
|
||||
}
|
||||
|
||||
const charactersPage = paginate(characters, sectionPage.character)
|
||||
const locationsPage = paginate(locations, sectionPage.location)
|
||||
const propsPage = paginate(props, sectionPage.prop)
|
||||
const voicesPage = paginate(voices, sectionPage.voice)
|
||||
|
||||
const renderPagination = (type: 'character' | 'location' | 'voice', page: number, totalPages: number) => {
|
||||
const renderPagination = (type: 'character' | 'location' | 'prop' | 'voice', page: number, totalPages: number) => {
|
||||
if (totalPages <= 1) return null
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
@@ -174,12 +281,13 @@ export function AssetGrid({
|
||||
)
|
||||
}
|
||||
|
||||
const isEmpty = characters.length === 0 && locations.length === 0 && voices.length === 0
|
||||
const isEmpty = characters.length === 0 && locations.length === 0 && props.length === 0 && voices.length === 0
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: t('allAssets') },
|
||||
{ id: 'character', label: t('characters') },
|
||||
{ id: 'location', label: t('locations') },
|
||||
{ id: 'prop', label: t('props') },
|
||||
{ id: 'voice', label: t('voices') },
|
||||
]
|
||||
|
||||
@@ -188,15 +296,11 @@ export function AssetGrid({
|
||||
{/* Header: 筛选 Tab + 操作按钮 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* 左侧筛选 */}
|
||||
{(() => {
|
||||
return (
|
||||
<SegmentedControl
|
||||
options={tabs.map(tab => ({ value: tab.id, label: tab.label }))}
|
||||
value={filter}
|
||||
onChange={(val) => setFilter(val as 'all' | 'character' | 'location' | 'voice')}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<SegmentedControl
|
||||
options={tabs.map(tab => ({ value: tab.id, label: tab.label }))}
|
||||
value={filter}
|
||||
onChange={(val) => setFilter(val as 'all' | 'character' | 'location' | 'prop' | 'voice')}
|
||||
/>
|
||||
|
||||
{/* 右侧操作按钮 */}
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -211,27 +315,12 @@ export function AssetGrid({
|
||||
<span>{isDownloading ? t('downloading') : t('downloadAll')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onAddCharacter}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>{t('addCharacter')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddLocation}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>{t('addLocation')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddVoice}
|
||||
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>{t('addVoice')}</span>
|
||||
</button>
|
||||
<AddAssetDropdown
|
||||
onAddCharacter={onAddCharacter}
|
||||
onAddLocation={onAddLocation}
|
||||
onAddProp={onAddProp}
|
||||
onAddVoice={onAddVoice}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -292,6 +381,28 @@ export function AssetGrid({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(filter === 'all' || filter === 'prop') && props.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-[var(--glass-text-primary)] mb-3 flex items-center gap-2">
|
||||
{t('props')}
|
||||
<span className="glass-chip glass-chip-neutral px-2 py-0.5">{props.length}</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{propsPage.items.map((prop) => (
|
||||
<LocationCard
|
||||
key={prop.id}
|
||||
location={prop}
|
||||
assetType="prop"
|
||||
onImageClick={onImageClick}
|
||||
onImageEdit={onImageEdit}
|
||||
onEdit={onPropEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{renderPagination('prop', propsPage.page, propsPage.totalPages)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 音色区块 */}
|
||||
{(filter === 'all' || filter === 'voice') && voices.length > 0 && (
|
||||
<section>
|
||||
|
||||
@@ -48,12 +48,13 @@ interface Location {
|
||||
|
||||
interface LocationCardProps {
|
||||
location: Location
|
||||
assetType?: 'location' | 'prop'
|
||||
onImageClick?: (url: string) => void
|
||||
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number) => void
|
||||
onEdit?: (location: Location, imageIndex: number) => void
|
||||
}
|
||||
|
||||
export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: LocationCardProps) {
|
||||
export function LocationCard({ location, assetType = 'location', onImageClick, onImageEdit, onEdit }: LocationCardProps) {
|
||||
// 🔥 使用 mutation hooks
|
||||
const generateImage = useGenerateLocationImage()
|
||||
const selectImage = useSelectLocationImage()
|
||||
@@ -63,6 +64,7 @@ export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: Lo
|
||||
|
||||
const t = useTranslations('assetHub')
|
||||
const tAssets = useTranslations('assets')
|
||||
const assetLabel = assetType === 'prop' ? t('propLabel') : t('locationLabel')
|
||||
const { count: generationCount, setCount: setGenerationCount } = useImageGenerationCount('location')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -353,7 +355,9 @@ export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: Lo
|
||||
{showDeleteConfirm && (
|
||||
<div className="absolute inset-0 glass-overlay flex items-center justify-center z-20 rounded-xl">
|
||||
<div className="glass-surface-modal p-4 m-4">
|
||||
<p className="mb-4 text-sm text-[var(--glass-text-primary)]">{t('confirmDeleteLocation')}</p>
|
||||
<p className="mb-4 text-sm text-[var(--glass-text-primary)]">
|
||||
{assetType === 'prop' ? t('confirmDeleteProp') : t('confirmDeleteLocation')}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setShowDeleteConfirm(false)} className="glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm">{t('cancel')}</button>
|
||||
<button onClick={handleDelete} className="glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm">{t('delete')}</button>
|
||||
@@ -387,9 +391,9 @@ export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: Lo
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={uploadImage.isPending} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
|
||||
<AppIcon name="upload" className="w-4 h-4 text-[var(--glass-tone-success-fg)]" />
|
||||
</button>
|
||||
<button onClick={() => onImageEdit?.('location', location.id, location.name, currentImageIndex)} className="glass-btn-base glass-btn-tone-info h-7 w-7 rounded-full">
|
||||
<AppIcon name="edit" className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => onImageEdit?.('location', location.id, location.name, currentImageIndex)} className="glass-btn-base glass-btn-tone-info h-7 w-7 rounded-full">
|
||||
<AppIcon name="edit" className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleGenerate()} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
</button>
|
||||
@@ -402,8 +406,8 @@ export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: Lo
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="globe2" className="w-12 h-12 mb-3" />
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="globe2" className="w-12 h-12 mb-3" />
|
||||
<ImageGenerationInlineCountButton
|
||||
prefix={<span>{tAssets('image.generateCountPrefix')}</span>}
|
||||
suffix={<span>{tAssets('image.generateCountSuffix')}</span>}
|
||||
@@ -431,7 +435,10 @@ export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: Lo
|
||||
{/* 信息区域 */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-[var(--glass-text-primary)] text-sm truncate">{location.name}</h3>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--glass-text-primary)] text-sm truncate">{location.name}</h3>
|
||||
<p className="text-[10px] text-[var(--glass-text-tertiary)]">{assetLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 编辑按钮 */}
|
||||
<button
|
||||
@@ -454,7 +461,9 @@ export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: Lo
|
||||
{showDeleteConfirm && (
|
||||
<div className="absolute inset-0 glass-overlay flex items-center justify-center z-20">
|
||||
<div className="glass-surface-modal p-4 m-4">
|
||||
<p className="mb-4 text-sm text-[var(--glass-text-primary)]">{t('confirmDeleteLocation')}</p>
|
||||
<p className="mb-4 text-sm text-[var(--glass-text-primary)]">
|
||||
{assetType === 'prop' ? t('confirmDeleteProp') : t('confirmDeleteLocation')}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setShowDeleteConfirm(false)} className="glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm">{t('cancel')}</button>
|
||||
<button onClick={handleDelete} className="glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm">{t('delete')}</button>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import Navbar from '@/components/Navbar'
|
||||
import { FolderSidebar } from './components/FolderSidebar'
|
||||
import { AssetGrid } from './components/AssetGrid'
|
||||
import { CharacterCreationModal, LocationCreationModal, CharacterEditModal, LocationEditModal } from '@/components/shared/assets'
|
||||
import { CharacterCreationModal, LocationCreationModal, PropCreationModal, CharacterEditModal, LocationEditModal, PropEditModal } from '@/components/shared/assets'
|
||||
import { FolderModal } from './components/FolderModal'
|
||||
import ImagePreviewModal from '@/components/ui/ImagePreviewModal'
|
||||
import ImageEditModal from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/ImageEditModal'
|
||||
@@ -17,14 +17,11 @@ import VoiceDesignDialog from './components/VoiceDesignDialog'
|
||||
import VoiceCreationModal from './components/VoiceCreationModal'
|
||||
import VoicePickerDialog from './components/VoicePickerDialog'
|
||||
import {
|
||||
useGlobalCharacters,
|
||||
useGlobalLocations,
|
||||
useGlobalVoices,
|
||||
useAssets,
|
||||
useAssetActions,
|
||||
useRefreshAssets,
|
||||
useGlobalFolders,
|
||||
useSSE,
|
||||
useModifyCharacterImage,
|
||||
useModifyLocationImage,
|
||||
type GlobalCharacter,
|
||||
} from '@/lib/query/hooks'
|
||||
import { queryKeys } from '@/lib/query/keys'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
@@ -42,20 +39,21 @@ export default function AssetHubPage() {
|
||||
|
||||
// 使用 React Query 获取数据
|
||||
const { data: folders = [], isLoading: foldersLoading } = useGlobalFolders()
|
||||
const { data: characters = [], isLoading: charactersLoading } = useGlobalCharacters(selectedFolderId)
|
||||
const { data: locations = [], isLoading: locationsLoading } = useGlobalLocations(selectedFolderId)
|
||||
const { data: voices = [], isLoading: voicesLoading } = useGlobalVoices(selectedFolderId)
|
||||
const { data: assets = [], isLoading: assetsLoading } = useAssets({
|
||||
scope: 'global',
|
||||
folderId: selectedFolderId,
|
||||
})
|
||||
const characterActions = useAssetActions({ scope: 'global', kind: 'character' })
|
||||
const locationActions = useAssetActions({ scope: 'global', kind: 'location' })
|
||||
const refreshAssets = useRefreshAssets({ scope: 'global' })
|
||||
|
||||
const loading = foldersLoading || charactersLoading || locationsLoading || voicesLoading
|
||||
const loading = foldersLoading || assetsLoading
|
||||
useSSE({ projectId: 'global-asset-hub', enabled: true })
|
||||
|
||||
// Mutation hooks
|
||||
const modifyCharacterImage = useModifyCharacterImage()
|
||||
const modifyLocationImage = useModifyLocationImage()
|
||||
|
||||
// 弹窗状态
|
||||
const [showAddCharacter, setShowAddCharacter] = useState(false)
|
||||
const [showAddLocation, setShowAddLocation] = useState(false)
|
||||
const [showAddProp, setShowAddProp] = useState(false)
|
||||
const [showFolderModal, setShowFolderModal] = useState(false)
|
||||
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null)
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
@@ -99,6 +97,12 @@ export default function AssetHubPage() {
|
||||
artStyle: string | null
|
||||
description: string
|
||||
} | null>(null)
|
||||
const [propEditModal, setPropEditModal] = useState<{
|
||||
propId: string
|
||||
propName: string
|
||||
summary: string
|
||||
variantId?: string
|
||||
} | null>(null)
|
||||
|
||||
// 创建文件夹
|
||||
const handleCreateFolder = async (name: string) => {
|
||||
@@ -167,38 +171,34 @@ export default function AssetHubPage() {
|
||||
setImageEditModal(null)
|
||||
|
||||
if (type === 'character' && appearanceIndex !== undefined) {
|
||||
modifyCharacterImage.mutate({
|
||||
characterId: id,
|
||||
void characterActions.modifyRender({
|
||||
id,
|
||||
appearanceIndex,
|
||||
imageIndex,
|
||||
modifyPrompt,
|
||||
extraImageUrls
|
||||
}, {
|
||||
onError: () => {
|
||||
alert(t('editFailed'))
|
||||
}
|
||||
}).catch(() => {
|
||||
alert(t('editFailed'))
|
||||
})
|
||||
} else if (type === 'location') {
|
||||
modifyLocationImage.mutate({
|
||||
locationId: id,
|
||||
void locationActions.modifyRender({
|
||||
id,
|
||||
imageIndex,
|
||||
modifyPrompt,
|
||||
extraImageUrls
|
||||
}, {
|
||||
onError: () => {
|
||||
alert(t('editFailed'))
|
||||
}
|
||||
}).catch(() => {
|
||||
alert(t('editFailed'))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打开 AI 声音设计对话框
|
||||
const handleOpenVoiceDesign = (characterId: string, characterName: string) => {
|
||||
const character = characters.find(c => c.id === characterId)
|
||||
const character = assets.find((asset) => asset.kind === 'character' && asset.id === characterId)
|
||||
setVoiceDesignCharacter({
|
||||
id: characterId,
|
||||
name: characterName,
|
||||
hasExistingVoice: !!character?.customVoiceUrl
|
||||
hasExistingVoice: character?.kind === 'character' ? !!character.voice.customVoiceUrl : false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,6 +220,7 @@ export default function AssetHubPage() {
|
||||
if (res.ok) {
|
||||
alert(t('voiceDesignSaved', { name: voiceDesignCharacter.name }))
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })
|
||||
refreshAssets()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
alert(
|
||||
@@ -236,8 +237,23 @@ export default function AssetHubPage() {
|
||||
|
||||
// 打开角色编辑弹窗
|
||||
const handleOpenCharacterEdit = (character: unknown, appearance: unknown) => {
|
||||
const typedCharacter = character as GlobalCharacter
|
||||
const typedAppearance = appearance as GlobalCharacter['appearances'][0]
|
||||
const typedCharacter = character as {
|
||||
id: string
|
||||
name: string
|
||||
appearances: Array<{
|
||||
id: string
|
||||
appearanceIndex: number
|
||||
changeReason: string
|
||||
description: string | null
|
||||
}>
|
||||
}
|
||||
const typedAppearance = appearance as {
|
||||
id: string
|
||||
appearanceIndex: number
|
||||
changeReason: string
|
||||
artStyle?: string | null
|
||||
description: string | null
|
||||
}
|
||||
setCharacterEditModal({
|
||||
characterId: typedCharacter.id,
|
||||
characterName: typedCharacter.name,
|
||||
@@ -269,21 +285,32 @@ export default function AssetHubPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenPropEdit = (prop: unknown, imageIndex: number) => {
|
||||
const typedProp = prop as {
|
||||
id: string
|
||||
name: string
|
||||
summary: string | null
|
||||
images: Array<{ id: string; imageIndex: number }>
|
||||
}
|
||||
const variant = typedProp.images.find((image) => image.imageIndex === imageIndex)
|
||||
setPropEditModal({
|
||||
propId: typedProp.id,
|
||||
propName: typedProp.name,
|
||||
summary: typedProp.summary || '',
|
||||
variantId: variant?.id,
|
||||
})
|
||||
}
|
||||
|
||||
// 角色编辑后触发生成
|
||||
const handleCharacterEditGenerate = async () => {
|
||||
if (!characterEditModal) return
|
||||
|
||||
try {
|
||||
await apiFetch('/api/asset-hub/generate-image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'character',
|
||||
id: characterEditModal.characterId,
|
||||
appearanceIndex: characterEditModal.appearanceIndex,
|
||||
artStyle: characterEditModal.artStyle || undefined,
|
||||
count: characterGenerationCount,
|
||||
})
|
||||
await characterActions.generate({
|
||||
id: characterEditModal.characterId,
|
||||
appearanceIndex: characterEditModal.appearanceIndex,
|
||||
artStyle: characterEditModal.artStyle || undefined,
|
||||
count: characterGenerationCount,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })
|
||||
} catch (error) {
|
||||
@@ -296,15 +323,10 @@ export default function AssetHubPage() {
|
||||
if (!locationEditModal) return
|
||||
|
||||
try {
|
||||
await apiFetch('/api/asset-hub/generate-image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'location',
|
||||
id: locationEditModal.locationId,
|
||||
artStyle: locationEditModal.artStyle || undefined,
|
||||
count: locationGenerationCount,
|
||||
})
|
||||
await locationActions.generate({
|
||||
id: locationEditModal.locationId,
|
||||
artStyle: locationEditModal.artStyle || undefined,
|
||||
count: locationGenerationCount,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.locations() })
|
||||
} catch (error) {
|
||||
@@ -317,26 +339,13 @@ export default function AssetHubPage() {
|
||||
if (!voicePickerCharacterId) return
|
||||
|
||||
try {
|
||||
const res = await apiFetch(`/api/asset-hub/characters/${voicePickerCharacterId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
globalVoiceId: voice.id,
|
||||
customVoiceUrl: voice.customVoiceUrl
|
||||
})
|
||||
await characterActions.bindVoice({
|
||||
characterId: voicePickerCharacterId,
|
||||
globalVoiceId: voice.id,
|
||||
customVoiceUrl: voice.customVoiceUrl,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })
|
||||
setVoicePickerCharacterId(null)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
alert(
|
||||
typeof data.error === 'string'
|
||||
? t('bindVoiceFailedDetail', { error: data.error })
|
||||
: t('bindVoiceFailed'),
|
||||
)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })
|
||||
setVoicePickerCharacterId(null)
|
||||
} catch (error) {
|
||||
_ulogError('绑定音色失败:', error)
|
||||
alert(t('bindVoiceFailed'))
|
||||
@@ -349,27 +358,45 @@ export default function AssetHubPage() {
|
||||
const imageEntries: Array<{ filename: string; url: string }> = []
|
||||
|
||||
// 角色图片:每个角色每个外貌的当前选中图
|
||||
for (const character of characters) {
|
||||
for (const appearance of character.appearances) {
|
||||
const url = appearance.imageUrl
|
||||
for (const asset of assets) {
|
||||
if (asset.kind !== 'character') continue
|
||||
for (const variant of asset.variants) {
|
||||
const selectedRender = variant.renders.find((render) => render.isSelected) ?? variant.renders[0]
|
||||
const url = selectedRender?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = appearance.appearanceIndex === 0
|
||||
const safeName = asset.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = variant.index === 0
|
||||
? `characters/${safeName}.jpg`
|
||||
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
|
||||
: `characters/${safeName}_appearance${variant.index}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
|
||||
// 场景图片:每个场景的选中图
|
||||
for (const location of locations) {
|
||||
for (const image of location.images) {
|
||||
const url = image.imageUrl
|
||||
for (const asset of assets) {
|
||||
if (asset.kind !== 'location') continue
|
||||
for (const variant of asset.variants) {
|
||||
const render = variant.renders[0]
|
||||
const url = render?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = location.images.length <= 1
|
||||
const safeName = asset.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = asset.variants.length <= 1
|
||||
? `locations/${safeName}.jpg`
|
||||
: `locations/${safeName}_${image.imageIndex + 1}.jpg`
|
||||
: `locations/${safeName}_${variant.index + 1}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
if (asset.kind !== 'prop') continue
|
||||
for (const variant of asset.variants) {
|
||||
const render = variant.renders[0]
|
||||
const url = render?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = asset.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = asset.variants.length <= 1
|
||||
? `props/${safeName}.jpg`
|
||||
: `props/${safeName}_${variant.index + 1}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
@@ -446,12 +473,11 @@ export default function AssetHubPage() {
|
||||
|
||||
{/* 右侧资产网格 */}
|
||||
<AssetGrid
|
||||
characters={characters}
|
||||
locations={locations}
|
||||
voices={voices}
|
||||
assets={assets}
|
||||
loading={loading}
|
||||
onAddCharacter={() => setShowAddCharacter(true)}
|
||||
onAddLocation={() => setShowAddLocation(true)}
|
||||
onAddProp={() => setShowAddProp(true)}
|
||||
onAddVoice={() => setShowAddVoice(true)}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={isDownloading}
|
||||
@@ -461,6 +487,7 @@ export default function AssetHubPage() {
|
||||
onVoiceDesign={handleOpenVoiceDesign}
|
||||
onCharacterEdit={handleOpenCharacterEdit}
|
||||
onLocationEdit={handleOpenLocationEdit}
|
||||
onPropEdit={handleOpenPropEdit}
|
||||
onVoiceSelect={(characterId) => setVoicePickerCharacterId(characterId)}
|
||||
/>
|
||||
</div>
|
||||
@@ -475,6 +502,7 @@ export default function AssetHubPage() {
|
||||
onSuccess={() => {
|
||||
setShowAddCharacter(false)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })
|
||||
refreshAssets()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -488,6 +516,19 @@ export default function AssetHubPage() {
|
||||
onSuccess={() => {
|
||||
setShowAddLocation(false)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.locations() })
|
||||
refreshAssets()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAddProp && (
|
||||
<PropCreationModal
|
||||
mode="asset-hub"
|
||||
folderId={selectedFolderId}
|
||||
onClose={() => setShowAddProp(false)}
|
||||
onSuccess={() => {
|
||||
setShowAddProp(false)
|
||||
refreshAssets()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -568,6 +609,18 @@ export default function AssetHubPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{propEditModal && (
|
||||
<PropEditModal
|
||||
mode="asset-hub"
|
||||
propId={propEditModal.propId}
|
||||
propName={propEditModal.propName}
|
||||
summary={propEditModal.summary}
|
||||
variantId={propEditModal.variantId}
|
||||
onClose={() => setPropEditModal(null)}
|
||||
onRefresh={refreshAssets}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新建音色弹窗 */}
|
||||
{showAddVoice && (
|
||||
<VoiceCreationModal
|
||||
@@ -577,6 +630,7 @@ export default function AssetHubPage() {
|
||||
onSuccess={() => {
|
||||
setShowAddVoice(false)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.voices() })
|
||||
refreshAssets()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user