- {!allAssetsHaveImages && globalCharIds.length + globalLocationIds.length > 0 && (
+ {!allAssetsHaveImages && globalCharIds.length + globalLocationIds.length + globalPropIds.length > 0 && (
{tScript('generate.missingAssets', { count: missingAssetsCount })}
diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewRuntime.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewRuntime.tsx
index 361a749..3e9967a 100644
--- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewRuntime.tsx
+++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewRuntime.tsx
@@ -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([])
const [activeLocationIds, setActiveLocationIds] = useState([])
+ const [activePropIds, setActivePropIds] = useState([])
const [selectedAppearanceKeys, setSelectedAppearanceKeys] = useState>(new Set())
const isManuallyEditingRef = useRef(false)
@@ -122,12 +127,14 @@ export default function ScriptView({
let charNames = new Set()
let locNames = new Set()
+ let propNames = new Set()
let charAppearanceSet = new Set()
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 (
@@ -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}
diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/SpotlightCards.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/SpotlightCards.tsx
index 1e872aa..ff6e0de 100644
--- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/SpotlightCards.tsx
+++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/SpotlightCards.tsx
@@ -80,7 +80,7 @@ export function SpotlightCharCard({
@@ -209,7 +209,7 @@ export function SpotlightLocationCard({
diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/asset-state-utils.ts b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/asset-state-utils.ts
index a9737b2..721d13a 100644
--- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/asset-state-utils.ts
+++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/asset-state-utils.ts
@@ -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
+}
diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/clip-asset-utils.ts b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/clip-asset-utils.ts
index de57fd9..a95aada 100644
--- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/clip-asset-utils.ts
+++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/clip-asset-utils.ts
@@ -1,11 +1,13 @@
type ClipAssetSource = {
characters?: string | null
location?: string | null
+ props?: string | null
}
export type ParsedClipAssets = {
charNames: Set
locNames: Set
+ propNames: Set
charAppearanceSet: Set
}
@@ -29,6 +31,7 @@ export function fuzzyMatchLocation(clipLocName: string, libraryLocName: string):
export function parseClipAssets(clip: ClipAssetSource): ParsedClipAssets {
const charNames = new Set()
const locNames = new Set()
+ const propNames = new Set()
const charAppearanceSet = new Set()
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()
const allLocNames = new Set()
+ const allPropNames = new Set()
const allCharAppearanceSet = new Set()
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 }
}
diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/selection-sync.ts b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/selection-sync.ts
new file mode 100644
index 0000000..b382b60
--- /dev/null
+++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/selection-sync.ts
@@ -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, next: Set): Set {
+ if (previous.size !== next.size) {
+ return next
+ }
+
+ for (const value of previous) {
+ if (!next.has(value)) {
+ return next
+ }
+ }
+
+ return previous
+}
diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelVariant.ts b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelVariant.ts
index e9afb60..1e877ec 100644
--- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelVariant.ts
+++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/usePanelVariant.ts
@@ -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,
diff --git a/src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx b/src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx
index 1fe991e..eaae286 100644
--- a/src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx
+++ b/src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx
@@ -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(null)
+ const menuRef = useRef(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 (
+ <>
+
+ {open && menuPos && createPortal(
+
+ {menuItems.map((item) => (
+
+ ))}
+
,
+ document.body,
+ )}
+ >
+ )
+}
+
// 内联 SVG 图标
const PlusIcon = ({ className }: { className?: string }) => (
)
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 = (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 (
@@ -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 + 操作按钮 */}
{/* 左侧筛选 */}
- {(() => {
- return (
-
({ value: tab.id, label: tab.label }))}
- value={filter}
- onChange={(val) => setFilter(val as 'all' | 'character' | 'location' | 'voice')}
- />
- )
- })()}
+ ({ value: tab.id, label: tab.label }))}
+ value={filter}
+ onChange={(val) => setFilter(val as 'all' | 'character' | 'location' | 'prop' | 'voice')}
+ />
{/* 右侧操作按钮 */}
@@ -211,27 +315,12 @@ export function AssetGrid({
{isDownloading ? t('downloading') : t('downloadAll')}
)}
-
-
-
+
@@ -292,6 +381,28 @@ export function AssetGrid({
)}
+ {(filter === 'all' || filter === 'prop') && props.length > 0 && (
+
+
+ {t('props')}
+ {props.length}
+
+
+ {propsPage.items.map((prop) => (
+
+ ))}
+
+ {renderPagination('prop', propsPage.page, propsPage.totalPages)}
+
+ )}
+
{/* 音色区块 */}
{(filter === 'all' || filter === 'voice') && voices.length > 0 && (
diff --git a/src/app/[locale]/workspace/asset-hub/components/LocationCard.tsx b/src/app/[locale]/workspace/asset-hub/components/LocationCard.tsx
index 56e2ad5..9043eb2 100644
--- a/src/app/[locale]/workspace/asset-hub/components/LocationCard.tsx
+++ b/src/app/[locale]/workspace/asset-hub/components/LocationCard.tsx
@@ -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(null)
@@ -353,7 +355,9 @@ export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: Lo
{showDeleteConfirm && (
-
{t('confirmDeleteLocation')}
+
+ {assetType === 'prop' ? t('confirmDeleteProp') : t('confirmDeleteLocation')}
+
@@ -387,9 +391,9 @@ export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: Lo
-
+
@@ -402,8 +406,8 @@ export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: Lo
)}
>
) : (
-
-
+
+
{tAssets('image.generateCountPrefix')}}
suffix={{tAssets('image.generateCountSuffix')}}
@@ -431,7 +435,10 @@ export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: Lo
{/* 信息区域 */}
-
{location.name}
+
+
{location.name}
+
{assetLabel}
+
{/* 编辑按钮 */}