feat: add props system and refactor asset library architecture

This commit is contained in:
saturn
2026-03-19 15:37:47 +08:00
parent 9aff44e37a
commit f364bbc9e4
139 changed files with 9112 additions and 2827 deletions

View File

@@ -96,7 +96,8 @@ export function useProject(projectId: string) {
novelPromotionData: {
...prev.novelPromotionData,
characters: assets.characters || [],
locations: assets.locations || []
locations: assets.locations || [],
props: assets.props || [],
}
}
})

View File

@@ -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

View File

@@ -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}

View File

@@ -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>
)
}

View File

@@ -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 && (

View File

@@ -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}

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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,

View File

@@ -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 供组件使用

View File

@@ -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">

View File

@@ -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}

View File

@@ -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" />

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()
}}
/>
)}