From f364bbc9e4286f297ccd9023768b546325916d6b Mon Sep 17 00:00:00 2001 From: saturn Date: Thu, 19 Mar 2026 15:37:47 +0800 Subject: [PATCH] feat: add props system and refactor asset library architecture --- .../agent_cinematographer.en.txt | 2 + .../agent_cinematographer.zh.txt | 4 +- lib/prompts/novel-promotion/agent_clip.en.txt | 8 +- lib/prompts/novel-promotion/agent_clip.zh.txt | 16 +- .../agent_storyboard_detail.zh.txt | 4 +- .../agent_storyboard_insert.zh.txt | 3 + .../agent_storyboard_plan.en.txt | 3 + .../agent_storyboard_plan.zh.txt | 3 + .../novel-promotion/select_prop.en.txt | 30 + .../novel-promotion/select_prop.zh.txt | 30 + messages/en/assetHub.json | 8 +- messages/en/assetLibrary.json | 5 +- messages/en/assetModal.json | 9 + messages/en/assetPicker.json | 3 +- messages/en/assets.json | 26 +- messages/zh/assetHub.json | 8 +- messages/zh/assetLibrary.json | 5 +- messages/zh/assetModal.json | 9 + messages/zh/assetPicker.json | 3 +- messages/zh/assets.json | 26 +- .../migration.sql | 11 + prisma/schema.prisma | 4 + .../dev/segmented-control-test/page.tsx | 1091 ++++++++++++++++ .../workspace/[projectId]/hooks/useProject.ts | 3 +- .../components/AssetLibrary.tsx | 42 +- .../components/AssetsStage.tsx | 274 +++- .../components/assets/AssetFilterBar.tsx | 48 + .../components/assets/AssetToolbar.tsx | 157 ++- .../components/assets/AssetsStageModals.tsx | 44 +- .../components/assets/CharacterSection.tsx | 12 +- .../components/assets/LocationCard.tsx | 23 +- .../components/assets/LocationSection.tsx | 31 +- .../components/assets/hooks/useAssetModals.ts | 31 +- .../assets/hooks/useAssetsCopyFromHub.ts | 9 +- .../assets/hooks/useLocationActions.ts | 71 +- .../script-view/ScriptViewAssetsPanel.tsx | 390 ++++-- .../script-view/ScriptViewRuntime.tsx | 79 +- .../components/script-view/SpotlightCards.tsx | 8 +- .../script-view/asset-state-utils.ts | 34 +- .../script-view/clip-asset-utils.ts | 28 +- .../components/script-view/selection-sync.ts | 27 + .../storyboard/hooks/usePanelVariant.ts | 1 + .../asset-hub/components/AssetGrid.tsx | 299 +++-- .../asset-hub/components/LocationCard.tsx | 27 +- src/app/[locale]/workspace/asset-hub/page.tsx | 222 ++-- src/app/api/asset-hub/generate-image/route.ts | 180 +-- src/app/api/asset-hub/modify-image/route.ts | 119 +- src/app/api/asset-hub/select-image/route.ts | 170 +-- src/app/api/asset-hub/undo-image/route.ts | 147 +-- .../api/asset-hub/update-asset-label/route.ts | 144 +-- src/app/api/assets/[assetId]/copy/route.ts | 34 + .../api/assets/[assetId]/generate/route.ts | 52 + .../assets/[assetId]/modify-render/route.ts | 52 + .../assets/[assetId]/revert-render/route.ts | 50 + src/app/api/assets/[assetId]/route.ts | 109 ++ .../assets/[assetId]/select-render/route.ts | 50 + .../assets/[assetId]/update-label/route.ts | 49 + .../[assetId]/variants/[variantId]/route.ts | 54 + src/app/api/assets/route.ts | 100 ++ .../[projectId]/assets/route.ts | 11 +- .../[projectId]/clips/[clipId]/route.ts | 11 +- .../[projectId]/copy-from-global/route.ts | 341 +---- .../generate-character-image/route.ts | 146 +-- .../[projectId]/generate-image/route.ts | 136 +- .../[projectId]/modify-asset-image/route.ts | 127 +- .../[projectId]/panel/route.ts | 21 +- .../select-character-image/route.ts | 81 +- .../select-location-image/route.ts | 84 +- .../[projectId]/undo-regenerate/route.ts | 271 +--- .../[projectId]/update-asset-label/route.ts | 168 +-- .../api/projects/[projectId]/assets/route.ts | 10 +- .../api/projects/[projectId]/data/route.ts | 11 +- .../shared/assets/GlobalAssetPicker.tsx | 98 +- .../shared/assets/PropCreationModal.tsx | 165 +++ .../shared/assets/PropEditModal.tsx | 157 +++ src/components/shared/assets/index.ts | 5 +- src/components/ui/SegmentedControl.tsx | 38 +- src/lib/assets/contracts.ts | 143 +++ src/lib/assets/grouping.ts | 49 + src/lib/assets/kinds/registry.ts | 88 ++ src/lib/assets/mappers.ts | 454 +++++++ src/lib/assets/services/asset-actions.ts | 1134 +++++++++++++++++ src/lib/assets/services/asset-label.ts | 175 +++ .../assets/services/asset-prompt-context.ts | 159 +++ .../assets/services/location-backed-assets.ts | 247 ++++ src/lib/assets/services/read-assets.ts | 131 ++ src/lib/media/attach.ts | 8 + .../script-to-storyboard/orchestrator.ts | 42 + .../story-to-script/orchestrator.ts | 61 +- src/lib/prompt-i18n/catalog.ts | 14 +- src/lib/prompt-i18n/prompt-ids.ts | 1 + src/lib/query/hooks/index.ts | 11 + src/lib/query/hooks/useAssets.ts | 421 ++++++ src/lib/query/hooks/useGlobalAssets.ts | 301 ++--- src/lib/query/hooks/useProjectAssets.ts | 253 ++-- src/lib/query/keys.ts | 28 +- .../asset-hub-character-mutations.ts | 24 +- .../mutations/asset-hub-location-mutations.ts | 28 +- .../mutations/asset-hub-update-mutations.ts | 62 +- .../mutations/character-base-mutations.ts | 38 +- .../character-image-ops-mutations.ts | 34 +- .../mutations/character-voice-mutations.ts | 11 +- .../mutations/location-image-mutations.ts | 44 +- .../location-management-mutations.ts | 23 +- .../mutations/useProjectConfigMutations.ts | 10 +- src/lib/storage/signed-urls.ts | 2 + src/lib/storyboard-phases.ts | 144 ++- .../workers/handlers/analyze-global-parse.ts | 12 + .../handlers/analyze-global-persist.ts | 41 + .../workers/handlers/analyze-global-prompt.ts | 7 + src/lib/workers/handlers/analyze-global.ts | 42 +- src/lib/workers/handlers/analyze-novel.ts | 96 +- src/lib/workers/handlers/clips-build.ts | 30 +- .../script-to-storyboard-atomic-retry.ts | 33 + .../handlers/script-to-storyboard-helpers.ts | 39 +- .../workers/handlers/script-to-storyboard.ts | 21 +- .../handlers/story-to-script-helpers.ts | 54 +- src/lib/workers/handlers/story-to-script.ts | 47 +- src/lib/workers/text.worker.ts | 56 +- src/types/project.ts | 13 + tests/contracts/route-behavior-matrix.ts | 1 + tests/contracts/route-catalog.ts | 15 + .../api/contract/crud-routes.test.ts | 83 +- .../api/contract/direct-submit-routes.test.ts | 65 +- .../api/specific/assets-route.test.ts | 374 ++++++ .../assets/location-backed-assets.test.ts | 47 + tests/unit/assets/mappers.test.ts | 129 ++ tests/unit/assets/prompt-context.test.ts | 50 + tests/unit/assets/registry.test.ts | 44 + .../character-voice-mutations.test.ts | 6 +- .../project-asset-mutations.test.ts | 2 + .../project-location-generate-body.test.ts | 6 +- .../unit/script-view/clip-asset-utils.test.ts | 23 + tests/unit/script-view/selection-sync.test.ts | 43 + tests/unit/worker/analyze-global.test.ts | 15 +- tests/unit/worker/analyze-novel.test.ts | 2 + tests/unit/worker/clips-build.test.ts | 1 + ...story-to-script-orchestrator.retry.test.ts | 21 + tests/unit/worker/story-to-script.test.ts | 19 +- 139 files changed, 9112 insertions(+), 2827 deletions(-) create mode 100644 lib/prompts/novel-promotion/select_prop.en.txt create mode 100644 lib/prompts/novel-promotion/select_prop.zh.txt create mode 100644 prisma/migrations/20260317120000_add_asset_kind_to_locations/migration.sql create mode 100644 src/app/[locale]/dev/segmented-control-test/page.tsx create mode 100644 src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetFilterBar.tsx create mode 100644 src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/selection-sync.ts create mode 100644 src/app/api/assets/[assetId]/copy/route.ts create mode 100644 src/app/api/assets/[assetId]/generate/route.ts create mode 100644 src/app/api/assets/[assetId]/modify-render/route.ts create mode 100644 src/app/api/assets/[assetId]/revert-render/route.ts create mode 100644 src/app/api/assets/[assetId]/route.ts create mode 100644 src/app/api/assets/[assetId]/select-render/route.ts create mode 100644 src/app/api/assets/[assetId]/update-label/route.ts create mode 100644 src/app/api/assets/[assetId]/variants/[variantId]/route.ts create mode 100644 src/app/api/assets/route.ts create mode 100644 src/components/shared/assets/PropCreationModal.tsx create mode 100644 src/components/shared/assets/PropEditModal.tsx create mode 100644 src/lib/assets/contracts.ts create mode 100644 src/lib/assets/grouping.ts create mode 100644 src/lib/assets/kinds/registry.ts create mode 100644 src/lib/assets/mappers.ts create mode 100644 src/lib/assets/services/asset-actions.ts create mode 100644 src/lib/assets/services/asset-label.ts create mode 100644 src/lib/assets/services/asset-prompt-context.ts create mode 100644 src/lib/assets/services/location-backed-assets.ts create mode 100644 src/lib/assets/services/read-assets.ts create mode 100644 src/lib/query/hooks/useAssets.ts create mode 100644 tests/integration/api/specific/assets-route.test.ts create mode 100644 tests/unit/assets/location-backed-assets.test.ts create mode 100644 tests/unit/assets/mappers.test.ts create mode 100644 tests/unit/assets/prompt-context.test.ts create mode 100644 tests/unit/assets/registry.test.ts create mode 100644 tests/unit/script-view/clip-asset-utils.test.ts create mode 100644 tests/unit/script-view/selection-sync.test.ts diff --git a/lib/prompts/novel-promotion/agent_cinematographer.en.txt b/lib/prompts/novel-promotion/agent_cinematographer.en.txt index 9c99270..3701224 100644 --- a/lib/prompts/novel-promotion/agent_cinematographer.en.txt +++ b/lib/prompts/novel-promotion/agent_cinematographer.en.txt @@ -9,6 +9,8 @@ Inputs: {locations_description} - Character context: {characters_info} +- Prop context: +{props_description} Output format (JSON array only): [ diff --git a/lib/prompts/novel-promotion/agent_cinematographer.zh.txt b/lib/prompts/novel-promotion/agent_cinematographer.zh.txt index 7ed46eb..0ffb9cb 100644 --- a/lib/prompts/novel-promotion/agent_cinematographer.zh.txt +++ b/lib/prompts/novel-promotion/agent_cinematographer.zh.txt @@ -119,6 +119,9 @@ 角色信息: {characters_info} +道具描述: +{props_description} + 【严格要求】 1. 只返回JSON数组,不要有markdown代码块标记 @@ -131,4 +134,3 @@ 8. 如果镜头涉及不同场景,灯光和色调要相应调整 9. 输出要简洁,每个镜头的规则独立完整 10. ⚠️ JSON安全:所有引号(""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 " - diff --git a/lib/prompts/novel-promotion/agent_clip.en.txt b/lib/prompts/novel-promotion/agent_clip.en.txt index be2d4f0..0672146 100644 --- a/lib/prompts/novel-promotion/agent_clip.en.txt +++ b/lib/prompts/novel-promotion/agent_clip.en.txt @@ -20,7 +20,8 @@ Output format (JSON array only): "end": "exact end snippet from source text (>=5 chars)", "summary": "short clip summary", "location": "best matched location name", - "characters": ["Character A", "Character B"] + "characters": ["Character A", "Character B"], + "props": ["Prop A", "Prop B"] } ] @@ -28,6 +29,9 @@ Rules: 1. Keep clips contiguous, ordered, and fully covering the source text. 2. Prefer natural scene/drama boundaries. 3. Minimize over-splitting. -4. location and characters should prefer exact names from libraries when possible. +4. location, characters, and props should prefer exact names from libraries when possible. 5. Return JSON only, no markdown or extra text. 6. ⚠️ JSON SAFETY: All quotation marks in dialogue (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values—they break JSON structure. + +Prop library: +{props_lib_name} diff --git a/lib/prompts/novel-promotion/agent_clip.zh.txt b/lib/prompts/novel-promotion/agent_clip.zh.txt index 6fe835e..6ba70e5 100644 --- a/lib/prompts/novel-promotion/agent_clip.zh.txt +++ b/lib/prompts/novel-promotion/agent_clip.zh.txt @@ -8,7 +8,8 @@ "end": 结束文本,最少包含五个字, "summary": "总结概括片段内容", "location": "场景发生位置", - "characters": ["角色1", "角色2"] + "characters": ["角色1", "角色2"], + "props": ["道具1", "道具2"] } ] @@ -68,8 +69,14 @@ 3. 例如角色库有"张三",原文写"老张"或"张总",必须填写"张三" 4. ⭐ 参考【角色介绍】理解"我"对应哪个角色,以及其他称呼的映射关系 +【props 道具选择 - 必须100%精确匹配】 +1. props 数组【只能】填写道具库中【完全一模一样】的名字 +2. ❌ 严禁改写、缩写、添加前后缀 +3. 只选择当前片段里真正出镜、被持有、被使用、被重点提及的实体道具 +4. 如果当前片段没有明确道具,返回空数组 [] + 【自检规则】 -输出前检查:location 和 characters 中的每个名字是否都能在场景库/角色库中找到完全一致的?如果不能,必须修正! +输出前检查:location、characters、props 中的每个名字是否都能在对应资产库中找到完全一致的?如果不能,必须修正! 原文如下: {input} @@ -81,4 +88,7 @@ {characters_lib_name} 角色介绍(⭐用于理解"我"和称呼对应的角色): -{characters_introduction} \ No newline at end of file +{characters_introduction} + +道具库: +{props_lib_name} diff --git a/lib/prompts/novel-promotion/agent_storyboard_detail.zh.txt b/lib/prompts/novel-promotion/agent_storyboard_detail.zh.txt index b3ab440..e0cabd3 100644 --- a/lib/prompts/novel-promotion/agent_storyboard_detail.zh.txt +++ b/lib/prompts/novel-promotion/agent_storyboard_detail.zh.txt @@ -165,6 +165,9 @@ 场景描述: {locations_description} +道具描述: +{props_description} + 【严格要求】 1. 为每个分镜补充shot_type、camera_move、video_prompt 2. shot_type格式:视角+景别(如"平视中景"、"越肩近景"、"仰拍全景") @@ -178,4 +181,3 @@ 10. description可以适当优化,但不要改变核心内容 11. ⚠️ 必须保留输入分镜中的 source_text 字段,原样输出到结果中,不得遗漏或修改 12. ⚠️ JSON安全:所有引号(""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 " - diff --git a/lib/prompts/novel-promotion/agent_storyboard_insert.zh.txt b/lib/prompts/novel-promotion/agent_storyboard_insert.zh.txt index 561253b..0dad461 100644 --- a/lib/prompts/novel-promotion/agent_storyboard_insert.zh.txt +++ b/lib/prompts/novel-promotion/agent_storyboard_insert.zh.txt @@ -24,6 +24,9 @@ ## 场景信息(仅包含前后镜头涉及的场景) {locations_description} +## 道具信息(仅包含前后镜头涉及的道具) +{props_description} + ====================================== 【分析规则】 ====================================== diff --git a/lib/prompts/novel-promotion/agent_storyboard_plan.en.txt b/lib/prompts/novel-promotion/agent_storyboard_plan.en.txt index 5ebdce2..b25aa01 100644 --- a/lib/prompts/novel-promotion/agent_storyboard_plan.en.txt +++ b/lib/prompts/novel-promotion/agent_storyboard_plan.en.txt @@ -16,6 +16,9 @@ Character appearance list: Character full descriptions: {characters_full_description} +Prop descriptions: +{props_description} + Clip metadata JSON: {clip_json} diff --git a/lib/prompts/novel-promotion/agent_storyboard_plan.zh.txt b/lib/prompts/novel-promotion/agent_storyboard_plan.zh.txt index 47c18e6..87ed399 100644 --- a/lib/prompts/novel-promotion/agent_storyboard_plan.zh.txt +++ b/lib/prompts/novel-promotion/agent_storyboard_plan.zh.txt @@ -301,6 +301,9 @@ 角色完整描述(供参考): {characters_full_description} +道具描述(供参考): +{props_description} + Clip信息: {clip_json} diff --git a/lib/prompts/novel-promotion/select_prop.en.txt b/lib/prompts/novel-promotion/select_prop.en.txt new file mode 100644 index 0000000..de3d6c7 --- /dev/null +++ b/lib/prompts/novel-promotion/select_prop.en.txt @@ -0,0 +1,30 @@ +You are a prop asset extractor. + +Task: identify reusable physical props from the input text and return JSON only. + +Output format: +{ + "props": [ + { + "name": "prop name", + "summary": "one-line objective prop description" + } + ] +} + +Rules: +1. Only include concrete reusable physical props that actually appear in the story. +2. Only output `name` and `summary`. +3. `name` and `summary` must both be non-empty. +4. Do not repeat props that already exist in the prop library with the exact same name. +5. Exclude abstract concepts, powers, roles, places, creatures, outfits, and makeup. +6. Keep names stable and short. +7. Keep summaries objective. +8. If none exist, return {"props": []}. +9. Replace raw quotation marks inside JSON string values with corner brackets「」. + +Input: +{input} + +Existing prop library: +{props_lib_name} diff --git a/lib/prompts/novel-promotion/select_prop.zh.txt b/lib/prompts/novel-promotion/select_prop.zh.txt new file mode 100644 index 0000000..90032c9 --- /dev/null +++ b/lib/prompts/novel-promotion/select_prop.zh.txt @@ -0,0 +1,30 @@ +你是“故事道具资产分析师”。 + +任务:从输入文本中识别适合做成长期复用资产的道具,只返回 JSON,不得包含任何额外解释或 markdown。 + +输出格式: +{ + "props": [ + { + "name": "道具名称", + "summary": "一句话描述道具的外观/用途" + } + ] +} + +规则: +1. 只保留在剧情中真实出现、可被反复引用、值得进入资产库的实体道具。 +2. 只输出两个字段:name、summary。 +3. name 不能为空;summary 不能为空。 +4. 如果道具库里已经有完全同名道具,不要重复输出。 +5. 禁止输出抽象概念、情绪、能力、身份、地点、生物、服装妆容。 +6. 名称尽量简洁稳定,例如“青铜匕首”“录音笔”“红绳手链”。 +7. summary 只写客观描述,不写剧情推断。 +8. 如果没有合适道具,返回 {"props": []}。 +9. JSON 字符串值中的引号统一替换为「」。 + +输入文本: +{input} + +已有道具库: +{props_lib_name} diff --git a/messages/en/assetHub.json b/messages/en/assetHub.json index b32cea4..281eb6e 100644 --- a/messages/en/assetHub.json +++ b/messages/en/assetHub.json @@ -9,10 +9,13 @@ "allAssets": "All Assets", "characters": "Characters", "locations": "Locations", + "props": "Props", "voices": "Voices", "addCharacter": "Add Character", "addLocation": "Add Location", + "addProp": "Add Prop", "addVoice": "Add Voice", + "addAsset": "New Asset", "downloadAll": "Download All", "downloadAllTitle": "Download All Image Assets as ZIP", "downloading": "Packing...", @@ -37,7 +40,10 @@ "confirmDeleteFolder": "Delete this folder? Assets inside will be moved to uncategorized.", "confirmDeleteCharacter": "Delete this character? This action cannot be undone.", "confirmDeleteLocation": "Delete this location? This action cannot be undone.", + "confirmDeleteProp": "Delete this prop? This action cannot be undone.", "confirmDeleteVoice": "Delete this voice? This action cannot be undone.", + "locationLabel": "Location", + "propLabel": "Prop", "voiceName": "Voice Name", "voiceNamePlaceholder": "Enter voice name", "voiceNameRequired": "Please enter a voice name", @@ -104,4 +110,4 @@ "dropOrClick": "Drop image or click to upload", "supportedFormats": "JPG, PNG supported" } -} \ No newline at end of file +} diff --git a/messages/en/assetLibrary.json b/messages/en/assetLibrary.json index 9b10bb6..9311abc 100644 --- a/messages/en/assetLibrary.json +++ b/messages/en/assetLibrary.json @@ -3,12 +3,15 @@ "button": "Assets", "characters": "Characters", "locations": "Locations", + "props": "Props", "noCharacters": "No characters", "noLocations": "No locations", + "noProps": "No props", "addCharacter": "Add Character", "addLocation": "Add Location", + "addProp": "Add Prop", "generateImage": "Generate Image", "regenerateImage": "Regenerate", "analyzeAssets": "Analyze Assets", "analyzing": "Analyzing..." -} \ No newline at end of file +} diff --git a/messages/en/assetModal.json b/messages/en/assetModal.json index b1cad6c..62c4338 100644 --- a/messages/en/assetModal.json +++ b/messages/en/assetModal.json @@ -51,6 +51,13 @@ "description": "Location Description", "descPlaceholder": "Enter location description..." }, + "prop": { + "title": "New Prop", + "name": "Prop Name", + "namePlaceholder": "Enter prop name", + "summary": "Prop Description", + "summaryPlaceholder": "Describe the prop..." + }, "artStyle": { "title": "Art Style" }, @@ -72,6 +79,8 @@ "addOnlyToAssetHub": "Add only to asset hub", "addOnlyLocation": "Add location only", "addOnlyToAssetHubLocation": "Add location to asset hub only", + "addOnlyProp": "Add prop only", + "addOnlyToAssetHubProp": "Add prop to asset hub only", "addAndGeneratePrefix": "Add and generate", "generateCountSuffix": "images", "selectGenerateCount": "Select generation count", diff --git a/messages/en/assetPicker.json b/messages/en/assetPicker.json index 28faeaf..cbbc592 100644 --- a/messages/en/assetPicker.json +++ b/messages/en/assetPicker.json @@ -1,6 +1,7 @@ { "selectCharacter": "Select Character from Asset Hub", "selectLocation": "Select Location from Asset Hub", + "selectProp": "Select Prop from Asset Hub", "selectVoice": "Select Voice from Asset Hub", "searchPlaceholder": "Search by name or folder...", "noAssets": "No assets in Asset Hub", @@ -15,4 +16,4 @@ "copyFailed": "Copy failed", "preview": "Preview", "stop": "Stop" -} \ No newline at end of file +} diff --git a/messages/en/assets.json b/messages/en/assets.json index a7146c0..d170ed3 100644 --- a/messages/en/assets.json +++ b/messages/en/assets.json @@ -3,6 +3,7 @@ "title": "Assets Confirmation", "characters": "Characters", "locations": "Locations", + "props": "Props", "analyze": "Analyze Assets", "analyzing": "Analyzing...", "generateAll": "Generate All", @@ -14,8 +15,10 @@ "assetsTitle": "Asset Analysis", "characterAssets": "Character Assets", "locationAssets": "Location Assets", + "propAssets": "Prop Assets", "counts": "{characterCount} Characters, {appearanceCount} Appearances", "locationCounts": "{count} Locations", + "propCounts": "{count} Props", "undoFailed": "Undo failed", "undoFailedError": "Undo failed: {error}", "undoSuccess": "Reverted to previous version", @@ -70,6 +73,18 @@ "updateFailed": "Update description failed", "addFailed": "Add location failed" }, + "prop": { + "add": "Add Prop", + "edit": "Edit Prop", + "delete": "Delete Prop", + "deleteConfirm": "Delete this prop?", + "deleteFailed": "Delete failed: {error}", + "name": "Prop Name", + "summary": "Summary", + "summaryPlaceholder": "Describe the prop", + "regenerateImage": "Regenerate", + "addFailed": "Add prop failed" + }, "image": { "upload": "Upload Image", "uploadReplace": "Upload Replacement", @@ -160,6 +175,7 @@ "modifyFailed": "Modification failed", "editCharacter": "Edit Character", "editLocation": "Edit Location", + "editProp": "Edit Prop", "saveAndGenerate": "Save and Generate", "generatingAutoClose": "Generating image, will close automatically when done...", "aiLocationTip": "Enter what you want to modify, AI will adjust the scene description", @@ -177,7 +193,7 @@ "showGenerated": "Generated", "showPending": "Pending", "assetManagement": "Asset Management", - "assetCount": "{total} assets ({appearances} character appearances + {locations} locations)", + "assetCount": "{total} assets ({appearances} character appearances + {locations} locations + {props} props)", "globalAnalyze": "Global Analysis", "globalAnalyzing": "Performing global asset analysis...", "globalAnalyzingHint": "Please don't refresh. Results will appear automatically when complete", @@ -239,11 +255,17 @@ "aiDesignFailed": "AI design failed", "createFailed": "Creation failed" }, + "filterBar": { + "all": "All", + "allEpisodes": "All Episodes", + "episodeLabel": "Ep.{number} · {name}" + }, "assetLibrary": { "button": "Asset Library", "title": "Asset Library", "copySuccessCharacter": "Character appearance copied successfully", "copySuccessLocation": "Location image copied successfully", + "copySuccessProp": "Prop image copied successfully", "copySuccessVoice": "Voice copied successfully", "copyFailed": "Copy failed: {error}", "downloadEmpty": "No image assets to download", @@ -332,4 +354,4 @@ "referenceImagesHint": "(optional, paste supported)", "startEditing": "Start Editing" } -} \ No newline at end of file +} diff --git a/messages/zh/assetHub.json b/messages/zh/assetHub.json index 36940e0..0088362 100644 --- a/messages/zh/assetHub.json +++ b/messages/zh/assetHub.json @@ -9,10 +9,13 @@ "allAssets": "所有资产", "characters": "角色", "locations": "场景", + "props": "道具", "voices": "音色", "addCharacter": "新建角色", "addLocation": "新建场景", + "addProp": "新建道具", "addVoice": "新建音色", + "addAsset": "新建资产", "downloadAll": "打包下载", "downloadAllTitle": "下载全部图片资产", "downloading": "打包中...", @@ -37,7 +40,10 @@ "confirmDeleteFolder": "确定删除此文件夹吗?文件夹内的资产将移至未分类。", "confirmDeleteCharacter": "确定删除此角色吗?此操作无法撤回。", "confirmDeleteLocation": "确定删除此场景吗?此操作无法撤回。", + "confirmDeleteProp": "确定删除此道具吗?此操作无法撤回。", "confirmDeleteVoice": "确定删除此音色吗?此操作无法撤回。", + "locationLabel": "场景", + "propLabel": "道具", "voiceName": "音色名称", "voiceNamePlaceholder": "请输入音色名称", "voiceNameRequired": "请输入音色名称", @@ -104,4 +110,4 @@ "dropOrClick": "拖放图片或点击上传", "supportedFormats": "支持 JPG、PNG 格式" } -} \ No newline at end of file +} diff --git a/messages/zh/assetLibrary.json b/messages/zh/assetLibrary.json index 14abe46..04e3d06 100644 --- a/messages/zh/assetLibrary.json +++ b/messages/zh/assetLibrary.json @@ -3,12 +3,15 @@ "button": "资产库", "characters": "角色", "locations": "场景", + "props": "道具", "noCharacters": "暂无角色", "noLocations": "暂无场景", + "noProps": "暂无道具", "addCharacter": "添加角色", "addLocation": "添加场景", + "addProp": "添加道具", "generateImage": "生成图片", "regenerateImage": "重新生成", "analyzeAssets": "分析资产", "analyzing": "分析中..." -} \ No newline at end of file +} diff --git a/messages/zh/assetModal.json b/messages/zh/assetModal.json index 7007d58..4f50cea 100644 --- a/messages/zh/assetModal.json +++ b/messages/zh/assetModal.json @@ -51,6 +51,13 @@ "description": "场景描述", "descPlaceholder": "请输入场景描述..." }, + "prop": { + "title": "新建道具", + "name": "道具名称", + "namePlaceholder": "请输入道具名称", + "summary": "道具描述", + "summaryPlaceholder": "请输入道具描述..." + }, "artStyle": { "title": "画面风格" }, @@ -72,6 +79,8 @@ "addOnlyToAssetHub": "仅添加人物到资产库", "addOnlyLocation": "仅添加场景", "addOnlyToAssetHubLocation": "仅添加场景到资产库", + "addOnlyProp": "仅添加道具", + "addOnlyToAssetHubProp": "仅添加道具到资产库", "addAndGeneratePrefix": "添加并生成", "generateCountSuffix": "张图片", "selectGenerateCount": "选择生成数量", diff --git a/messages/zh/assetPicker.json b/messages/zh/assetPicker.json index 06eb5b9..3e36cec 100644 --- a/messages/zh/assetPicker.json +++ b/messages/zh/assetPicker.json @@ -1,6 +1,7 @@ { "selectCharacter": "从资产中心选择角色", "selectLocation": "从资产中心选择场景", + "selectProp": "从资产中心选择道具", "selectVoice": "从资产中心选择音色", "searchPlaceholder": "搜索资产名称或文件夹...", "noAssets": "资产中心暂无资产", @@ -15,4 +16,4 @@ "copyFailed": "复制失败", "preview": "试听", "stop": "停止" -} \ No newline at end of file +} diff --git a/messages/zh/assets.json b/messages/zh/assets.json index 86c9e49..576ed60 100644 --- a/messages/zh/assets.json +++ b/messages/zh/assets.json @@ -3,6 +3,7 @@ "title": "资产确认", "characters": "角色", "locations": "场景", + "props": "道具", "analyze": "分析资产", "analyzing": "分析中...", "generateAll": "批量生成全部", @@ -14,8 +15,10 @@ "assetsTitle": "资产分析", "characterAssets": "角色资产", "locationAssets": "场景资产", + "propAssets": "道具资产", "counts": "{characterCount} 个角色,{appearanceCount} 个形象", "locationCounts": "{count} 个场景", + "propCounts": "{count} 个道具", "undoFailed": "撤回失败", "undoFailedError": "撤回失败: {error}", "undoSuccess": "已撤回到上一版本", @@ -70,6 +73,18 @@ "updateFailed": "更新描述失败", "addFailed": "添加场景失败" }, + "prop": { + "add": "添加道具", + "edit": "编辑道具", + "delete": "删除道具", + "deleteConfirm": "确定要删除这个道具吗?", + "deleteFailed": "删除失败: {error}", + "name": "道具名", + "summary": "简要描述", + "summaryPlaceholder": "描述这个道具", + "regenerateImage": "重新生成", + "addFailed": "添加道具失败" + }, "image": { "upload": "上传图片", "uploadReplace": "上传替换图片", @@ -160,6 +175,7 @@ "modifyFailed": "修改失败", "editCharacter": "编辑角色", "editLocation": "编辑场景", + "editProp": "编辑道具", "saveAndGenerate": "保存并生成", "generatingAutoClose": "正在生成图片,完成后将自动关闭...", "aiLocationTip": "输入你想修改的内容,AI会自动调整场景描述", @@ -177,7 +193,7 @@ "showGenerated": "已生成", "showPending": "待生成", "assetManagement": "资产管理", - "assetCount": "共 {total} 个资产({appearances} 角色形象 + {locations} 场景)", + "assetCount": "共 {total} 个资产({appearances} 角色形象 + {locations} 场景 + {props} 道具)", "globalAnalyze": "全局分析", "globalAnalyzing": "正在执行全局资产分析...", "globalAnalyzingHint": "请勿刷新页面,分析完成后将自动显示结果", @@ -239,11 +255,17 @@ "aiDesignFailed": "AI 设计失败", "createFailed": "创建失败" }, + "filterBar": { + "all": "全部", + "allEpisodes": "全部集数", + "episodeLabel": "第{number}集 · {name}" + }, "assetLibrary": { "button": "资产库", "title": "资产库", "copySuccessCharacter": "角色形象复制成功", "copySuccessLocation": "场景图片复制成功", + "copySuccessProp": "道具图片复制成功", "copySuccessVoice": "音色复制成功", "copyFailed": "复制失败: {error}", "downloadEmpty": "当前没有可下载的图片资产", @@ -332,4 +354,4 @@ "referenceImagesHint": "(可选,支持粘贴)", "startEditing": "开始编辑" } -} \ No newline at end of file +} diff --git a/prisma/migrations/20260317120000_add_asset_kind_to_locations/migration.sql b/prisma/migrations/20260317120000_add_asset_kind_to_locations/migration.sql new file mode 100644 index 0000000..85c7508 --- /dev/null +++ b/prisma/migrations/20260317120000_add_asset_kind_to_locations/migration.sql @@ -0,0 +1,11 @@ +ALTER TABLE `novel_promotion_locations` + ADD COLUMN `assetKind` VARCHAR(191) NOT NULL DEFAULT 'location'; + +ALTER TABLE `global_locations` + ADD COLUMN `assetKind` VARCHAR(191) NOT NULL DEFAULT 'location'; + +ALTER TABLE `novel_promotion_clips` + ADD COLUMN `props` TEXT NULL; + +ALTER TABLE `novel_promotion_panels` + ADD COLUMN `props` TEXT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4d7a0f9..408766e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -104,6 +104,7 @@ model NovelPromotionLocation { novelPromotionProjectId String name String summary String? @db.Text // 场景简要描述(用途/人物关联) + assetKind String @default("location") createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt sourceGlobalLocationId String? // 🆕 来源全局场景ID(复制时记录) @@ -170,6 +171,7 @@ model NovelPromotionClip { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt characters String? @db.Text + props String? @db.Text endText String? @db.Text shotCount Int? startText String? @db.Text @@ -192,6 +194,7 @@ model NovelPromotionPanel { description String? @db.Text location String? @db.Text characters String? @db.Text + props String? @db.Text srtSegment String? @db.Text srtStart Float? srtEnd Float? @@ -878,6 +881,7 @@ model GlobalLocation { name String artStyle String? summary String? @db.Text + assetKind String @default("location") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/[locale]/dev/segmented-control-test/page.tsx b/src/app/[locale]/dev/segmented-control-test/page.tsx new file mode 100644 index 0000000..7f10dbb --- /dev/null +++ b/src/app/[locale]/dev/segmented-control-test/page.tsx @@ -0,0 +1,1091 @@ +'use client' + +import { useState, useRef, useEffect, type ReactNode } from 'react' + +// ─── 演示选项 ───────────────────────────────────────── +const demoTabs = [ + { value: 'all', label: '全部' }, + { value: 'character', label: '角色' }, + { value: 'location', label: '场景' }, + { value: 'prop', label: '道具' }, +] + +const demoTabsWithCount = [ + { value: 'all', label: '全部 (24)' }, + { value: 'character', label: '角色 (12)' }, + { value: 'location', label: '场景 (8)' }, + { value: 'prop', label: '道具 (4)' }, +] + +// ─── 原始版本 (Current) ────────────────────────────────── +function SegmentedCurrent({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + return ( +
+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +// ─── 方案 A: 滑动指示器 (Sliding Pill) ────────────────── +function SegmentedSlidingPill({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setIndicatorStyle({ + left: activeButton.offsetLeft, + width: activeButton.offsetWidth, + }) + } + }, [value, options]) + + return ( +
+
+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +// ─── 方案 B: 渐变下划线 (Gradient Underline) ──────────── +function SegmentedGradientUnderline({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [lineStyle, setLineStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setLineStyle({ + left: activeButton.offsetLeft + 8, + width: activeButton.offsetWidth - 16, + }) + } + }, [value, options]) + + return ( +
+
+ {options.map((opt) => ( + + ))} +
+ {/* 渐变下划线 */} +
+
+
+ ) +} + +// ─── 方案 C: 胶囊按钮组 (Capsule Group) ───────────────── +function SegmentedCapsule({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ) +} + +// ─── 方案 D: 霓虹卡片 (Neon Card) ─────────────────────── +function SegmentedNeonCard({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setIndicatorStyle({ + left: activeButton.offsetLeft, + width: activeButton.offsetWidth, + }) + } + }, [value, options]) + + return ( +
+
+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +// ─── 方案 E: 玻璃态 (Glassmorphism) ───────────────────── +function SegmentedGlass({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setIndicatorStyle({ + left: activeButton.offsetLeft, + width: activeButton.offsetWidth, + }) + } + }, [value, options]) + + return ( +
+
+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +// ─── 方案 F: 浮雕质感 (Embossed) ──────────────────────── +function SegmentedEmbossed({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ) +} + +// ═══════════════════════════════════════════════════════════ +// 🆕 Round 2: 基于 Sliding Pill + Neon Card + Glass 融合 +// ═══════════════════════════════════════════════════════════ + +// ─── 方案 G: 玻璃霓虹滑块 (Glass Neon Slide) ──────────── +// 融合: Glass 的半透明底 + Neon 的渐变发光指示器 + Pill 的滑动动画 +function SegmentedGlassNeonSlide({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setIndicatorStyle({ left: activeButton.offsetLeft, width: activeButton.offsetWidth }) + } + }, [value, options]) + + return ( +
+
+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +// ─── 方案 H: 极光药丸 (Aurora Pill) ───────────────────── +// 融合: Pill 的圆润滑动 + 极光渐变边框 + Glass 的通透感 +function SegmentedAuroraPill({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setIndicatorStyle({ left: activeButton.offsetLeft, width: activeButton.offsetWidth }) + } + }, [value, options]) + + return ( +
+
+
+
+
+
+
+
+
+ {options.map((opt) => ( + + ))} +
+
+
+
+ ) +} + +// ─── 方案 I: 冰晶霓虹 (Ice Neon) ──────────────────────── +// 融合: Neon 的发光效果 + 冷调蓝色渐变 + Pill 的弹性滑动 + Glass 的边框 +function SegmentedIceNeon({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setIndicatorStyle({ left: activeButton.offsetLeft, width: activeButton.offsetWidth }) + } + }, [value, options]) + + return ( +
+
+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +// ─── 方案 J: 磨砂药丸 (Frosted Pill) ──────────────────── +// 融合: Pill 的弹性滑动 + Glass 的磨砂质感 + 微妙的内阴影 +function SegmentedFrostedPill({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setIndicatorStyle({ left: activeButton.offsetLeft, width: activeButton.offsetWidth }) + } + }, [value, options]) + + return ( +
+
+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +// ─── 方案 K: 星辰玻璃 (Stellar Glass) ─────────────────── +// 融合: 深色渐变底 + Glass 的通透指示器 + Neon 的微光边缘 + Pill 的滑动 +function SegmentedStellarGlass({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setIndicatorStyle({ left: activeButton.offsetLeft, width: activeButton.offsetWidth }) + } + }, [value, options]) + + return ( +
+
+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +// ═══════════════════════════════════════════════════════════ +// 🏆 Final Round: 最终候选 — 对齐当前系统 px-3 py-1.5 间距 +// ═══════════════════════════════════════════════════════════ + +// ─── Final 1: 滑动药丸 (Sliding Pill Final) ───────────── +// 与当前系统对等的 px-3 py-1.5 按钮间距 +function SegmentedSlidingPillFinal({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setIndicatorStyle({ left: activeButton.offsetLeft, width: activeButton.offsetWidth }) + } + }, [value, options]) + + return ( +
+
+ {/* 指示器放在 grid 内部,与按钮共享定位参考系 */} +
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +// ─── Final 2: 蓝色霓虹 (Blue Neon Final) ──────────────── +// 蓝色渐变 + 与当前系统对等的 px-3 py-1.5 按钮间距 +function SegmentedBlueNeonFinal({ options, value, onChange }: { + options: Array<{ value: string; label: ReactNode }> + value: string + onChange: (v: string) => void +}) { + const containerRef = useRef(null) + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + + useEffect(() => { + if (!containerRef.current) return + const activeIndex = options.findIndex((opt) => opt.value === value) + const buttons = containerRef.current.querySelectorAll('button') + const activeButton = buttons[activeIndex] + if (activeButton) { + setIndicatorStyle({ left: activeButton.offsetLeft, width: activeButton.offsetWidth }) + } + }, [value, options]) + + return ( +
+
+ {/* 指示器放在 grid 内部,与按钮共享定位参考系 */} +
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +// ─── 页面 ───────────────────────────────────────────── +export default function SegmentedControlTestPage() { + const [v1, setV1] = useState('all') + const [v2, setV2] = useState('all') + const [v3, setV3] = useState('all') + const [v4, setV4] = useState('all') + const [v5, setV5] = useState('all') + const [v6, setV6] = useState('all') + + // 带计数的独立状态 + const [v1c, setV1c] = useState('all') + const [v2c, setV2c] = useState('all') + const [v3c, setV3c] = useState('all') + const [v4c, setV4c] = useState('all') + const [v5c, setV5c] = useState('all') + const [v6c, setV6c] = useState('all') + + // Round 2 状态 + const [vG, setVG] = useState('all') + const [vH, setVH] = useState('all') + const [vI, setVI] = useState('all') + const [vJ, setVJ] = useState('all') + const [vK, setVK] = useState('all') + const [vGc, setVGc] = useState('all') + const [vHc, setVHc] = useState('all') + const [vIc, setVIc] = useState('all') + const [vJc, setVJc] = useState('all') + const [vKc, setVKc] = useState('all') + + // Final 状态 + const [vF1, setVF1] = useState('all') + const [vF2, setVF2] = useState('all') + const [vF1c, setVF1c] = useState('all') + const [vF2c, setVF2c] = useState('all') + + return ( +
+
+ {/* 页面标题 */} +
+

+ SegmentedControl 样式对比 +

+

+ 共 13 种方案 · Round 1 (A-F) + Round 2 (G-K) + 🏆 Final (2) · 每种方案展示简约版和带计数版 +

+
+ + {/* ─── 0. Current (原始) ─── */} +
+
+ CURRENT +

当前版本 · iOS Segmented

+
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ─── A. Sliding Pill ─── */} +
+
+ A +

滑动药丸 · Sliding Pill

+ 平滑的滑动动画指示器 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ─── B. Gradient Underline ─── */} +
+
+ B +

渐变下划线 · Gradient Underline

+ 紫色渐变下划线指示器, 极简风格 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ─── C. Capsule ─── */} +
+
+ C +

胶囊按钮 · Capsule Group

+ 黑白反转, 胶囊形态 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ─── D. Neon Card ─── */} +
+
+ D +

霓虹卡片 · Neon Card

+ 渐变紫色指示器, 发光效果 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ─── E. Glass ─── */} +
+
+ E +

毛玻璃 · Glassmorphism

+ 半透明毛玻璃质感, 融入背景 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ─── F. Embossed ─── */} +
+
+ F +

浮雕质感 · Embossed

+ 微妙的渐变 + 浮雕阴影, 精致的立体感 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ═══════════════════════════════════════════════════ */} + {/* 🆕 Round 2: 融合变体 */} + {/* ═══════════════════════════════════════════════════ */} + +
+
+ 🆕 Round 2 + 基于 Sliding Pill + Neon Card + Glass 融合 +
+
+ + {/* ─── G. Glass Neon Slide ─── */} +
+
+ G +

玻璃霓虹滑块 · Glass Neon Slide

+ Glass 半透明底 + Neon 渐变发光 + 弹性滑动 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ─── H. Aurora Pill ─── */} +
+
+ H +

极光药丸 · Aurora Pill

+ 彩虹渐变边框 + 圆润全圆角 + 弹性滑动指示器 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ─── I. Ice Neon ─── */} +
+
+ I +

冰晶霓虹 · Ice Neon

+ 冷调蓝紫渐变发光 + 弹性滑动 + 淡蓝底色 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ─── J. Frosted Pill ─── */} +
+
+ J +

磨砂药丸 · Frosted Pill

+ 磨砂质感底 + 超弹性滑动 + 选中加粗 + 精致内阴影 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* ─── K. Stellar Glass ─── */} +
+
+ K +

星辰玻璃 · Stellar Glass

+ 深色宇宙渐变底 + 发光玻璃指示器 + 紫色辉光文字 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+
+ + {/* 暗色背景对比区 - Round 1 */} +
+

🌙 暗色背景参考 · Round 1

+
+
+ Current + +
+
+ A · Sliding Pill + +
+
+ B · Gradient Underline + +
+
+ C · Capsule + +
+
+ D · Neon Card + +
+
+ E · Glassmorphism + +
+
+ F · Embossed + +
+
+
+ + {/* 暗色背景对比区 - Round 2 */} +
+

🌙 暗色背景参考 · Round 2

+
+
+ G · Glass Neon Slide + +
+
+ H · Aurora Pill + +
+
+ I · Ice Neon + +
+
+ J · Frosted Pill + +
+
+ K · Stellar Glass + +
+
+
+ + {/* ═══════════════════════════════════════════════════ */} + {/* 🏆 Final Round: 最终候选 */} + {/* ═══════════════════════════════════════════════════ */} + +
+
+ 🏆 Final Round + 对齐系统 px-3 py-1.5 间距 · 霓虹改蓝色 +
+
+ + {/* ─── Final 1: Sliding Pill ─── */} +
+
+ F1 +

滑动药丸 · Sliding Pill Final

+ 对齐系统间距 px-3 py-1.5 + 滑动动画 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+ 对照 + + ← 当前系统 +
+
+
+ + {/* ─── Final 2: Blue Neon ─── */} +
+
+ F2 +

蓝色霓虹 · Blue Neon Final

+ 蓝色渐变发光 + 对齐系统间距 px-3 py-1.5 +
+
+
+ 简约 + +
+
+ 计数 + +
+
+ 对照 + + ← 当前系统 +
+
+
+ + {/* 🏆 Final 暗色背景对比区 */} +
+

🌙 暗色背景参考 · Final

+
+
+ Current (对照) + +
+
+ F1 · Sliding Pill Final + +
+
+ F2 · Blue Neon Final + +
+
+
+ + {/* Footer 说明 */} +
+ 测试页面 · 仅用于样式对比 · 选择喜欢的方案后替换 SegmentedControl.tsx +
+
+
+ ) +} diff --git a/src/app/[locale]/workspace/[projectId]/hooks/useProject.ts b/src/app/[locale]/workspace/[projectId]/hooks/useProject.ts index 2a8d542..f33f76e 100644 --- a/src/app/[locale]/workspace/[projectId]/hooks/useProject.ts +++ b/src/app/[locale]/workspace/[projectId]/hooks/useProject.ts @@ -96,7 +96,8 @@ export function useProject(projectId: string) { novelPromotionData: { ...prev.novelPromotionData, characters: assets.characters || [], - locations: assets.locations || [] + locations: assets.locations || [], + props: assets.props || [], } } }) diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetLibrary.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetLibrary.tsx index 7fe3d9d..597190c 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetLibrary.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetLibrary.tsx @@ -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 diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetsStage.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetsStage.tsx index 2fac828..f960322 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetsStage.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetsStage.tsx @@ -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(null) const [toast, setToast] = useState<{ message: string; type: 'success' | 'warning' | 'error' } | null>(null) + const [kindFilter, setKindFilter] = useState('all') + const [episodeFilter, setEpisodeFilter] = useState(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} + /> + + {/* 资产筛选栏 */} + - {/* 角色资产区块 */} - 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} - /> - - {/* 场景资产区块 */} - 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') && ( + 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') && ( + 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') && ( + 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} + /> + )} 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 ( +
+ +
+ ) +} diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar.tsx index bcd18fc..f929967 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar.tsx @@ -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(null) + const menuRef = useRef(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 ( + <> + + {open && menuPos && createPortal( +
+ {/* All episodes option */} + + {/* Divider */} +
+ {/* Episode list */} + {episodes.map((ep) => ( + + ))} +
, + 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({ {t("toolbar.assetManagement")} + {/* 剧集筛选 chip */} + {episodes.length > 0 && ( + + )} - {t("toolbar.assetCount", { total: totalAssets, appearances: totalAppearances, locations: totalLocations })} + {t("toolbar.assetCount", { total: totalAssets, appearances: totalAppearances, locations: totalLocations, props: totalProps })} {/* 全局资产分析按钮 */} {onGlobalAnalyze && ( diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetsStageModals.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetsStageModals.tsx index 4a3b674..88b02a8 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetsStageModals.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetsStageModals.tsx @@ -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 + handleGenerateImage: (type: 'character' | 'location' | 'prop', id: string, appearanceId?: string) => Promise handleUpdateAppearanceDescription: (newDescription: string) => Promise handleUpdateLocationDescription: (newDescription: string) => Promise handleLocationImageEdit: (modifyPrompt: string, extraImageUrls?: string[]) => Promise @@ -64,8 +73,10 @@ interface AssetsStageModalsProps { handleConfirmProfile: (characterId: string, updatedProfileData?: CharacterProfileData) => Promise 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 && ( + { + closeAddProp() + onRefresh() + }} + /> + )} + {voiceDesignCharacter && ( )} + {editingProp && ( + + )} + {editingProfile && ( void // 🆕 从资产中心复制 // 辅助函数 getAppearances: (character: Character) => CharacterAppearance[] + /** 分集筛选:仅显示指定 ID 的角色,null 表示显示全部 */ + filterIds?: Set | 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(null) const scrollAnimationRef = useRef(null) diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard.tsx index 12cd641..acf501b 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard.tsx @@ -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(null) const [pendingUploadIndex, setPendingUploadIndex] = useState(undefined) @@ -218,7 +221,7 @@ export default function LocationCard({ @@ -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 ? ( @@ -329,25 +332,25 @@ export default function LocationCard({ const compactHeaderActions = ( <> {onCopyFromGlobal && ( - )} - - diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection.tsx index 2971301..7ed4eed 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection.tsx @@ -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 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 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 | 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 = assetType === 'prop' + ? assets?.props ?? [] + : assets?.locations ?? [] + const locations = filterIds ? allLocations.filter((l) => filterIds.has(l.id)) : allLocations + const assetKey = assetType === 'prop' ? 'prop' : 'location' return (
@@ -68,16 +76,20 @@ export default function LocationSection({ -

{t("stage.locationAssets")}

+

+ {assetType === 'prop' ? t('stage.propAssets') : t("stage.locationAssets")} +

- {t("stage.locationCounts", { count: locations.length })} + {assetType === 'prop' + ? t('stage.propCounts', { count: locations.length }) + : t("stage.locationCounts", { count: locations.length })}
@@ -86,6 +98,7 @@ export default function LocationSection({ 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} /> ))}
diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetModals.ts b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetModals.ts index e890081..ef4ec7a 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetModals.ts +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetModals.ts @@ -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(null) // 场景编辑弹窗 const [editingLocation, setEditingLocation] = useState(null) + const [editingProp, setEditingProp] = useState(null) // 新增弹窗 const [showAddCharacter, setShowAddCharacter] = useState(false) const [showAddLocation, setShowAddLocation] = useState(false) + const [showAddProp, setShowAddProp] = useState(false) // 图片编辑弹窗 const [imageEditModal, setImageEditModal] = useState(null) const [characterImageEditModal, setCharacterImageEditModal] = useState(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 diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetsCopyFromHub.ts b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetsCopyFromHub.ts index 91375bd..e9f6f30 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetsCopyFromHub.ts +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetsCopyFromHub.ts @@ -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, diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useLocationActions.ts b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useLocationActions.ts index 1adcda0..b8aa87c 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useLocationActions.ts +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useLocationActions.ts @@ -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 供组件使用 diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel.tsx index 690daa5..be7eef0 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel.tsx @@ -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 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>(new Set()) const [pendingAppearanceKeys, setPendingAppearanceKeys] = useState>(new Set()) @@ -150,12 +158,17 @@ export default function ScriptViewAssetsPanel({ const [initialLocationLabels, setInitialLocationLabels] = useState>({}) const [isSavingCharacterSelection, setIsSavingCharacterSelection] = useState(false) const [isSavingLocationSelection, setIsSavingLocationSelection] = useState(false) + const [pendingPropIds, setPendingPropIds] = useState>(new Set()) + const [isSavingPropSelection, setIsSavingPropSelection] = useState(false) const hasInitializedCharDraftRef = useRef(false) const hasInitializedLocDraftRef = useRef(false) + const hasInitializedPropDraftRef = useRef(false) const charEditorTriggerRef = useRef(null) const charEditorPopoverRef = useRef(null) const locEditorTriggerRef = useRef(null) const locEditorPopoverRef = useRef(null) + const propEditorTriggerRef = useRef(null) + const propEditorPopoverRef = useRef(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 (
-
+

{tScript('inSceneAssets')}

-
- - {clips.map((clip, idx) => ( +
+
- ))} + {clips.map((clip, idx) => ( + + ))} +
-
- {assetsLoading && characters.length === 0 && locations.length === 0 && ( -
- -
- )} +
+
+ {assetsLoading && characters.length === 0 && locations.length === 0 && ( +
+ +
+ )} -
-
-

- {tScript('asset.activeCharacters')} ({characters.filter((c) => activeCharIds.includes(c.id)).reduce((sum, char) => sum + getSelectedAppearances(char).length, 0)}) -

- -
+
+
+

+ {tScript('asset.activeCharacters')} ({characters.filter((c) => activeCharIds.includes(c.id)).reduce((sum, char) => sum + getSelectedAppearances(char).length, 0)}) +

+ +
{showAddChar && mounted && createPortal(
@@ -528,58 +587,61 @@ export default function ScriptViewAssetsPanel({ document.body, )} - {activeCharIds.length === 0 ? ( -
{tScript('screenplay.noCharacter')}
- ) : ( -
- {characters - .filter((c) => activeCharIds.includes(c.id)) - .flatMap((char) => { - const selectedApps = getSelectedAppearances(char) - if (selectedApps.length === 0) { - return ( - { }} - onOpenAssetLibrary={onOpenAssetLibrary} - onRemove={() => void onUpdateClipAssets('character', 'remove', char.id, tScript('asset.defaultAppearance'))} - /> - ) - } - return selectedApps.map((appearance) => ( - { }} - onOpenAssetLibrary={onOpenAssetLibrary} - onRemove={() => void onUpdateClipAssets('character', 'remove', char.id, appearance.changeReason || tScript('asset.defaultAppearance'))} - /> - )) - })} -
- )} -
- -
-
-

{tScript('asset.activeLocations')} ({activeLocationIds.length})

- + {activeCharIds.length === 0 ? ( +
{tScript('screenplay.noCharacter')}
+ ) : ( +
+ {characters + .filter((c) => activeCharIds.includes(c.id)) + .flatMap((char) => { + const selectedApps = getSelectedAppearances(char) + if (selectedApps.length === 0) { + return ( +
+ { }} + onOpenAssetLibrary={onOpenAssetLibrary} + onRemove={() => void onUpdateClipAssets('character', 'remove', char.id, tScript('asset.defaultAppearance'))} + /> +
+ ) + } + return selectedApps.map((appearance) => ( +
+ { }} + onOpenAssetLibrary={onOpenAssetLibrary} + onRemove={() => void onUpdateClipAssets('character', 'remove', char.id, appearance.changeReason || tScript('asset.defaultAppearance'))} + /> +
+ )) + })} +
+ )}
+
+
+

{tScript('asset.activeLocations')} ({activeLocationIds.length})

+ +
+ {showAddLoc && mounted && createPortal(
{tCommon('edit')} · {tScript('asset.activeLocations')}
@@ -673,27 +735,131 @@ export default function ScriptViewAssetsPanel({ document.body, )} - {activeLocationIds.length === 0 ? ( -
{tScript('screenplay.noLocation')}
- ) : ( -
- {locations.filter((l) => activeLocationIds.includes(l.id)).map((loc) => ( - { }} - onOpenAssetLibrary={onOpenAssetLibrary} - onRemove={() => void onUpdateClipAssets('location', 'remove', loc.id)} - /> - ))} + {activeLocationIds.length === 0 ? ( +
{tScript('screenplay.noLocation')}
+ ) : ( +
+ {locations.filter((l) => activeLocationIds.includes(l.id)).map((loc) => ( +
+ { }} + onOpenAssetLibrary={onOpenAssetLibrary} + onRemove={() => void onUpdateClipAssets('location', 'remove', loc.id)} + /> +
+ ))} +
+ )} +
+ +
+
+

道具 ({activePropIds.length})

+
+ + {showAddProp && mounted && createPortal( +
+
{tCommon('edit')} · 道具
+
+
+ {props.map((prop) => { + const isSelected = pendingPropIds.has(prop.id) + const previewImage = getSelectedLocationImage(prop as unknown as Location)?.imageUrl || null + return ( + + ) + })} +
+
+
+ + +
+
, + document.body, )} + + {activePropIds.length === 0 ? ( +
当前片段未选择道具
+ ) : ( +
+ {props.filter((prop) => activePropIds.includes(prop.id)).map((prop) => ( +
+ { }} + onOpenAssetLibrary={onOpenAssetLibrary} + onRemove={() => void onUpdateClipAssets('prop', 'remove', prop.id)} + /> +
+ ))} +
+ )} +
- {!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}

+
{/* 编辑按钮 */} diff --git a/src/app/[locale]/workspace/asset-hub/page.tsx b/src/app/[locale]/workspace/asset-hub/page.tsx index a00068e..515928a 100644 --- a/src/app/[locale]/workspace/asset-hub/page.tsx +++ b/src/app/[locale]/workspace/asset-hub/page.tsx @@ -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(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() { {/* 右侧资产网格 */} 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)} />
@@ -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 && ( + setShowAddProp(false)} + onSuccess={() => { + setShowAddProp(false) + refreshAssets() }} /> )} @@ -568,6 +609,18 @@ export default function AssetHubPage() { /> )} + {propEditModal && ( + setPropEditModal(null)} + onRefresh={refreshAssets} + /> + )} + {/* 新建音色弹窗 */} {showAddVoice && ( { setShowAddVoice(false) queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.voices() }) + refreshAssets() }} /> )} diff --git a/src/app/api/asset-hub/generate-image/route.ts b/src/app/api/asset-hub/generate-image/route.ts index 63919d9..ffdf140 100644 --- a/src/app/api/asset-hub/generate-image/route.ts +++ b/src/app/api/asset-hub/generate-image/route.ts @@ -1,177 +1,31 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireUserAuth, isErrorResponse } from '@/lib/api-auth' -import { apiHandler, ApiError, getRequestId } from '@/lib/api-errors' -import { submitTask } from '@/lib/task/submitter' -import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale' -import { TASK_TYPE } from '@/lib/task/types' -import { buildDefaultTaskBillingInfo } from '@/lib/billing' -import { getUserModelConfig, buildImageBillingPayloadFromUserConfig } from '@/lib/config-service' -import { prisma } from '@/lib/prisma' -import { - hasGlobalCharacterOutput, - hasGlobalLocationOutput -} from '@/lib/task/has-output' -import { withTaskUiPayload } from '@/lib/task/ui-payload' -import { PRIMARY_APPEARANCE_INDEX, isArtStyleValue } from '@/lib/constants' -import { normalizeImageGenerationCount } from '@/lib/image-generation/count' -import { ensureGlobalLocationImageSlots } from '@/lib/image-generation/location-slots' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireUserAuth } from '@/lib/api-auth' +import { submitAssetGenerateTask } from '@/lib/assets/services/asset-actions' -function toNumber(value: unknown) { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : null -} - -function toObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - return value as Record -} - -function normalizeString(value: unknown): string { - return typeof value === 'string' ? value.trim() : '' -} - -function resolveRequestedArtStyle(body: Record): string | null { - if (!Object.prototype.hasOwnProperty.call(body, 'artStyle')) return null - const artStyle = normalizeString(body.artStyle) - if (!isArtStyleValue(artStyle)) { - throw new ApiError('INVALID_PARAMS', { - code: 'INVALID_ART_STYLE', - message: 'artStyle must be a supported value', - }) - } - return artStyle -} - -async function resolveStoredArtStyle(input: { - userId: string - type: 'character' | 'location' - id: string - appearanceIndex: number -}): Promise { - if (input.type === 'character') { - const appearance = await prisma.globalCharacterAppearance.findFirst({ - where: { - characterId: input.id, - appearanceIndex: input.appearanceIndex, - character: { userId: input.userId }, - }, - select: { artStyle: true }, - }) - if (!appearance) { - throw new ApiError('NOT_FOUND') - } - const artStyle = normalizeString(appearance.artStyle) - if (!isArtStyleValue(artStyle)) { - throw new ApiError('INVALID_PARAMS', { - code: 'MISSING_ART_STYLE', - message: 'Character appearance artStyle is not configured', - }) - } - return artStyle - } - - const location = await prisma.globalLocation.findFirst({ - where: { - id: input.id, - userId: input.userId, - }, - select: { artStyle: true }, - }) - if (!location) { - throw new ApiError('NOT_FOUND') - } - const artStyle = normalizeString(location.artStyle) - if (!isArtStyleValue(artStyle)) { - throw new ApiError('INVALID_PARAMS', { - code: 'MISSING_ART_STYLE', - message: 'Location artStyle is not configured', - }) - } - return artStyle +type LegacyGenerateBody = Record & { + type?: 'character' | 'location' + id?: string } export const POST = apiHandler(async (request: NextRequest) => { const authResult = await requireUserAuth() if (isErrorResponse(authResult)) return authResult - const { session } = authResult - const rawBody = await request.json().catch(() => ({})) - const body = toObject(rawBody) - const locale = resolveRequiredTaskLocale(request, body) - const type = normalizeString(body.type) - const id = normalizeString(body.id) - if (!type || !id) { + const body = await request.json() as LegacyGenerateBody + if ((body.type !== 'character' && body.type !== 'location') || typeof body.id !== 'string' || body.id.trim().length === 0) { throw new ApiError('INVALID_PARAMS') } - if (type !== 'character' && type !== 'location') { - throw new ApiError('INVALID_PARAMS') - } - const appearanceIndex = toNumber(body.appearanceIndex) - const resolvedAppearanceIndex = appearanceIndex ?? PRIMARY_APPEARANCE_INDEX - const count = type === 'character' - ? normalizeImageGenerationCount('character', body.count) - : normalizeImageGenerationCount('location', body.count) - const requestedArtStyle = resolveRequestedArtStyle(body) - const artStyle = requestedArtStyle || await resolveStoredArtStyle({ - userId: session.user.id, - type, - id, - appearanceIndex: resolvedAppearanceIndex, - }) - if (type === 'location' && toNumber(body.imageIndex) === null) { - const location = await prisma.globalLocation.findFirst({ - where: { id, userId: session.user.id }, - select: { name: true, summary: true }, - }) - if (!location) { - throw new ApiError('NOT_FOUND') - } - await ensureGlobalLocationImageSlots({ - locationId: id, - count, - fallbackDescription: location.summary || location.name, - }) - } - const payloadBase: Record = type === 'character' - ? { ...body, id, type, appearanceIndex: resolvedAppearanceIndex, artStyle, count } - : { ...body, id, type, artStyle, count } - const targetType = type === 'character' ? 'GlobalCharacter' : 'GlobalLocation' - const hasOutputAtStart = type === 'character' - ? await hasGlobalCharacterOutput({ - characterId: id, - appearanceIndex: resolvedAppearanceIndex - }) - : await hasGlobalLocationOutput({ - locationId: id - }) - const userModelConfig = await getUserModelConfig(session.user.id) - const imageModel = type === 'character' - ? userModelConfig.characterModel - : userModelConfig.locationModel - - let billingPayload: Record - try { - billingPayload = buildImageBillingPayloadFromUserConfig({ - userModelConfig, - imageModel, - basePayload: payloadBase, - }) - } catch (err) { - const message = err instanceof Error ? err.message : 'Image model capability not configured' - throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message }) - } - const result = await submitTask({ - userId: session.user.id, - locale, - requestId: getRequestId(request), - projectId: 'global-asset-hub', - type: TASK_TYPE.ASSET_HUB_IMAGE, - targetType, - targetId: id, - payload: withTaskUiPayload(billingPayload, { hasOutputAtStart }), - dedupeKey: `${TASK_TYPE.ASSET_HUB_IMAGE}:${targetType}:${id}:${type === 'character' ? resolvedAppearanceIndex : 'na'}:${toNumber(body.imageIndex) === null ? count : `single:${toNumber(body.imageIndex)}`}`, - billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.ASSET_HUB_IMAGE, billingPayload) + const result = await submitAssetGenerateTask({ + request, + kind: body.type, + assetId: body.id, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, }) return NextResponse.json(result) diff --git a/src/app/api/asset-hub/modify-image/route.ts b/src/app/api/asset-hub/modify-image/route.ts index 736fa3d..f675238 100644 --- a/src/app/api/asset-hub/modify-image/route.ts +++ b/src/app/api/asset-hub/modify-image/route.ts @@ -1,116 +1,31 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireUserAuth, isErrorResponse } from '@/lib/api-auth' -import { apiHandler, ApiError, getRequestId } from '@/lib/api-errors' -import { submitTask } from '@/lib/task/submitter' -import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale' -import { TASK_TYPE } from '@/lib/task/types' -import { buildDefaultTaskBillingInfo } from '@/lib/billing' -import { getUserModelConfig, buildImageBillingPayloadFromUserConfig } from '@/lib/config-service' -import { - hasGlobalCharacterAppearanceOutput, - hasGlobalLocationImageOutput -} from '@/lib/task/has-output' -import { withTaskUiPayload } from '@/lib/task/ui-payload' -import { sanitizeImageInputsForTaskPayload } from '@/lib/media/outbound-image' -import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireUserAuth } from '@/lib/api-auth' +import { submitAssetModifyTask } from '@/lib/assets/services/asset-actions' -function toNumber(value: unknown) { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : null -} - -function toObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - return value as Record +type LegacyModifyBody = Record & { + type?: 'character' | 'location' + id?: string } export const POST = apiHandler(async (request: NextRequest) => { const authResult = await requireUserAuth() if (isErrorResponse(authResult)) return authResult - const { session } = authResult - const body = await request.json() - const locale = resolveRequiredTaskLocale(request, body) - const type = body?.type - const modifyPrompt = body?.modifyPrompt - const id = body?.id - const appearanceIndex = body?.appearanceIndex - const imageIndex = body?.imageIndex - - if (!type || !modifyPrompt || !id) { + const body = await request.json() as LegacyModifyBody + if ((body.type !== 'character' && body.type !== 'location') || typeof body.id !== 'string' || body.id.trim().length === 0) { throw new ApiError('INVALID_PARAMS') } - if (type !== 'character' && type !== 'location') { - throw new ApiError('INVALID_PARAMS') - } - - const extraImageAudit = sanitizeImageInputsForTaskPayload( - Array.isArray(body?.extraImageUrls) ? body.extraImageUrls : [], - ) - const rejectedRelativePathCount = extraImageAudit.issues.filter( - (issue) => issue.reason === 'relative_path_rejected', - ).length - if (rejectedRelativePathCount > 0) { - throw new ApiError('INVALID_PARAMS') - } - - const targetType = type === 'character' ? 'GlobalCharacterAppearance' : 'GlobalLocationImage' - const targetId = type === 'character' - ? `${id}:${appearanceIndex ?? PRIMARY_APPEARANCE_INDEX}:${imageIndex ?? 0}` - : `${id}:${imageIndex ?? 0}` - const hasOutputAtStart = type === 'character' - ? await hasGlobalCharacterAppearanceOutput({ - targetId, - characterId: id, - appearanceIndex: toNumber(appearanceIndex), - imageIndex: toNumber(imageIndex) - }) - : await hasGlobalLocationImageOutput({ - targetId, - locationId: id, - imageIndex: toNumber(imageIndex) - }) - - const payload = { - ...body, - extraImageUrls: extraImageAudit.normalized, - meta: { - ...toObject(body?.meta), - outboundImageInputAudit: { - extraImageUrls: extraImageAudit.issues - } - } - } - - const userModelConfig = await getUserModelConfig(session.user.id) - const imageModel = userModelConfig.editModel - - let billingPayload: Record - try { - billingPayload = buildImageBillingPayloadFromUserConfig({ - userModelConfig, - imageModel, - basePayload: payload, - }) - } catch (err) { - const message = err instanceof Error ? err.message : 'Image model capability not configured' - throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message }) - } - const result = await submitTask({ - userId: session.user.id, - locale, - requestId: getRequestId(request), - projectId: 'global-asset-hub', - type: TASK_TYPE.ASSET_HUB_MODIFY, - targetType, - targetId, - payload: withTaskUiPayload(billingPayload, { - intent: 'modify', - hasOutputAtStart - }), - dedupeKey: `${TASK_TYPE.ASSET_HUB_MODIFY}:${targetId}`, - billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.ASSET_HUB_MODIFY, billingPayload) + const result = await submitAssetModifyTask({ + request, + kind: body.type, + assetId: body.id, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, }) return NextResponse.json(result) diff --git a/src/app/api/asset-hub/select-image/route.ts b/src/app/api/asset-hub/select-image/route.ts index 3a4b5f2..8ad6a8e 100644 --- a/src/app/api/asset-hub/select-image/route.ts +++ b/src/app/api/asset-hub/select-image/route.ts @@ -1,159 +1,31 @@ -import { logWarn as _ulogWarn } from '@/lib/logging/core' import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract' -import { requireUserAuth, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError } from '@/lib/api-errors' -import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants' -import { deleteObject } from '@/lib/storage' -import { resolveStorageKeyFromMediaValue } from '@/lib/media/service' +import { isErrorResponse, requireUserAuth } from '@/lib/api-auth' +import { selectAssetRender } from '@/lib/assets/services/asset-actions' -interface SelectImageBody { - type?: 'character' | 'location' - id?: string - appearanceIndex?: number - imageIndex?: number - confirm?: boolean +type LegacySelectBody = Record & { + type?: 'character' | 'location' + id?: string } -/** - * POST /api/asset-hub/select-image - * 选择/确认图片方案 - */ export const POST = apiHandler(async (request: NextRequest) => { - // 🔐 统一权限验证 - const authResult = await requireUserAuth() - if (isErrorResponse(authResult)) return authResult - const { session } = authResult + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult - const body = (await request.json()) as SelectImageBody - const { type, id, appearanceIndex, imageIndex, confirm } = body + const body = await request.json() as LegacySelectBody + if ((body.type !== 'character' && body.type !== 'location') || typeof body.id !== 'string' || body.id.trim().length === 0) { + throw new ApiError('INVALID_PARAMS') + } - if (type === 'character') { - const appearance = await prisma.globalCharacterAppearance.findFirst({ - where: { - characterId: id, - appearanceIndex: appearanceIndex ?? PRIMARY_APPEARANCE_INDEX, - character: { userId: session.user.id } - } - }) + const result = await selectAssetRender({ + kind: body.type, + assetId: body.id, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, + }) - if (!appearance) { - throw new ApiError('NOT_FOUND') - } - - if (confirm && appearance.selectedIndex !== null) { - // 确认选择:只保留选中的图片,删除其他候选 - const imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'globalCharacterAppearance.imageUrls') - const selectedUrl = imageUrls[appearance.selectedIndex] - - if (!selectedUrl) { - throw new ApiError('NOT_FOUND') - } - - // 从存储中删除未选中的图片 - for (let i = 0; i < imageUrls.length; i++) { - if (i !== appearance.selectedIndex && imageUrls[i]) { - const key = await resolveStorageKeyFromMediaValue(imageUrls[i]!) - if (key) { - try { await deleteObject(key) } catch { _ulogWarn('Failed to delete image:', key) } - } - } - } - - // 同时处理 descriptions,只保留选中的描述 - let descriptions: string[] = [] - if (appearance.descriptions) { - try { descriptions = JSON.parse(appearance.descriptions) } catch { /* ignore */ } - } - const selectedDescription = descriptions[appearance.selectedIndex] || appearance.description || '' - - await prisma.globalCharacterAppearance.update({ - where: { id: appearance.id }, - data: { - imageUrl: selectedUrl, - imageUrls: encodeImageUrls([selectedUrl]), - selectedIndex: 0, - description: selectedDescription, - descriptions: JSON.stringify([selectedDescription]), - } - }) - } else { - // 只是选择,不确认 - await prisma.globalCharacterAppearance.update({ - where: { id: appearance.id }, - data: { selectedIndex: imageIndex } - }) - } - - return NextResponse.json({ success: true }) - - } else if (type === 'location') { - const location = await prisma.globalLocation.findFirst({ - where: { id, userId: session.user.id }, - include: { images: { orderBy: { imageIndex: 'asc' } } } - }) - - if (!location) { - throw new ApiError('NOT_FOUND') - } - - const images = location.images || [] - const selectedImg = images.find((img) => img.isSelected) - const confirmIndex = imageIndex ?? selectedImg?.imageIndex - - if (confirm && confirmIndex !== null && confirmIndex !== undefined) { - // 确认选择:只保留选中的图片,删除其他候选 - const targetImage = images.find((img) => img.imageIndex === confirmIndex) - if (!targetImage) { - throw new ApiError('NOT_FOUND') - } - - // 从存储中删除未选中的图片 - const imagesToDelete = images.filter((img) => img.id !== targetImage.id) - for (const img of imagesToDelete) { - if (img.imageUrl) { - const key = await resolveStorageKeyFromMediaValue(img.imageUrl) - if (key) { - try { await deleteObject(key) } catch { _ulogWarn('Failed to delete image:', key) } - } - } - } - - // 在事务中更新数据库 - await prisma.$transaction(async (tx) => { - // 删除未选中的图片记录 - await tx.globalLocationImage.deleteMany({ - where: { locationId: id!, id: { not: targetImage.id } } - }) - // 将选中图片的 imageIndex 重置为 0 - await tx.globalLocationImage.update({ - where: { id: targetImage.id }, - data: { imageIndex: 0, isSelected: true } - }) - }) - } else { - // 只是选择,不确认 - await prisma.globalLocationImage.updateMany({ - where: { locationId: id }, - data: { isSelected: false } - }) - - if (imageIndex !== null && imageIndex !== undefined) { - const targetImage = images.find((img) => img.imageIndex === imageIndex) - if (targetImage) { - await prisma.globalLocationImage.update({ - where: { id: targetImage.id }, - data: { isSelected: true } - }) - } - } - } - - return NextResponse.json({ success: true }) - - } else { - throw new ApiError('INVALID_PARAMS') - } + return NextResponse.json(result) }) - diff --git a/src/app/api/asset-hub/undo-image/route.ts b/src/app/api/asset-hub/undo-image/route.ts index 8e7ca35..02a4cf4 100644 --- a/src/app/api/asset-hub/undo-image/route.ts +++ b/src/app/api/asset-hub/undo-image/route.ts @@ -1,136 +1,31 @@ import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract' -import { requireUserAuth, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError } from '@/lib/api-errors' -import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants' +import { isErrorResponse, requireUserAuth } from '@/lib/api-auth' +import { revertAssetRender } from '@/lib/assets/services/asset-actions' -interface UndoImageBody { - type?: 'character' | 'location' - id?: string - appearanceIndex?: number +type LegacyRevertBody = Record & { + type?: 'character' | 'location' + id?: string } -interface GlobalCharacterAppearanceRecord { - id: string - imageUrl: string | null - description: string | null - descriptions: unknown - previousImageUrl: string | null - previousImageUrls: string | null - previousDescription: string | null - previousDescriptions: unknown -} - -interface GlobalLocationImageRecord { - id: string - imageUrl: string | null - description: string | null - previousImageUrl: string | null - previousDescription: string | null -} - -interface GlobalLocationRecord { - images?: GlobalLocationImageRecord[] -} - -interface AssetHubUndoDb { - globalCharacterAppearance: { - findFirst(args: Record): Promise - update(args: Record): Promise - } - globalLocation: { - findFirst(args: Record): Promise - } - globalLocationImage: { - update(args: Record): Promise - } -} - -/** - * POST /api/asset-hub/undo-image - * 撤回到上一版本图片(同时恢复描述词) - */ export const POST = apiHandler(async (request: NextRequest) => { - const db = prisma as unknown as AssetHubUndoDb - // 🔐 统一权限验证 - const authResult = await requireUserAuth() - if (isErrorResponse(authResult)) return authResult - const { session } = authResult + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult - const body = (await request.json()) as UndoImageBody - const { type, id, appearanceIndex } = body + const body = await request.json() as LegacyRevertBody + if ((body.type !== 'character' && body.type !== 'location') || typeof body.id !== 'string' || body.id.trim().length === 0) { + throw new ApiError('INVALID_PARAMS') + } - if (type === 'character') { - const appearance = await db.globalCharacterAppearance.findFirst({ - where: { - characterId: id, - appearanceIndex: appearanceIndex ?? PRIMARY_APPEARANCE_INDEX, - character: { userId: session.user.id } - } - }) + const result = await revertAssetRender({ + kind: body.type, + assetId: body.id, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, + }) - if (!appearance) { - throw new ApiError('NOT_FOUND') - } - - const previousImageUrls = decodeImageUrlsFromDb(appearance.previousImageUrls, 'globalCharacterAppearance.previousImageUrls') - if (!appearance.previousImageUrl && previousImageUrls.length === 0) { - throw new ApiError('INVALID_PARAMS') - } - - const restoredImageUrls = previousImageUrls.length > 0 - ? previousImageUrls - : (appearance.previousImageUrl ? [appearance.previousImageUrl] : []) - - // 恢复上一版本(图片 + 描述词) - await db.globalCharacterAppearance.update({ - where: { id: appearance.id }, - data: { - imageUrl: appearance.previousImageUrl || restoredImageUrls[0] || null, - imageUrls: encodeImageUrls(restoredImageUrls), - previousImageUrl: null, - previousImageUrls: encodeImageUrls([]), - selectedIndex: null, - // 🔥 同时恢复描述词 - description: appearance.previousDescription ?? appearance.description, - descriptions: appearance.previousDescriptions ?? appearance.descriptions, - previousDescription: null, - previousDescriptions: null - } - }) - - return NextResponse.json({ success: true, message: '已撤回到上一版本(图片和描述词)' }) - - } else if (type === 'location') { - const location = await db.globalLocation.findFirst({ - where: { id, userId: session.user.id }, - include: { images: true } - }) - - if (!location) { - throw new ApiError('NOT_FOUND') - } - - // 恢复所有图片的上一版本(图片 + 描述词) - for (const img of location.images || []) { - if (img.previousImageUrl) { - await db.globalLocationImage.update({ - where: { id: img.id }, - data: { - imageUrl: img.previousImageUrl, - previousImageUrl: null, - // 🔥 同时恢复描述词 - description: img.previousDescription ?? img.description, - previousDescription: null - } - }) - } - } - - return NextResponse.json({ success: true, message: '已撤回到上一版本(图片和描述词)' }) - - } else { - throw new ApiError('INVALID_PARAMS') - } + return NextResponse.json(result) }) diff --git a/src/app/api/asset-hub/update-asset-label/route.ts b/src/app/api/asset-hub/update-asset-label/route.ts index 088de4e..4306d98 100644 --- a/src/app/api/asset-hub/update-asset-label/route.ts +++ b/src/app/api/asset-hub/update-asset-label/route.ts @@ -1,24 +1,16 @@ -import { logError as _ulogError } from '@/lib/logging/core' import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { uploadObject, getSignedUrl, toFetchableUrl, generateUniqueKey } from '@/lib/storage' -import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract' -import { resolveStorageKeyFromMediaValue } from '@/lib/media/service' -import sharp from 'sharp' -import { initializeFonts, createLabelSVG } from '@/lib/fonts' import { requireUserAuth, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError } from '@/lib/api-errors' +import { updateAssetRenderLabel } from '@/lib/assets/services/asset-label' /** * POST /api/asset-hub/update-asset-label * 更新资产中心图片上的黑边标识符(修改名字后调用) */ export const POST = apiHandler(async (request: NextRequest) => { - await initializeFonts() - const authResult = await requireUserAuth() if (isErrorResponse(authResult)) return authResult - const { session } = authResult + void authResult const body = await request.json() const { type, id, newName, appearanceIndex } = body @@ -27,131 +19,17 @@ export const POST = apiHandler(async (request: NextRequest) => { throw new ApiError('INVALID_PARAMS') } - if (type === 'character') { - const character = await prisma.globalCharacter.findUnique({ - where: { id }, - include: { appearances: true }, + void appearanceIndex + + if (type === 'character' || type === 'location') { + await updateAssetRenderLabel({ + scope: 'global', + kind: type, + assetId: id, + newName, }) - - if (!character || character.userId !== session.user.id) { - throw new ApiError('NOT_FOUND') - } - - const updatePromises = character.appearances.map(async (appearance) => { - if (appearanceIndex !== undefined && appearance.appearanceIndex !== appearanceIndex) { - return null - } - - let imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'globalCharacterAppearance.imageUrls') - if (imageUrls.length === 0 && appearance.imageUrl) { - imageUrls = [appearance.imageUrl] - } - if (imageUrls.length === 0) return null - - const newLabelText = `${newName} - ${appearance.changeReason}` - const newImageUrls: string[] = await Promise.all( - imageUrls.map(async (url, i) => { - if (!url) return '' - try { - return await updateImageLabel(url, newLabelText) - } catch (e) { - _ulogError(`Failed to update label for global character image ${i}:`, e) - return url - } - }) - ) - - const firstUrl = newImageUrls.find((u) => !!u) || null - - await prisma.globalCharacterAppearance.update({ - where: { id: appearance.id }, - data: { - imageUrls: encodeImageUrls(newImageUrls), - imageUrl: firstUrl, - }, - }) - - return { appearanceIndex: appearance.appearanceIndex, imageUrls: newImageUrls } - }) - - const results = await Promise.all(updatePromises) - return NextResponse.json({ success: true, results: results.filter((r) => r !== null) }) - } - - if (type === 'location') { - const location = await prisma.globalLocation.findUnique({ - where: { id }, - include: { images: true }, - }) - - if (!location || location.userId !== session.user.id) { - throw new ApiError('NOT_FOUND') - } - - const updatePromises = location.images.map(async (image) => { - if (!image.imageUrl) return null - - try { - const newImageUrl = await updateImageLabel(image.imageUrl, newName) - - await prisma.globalLocationImage.update({ - where: { id: image.id }, - data: { imageUrl: newImageUrl }, - }) - - return { imageIndex: image.imageIndex, imageUrl: newImageUrl } - } catch (e) { - _ulogError(`Failed to update label for global location image ${image.imageIndex}:`, e) - return null - } - }) - - const results = await Promise.all(updatePromises) - return NextResponse.json({ success: true, results: results.filter((r) => r !== null) }) + return NextResponse.json({ success: true }) } throw new ApiError('INVALID_PARAMS') }) - -/** - * 更新图片的黑边标签 - * 生成新 COS key 上传,URL 变化后浏览器缓存失效,前端能立即看到新标签 - */ -async function updateImageLabel(imageUrl: string, newLabelText: string): Promise { - const originalKey = await resolveStorageKeyFromMediaValue(imageUrl) - if (!originalKey) { - throw new Error(`无法归一化媒体 key: ${imageUrl}`) - } - const signedUrl = getSignedUrl(originalKey, 3600) - - const response = await fetch(toFetchableUrl(signedUrl)) - if (!response.ok) { - throw new Error(`Failed to download image: ${response.status}`) - } - const buffer = Buffer.from(await response.arrayBuffer()) - - const meta = await sharp(buffer).metadata() - const w = meta.width || 2160 - const h = meta.height || 2160 - - const fontSize = Math.floor(h * 0.04) - const pad = Math.floor(fontSize * 0.5) - const barH = fontSize + pad * 2 - - const croppedBuffer = await sharp(buffer) - .extract({ left: 0, top: barH, width: w, height: h - barH }) - .toBuffer() - - const svg = await createLabelSVG(w, barH, fontSize, pad, newLabelText) - - const processed = await sharp(croppedBuffer) - .extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } }) - .composite([{ input: svg, top: 0, left: 0 }]) - .jpeg({ quality: 90, mozjpeg: true }) - .toBuffer() - - // 生成新 key,使 URL 发生变化,强制浏览器绕过缓存 - const newKey = generateUniqueKey('labeled-rename', 'jpg') - await uploadObject(processed, newKey) - return newKey -} diff --git a/src/app/api/assets/[assetId]/copy/route.ts b/src/app/api/assets/[assetId]/copy/route.ts new file mode 100644 index 0000000..428904d --- /dev/null +++ b/src/app/api/assets/[assetId]/copy/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight } from '@/lib/api-auth' +import { copyAssetFromGlobal } from '@/lib/assets/services/asset-actions' +import type { AssetKind } from '@/lib/assets/contracts' + +type CopyBody = { + kind?: AssetKind + projectId?: string + globalAssetId?: string +} + +export const POST = apiHandler(async ( + request: NextRequest, + context: { params: Promise<{ assetId: string }> }, +) => { + const { assetId } = await context.params + const body = await request.json() as CopyBody + if (!body.projectId || !body.globalAssetId || (body.kind !== 'character' && body.kind !== 'location' && body.kind !== 'prop' && body.kind !== 'voice')) { + throw new ApiError('INVALID_PARAMS') + } + const authResult = await requireProjectAuthLight(body.projectId) + if (isErrorResponse(authResult)) return authResult + const result = await copyAssetFromGlobal({ + kind: body.kind, + targetId: assetId, + globalAssetId: body.globalAssetId, + access: { + userId: authResult.session.user.id, + projectId: body.projectId, + }, + }) + return NextResponse.json(result) +}) diff --git a/src/app/api/assets/[assetId]/generate/route.ts b/src/app/api/assets/[assetId]/generate/route.ts new file mode 100644 index 0000000..0215f2f --- /dev/null +++ b/src/app/api/assets/[assetId]/generate/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight, requireUserAuth } from '@/lib/api-auth' +import { submitAssetGenerateTask } from '@/lib/assets/services/asset-actions' +import type { AssetKind, AssetScope } from '@/lib/assets/contracts' + +type GenerateBody = { + scope?: AssetScope + kind?: Extract + projectId?: string +} & Record + +export const POST = apiHandler(async ( + request: NextRequest, + context: { params: Promise<{ assetId: string }> }, +) => { + const { assetId } = await context.params + const body = await request.json() as GenerateBody + if ((body.scope !== 'global' && body.scope !== 'project') || (body.kind !== 'character' && body.kind !== 'location' && body.kind !== 'prop')) { + throw new ApiError('INVALID_PARAMS') + } + if (body.scope === 'project') { + if (!body.projectId) throw new ApiError('INVALID_PARAMS') + const authResult = await requireProjectAuthLight(body.projectId) + if (isErrorResponse(authResult)) return authResult + const result = await submitAssetGenerateTask({ + request, + kind: body.kind, + assetId, + body, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId: body.projectId, + }, + }) + return NextResponse.json(result) + } + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + const result = await submitAssetGenerateTask({ + request, + kind: body.kind, + assetId, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, + }) + return NextResponse.json(result) +}) diff --git a/src/app/api/assets/[assetId]/modify-render/route.ts b/src/app/api/assets/[assetId]/modify-render/route.ts new file mode 100644 index 0000000..a33f552 --- /dev/null +++ b/src/app/api/assets/[assetId]/modify-render/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight, requireUserAuth } from '@/lib/api-auth' +import { submitAssetModifyTask } from '@/lib/assets/services/asset-actions' +import type { AssetKind, AssetScope } from '@/lib/assets/contracts' + +type ModifyRenderBody = { + scope?: AssetScope + kind?: Extract + projectId?: string +} & Record + +export const POST = apiHandler(async ( + request: NextRequest, + context: { params: Promise<{ assetId: string }> }, +) => { + const { assetId } = await context.params + const body = await request.json() as ModifyRenderBody + if ((body.scope !== 'global' && body.scope !== 'project') || (body.kind !== 'character' && body.kind !== 'location' && body.kind !== 'prop')) { + throw new ApiError('INVALID_PARAMS') + } + if (body.scope === 'project') { + if (!body.projectId) throw new ApiError('INVALID_PARAMS') + const authResult = await requireProjectAuthLight(body.projectId) + if (isErrorResponse(authResult)) return authResult + const result = await submitAssetModifyTask({ + request, + kind: body.kind, + assetId, + body, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId: body.projectId, + }, + }) + return NextResponse.json(result) + } + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + const result = await submitAssetModifyTask({ + request, + kind: body.kind, + assetId, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, + }) + return NextResponse.json(result) +}) diff --git a/src/app/api/assets/[assetId]/revert-render/route.ts b/src/app/api/assets/[assetId]/revert-render/route.ts new file mode 100644 index 0000000..06abb41 --- /dev/null +++ b/src/app/api/assets/[assetId]/revert-render/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight, requireUserAuth } from '@/lib/api-auth' +import { revertAssetRender } from '@/lib/assets/services/asset-actions' +import type { AssetKind, AssetScope } from '@/lib/assets/contracts' + +type RevertRenderBody = { + scope?: AssetScope + kind?: Extract + projectId?: string +} & Record + +export const POST = apiHandler(async ( + request: NextRequest, + context: { params: Promise<{ assetId: string }> }, +) => { + const { assetId } = await context.params + const body = await request.json() as RevertRenderBody + if ((body.scope !== 'global' && body.scope !== 'project') || (body.kind !== 'character' && body.kind !== 'location' && body.kind !== 'prop')) { + throw new ApiError('INVALID_PARAMS') + } + if (body.scope === 'project') { + if (!body.projectId) throw new ApiError('INVALID_PARAMS') + const authResult = await requireProjectAuthLight(body.projectId) + if (isErrorResponse(authResult)) return authResult + const result = await revertAssetRender({ + kind: body.kind, + assetId, + body, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId: body.projectId, + }, + }) + return NextResponse.json(result) + } + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + const result = await revertAssetRender({ + kind: body.kind, + assetId, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, + }) + return NextResponse.json(result) +}) diff --git a/src/app/api/assets/[assetId]/route.ts b/src/app/api/assets/[assetId]/route.ts new file mode 100644 index 0000000..ef2799c --- /dev/null +++ b/src/app/api/assets/[assetId]/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight, requireUserAuth } from '@/lib/api-auth' +import { removeAsset, updateAsset } from '@/lib/assets/services/asset-actions' +import type { AssetKind, AssetScope } from '@/lib/assets/contracts' + +type UpdateAssetBody = { + scope?: AssetScope + kind?: AssetKind + projectId?: string +} & Record + +function isAssetScope(value: unknown): value is AssetScope { + return value === 'global' || value === 'project' +} + +function isAssetKind(value: unknown): value is AssetKind { + return value === 'character' || value === 'location' || value === 'prop' || value === 'voice' +} + +export const PATCH = apiHandler(async ( + request: NextRequest, + context: { params: Promise<{ assetId: string }> }, +) => { + const { assetId } = await context.params + const body = await request.json() as UpdateAssetBody + if (!isAssetScope(body.scope) || !isAssetKind(body.kind)) { + throw new ApiError('INVALID_PARAMS') + } + + if (body.scope === 'project') { + if (!body.projectId) throw new ApiError('INVALID_PARAMS') + const authResult = await requireProjectAuthLight(body.projectId) + if (isErrorResponse(authResult)) return authResult + const result = await updateAsset({ + kind: body.kind, + assetId, + body, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId: body.projectId, + }, + }) + return NextResponse.json(result) + } + + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + const result = await updateAsset({ + kind: body.kind, + assetId, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, + }) + return NextResponse.json(result) +}) + +type DeleteAssetBody = { + scope?: AssetScope + kind?: AssetKind + projectId?: string +} + +function isDeletableKind(value: AssetKind | undefined): value is Extract { + return value === 'location' || value === 'prop' +} + +export const DELETE = apiHandler(async ( + request: NextRequest, + context: { params: Promise<{ assetId: string }> }, +) => { + const { assetId } = await context.params + const body = await request.json() as DeleteAssetBody + if (!isAssetScope(body.scope) || !isDeletableKind(body.kind)) { + throw new ApiError('INVALID_PARAMS') + } + + if (body.scope === 'project') { + if (!body.projectId) throw new ApiError('INVALID_PARAMS') + const authResult = await requireProjectAuthLight(body.projectId) + if (isErrorResponse(authResult)) return authResult + const result = await removeAsset({ + kind: body.kind, + assetId, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId: body.projectId, + }, + }) + return NextResponse.json(result) + } + + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + const result = await removeAsset({ + kind: body.kind, + assetId, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, + }) + return NextResponse.json(result) +}) diff --git a/src/app/api/assets/[assetId]/select-render/route.ts b/src/app/api/assets/[assetId]/select-render/route.ts new file mode 100644 index 0000000..fc972ca --- /dev/null +++ b/src/app/api/assets/[assetId]/select-render/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight, requireUserAuth } from '@/lib/api-auth' +import { selectAssetRender } from '@/lib/assets/services/asset-actions' +import type { AssetKind, AssetScope } from '@/lib/assets/contracts' + +type SelectRenderBody = { + scope?: AssetScope + kind?: Extract + projectId?: string +} & Record + +export const POST = apiHandler(async ( + request: NextRequest, + context: { params: Promise<{ assetId: string }> }, +) => { + const { assetId } = await context.params + const body = await request.json() as SelectRenderBody + if ((body.scope !== 'global' && body.scope !== 'project') || (body.kind !== 'character' && body.kind !== 'location' && body.kind !== 'prop')) { + throw new ApiError('INVALID_PARAMS') + } + if (body.scope === 'project') { + if (!body.projectId) throw new ApiError('INVALID_PARAMS') + const authResult = await requireProjectAuthLight(body.projectId) + if (isErrorResponse(authResult)) return authResult + const result = await selectAssetRender({ + kind: body.kind, + assetId, + body, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId: body.projectId, + }, + }) + return NextResponse.json(result) + } + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + const result = await selectAssetRender({ + kind: body.kind, + assetId, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, + }) + return NextResponse.json(result) +}) diff --git a/src/app/api/assets/[assetId]/update-label/route.ts b/src/app/api/assets/[assetId]/update-label/route.ts new file mode 100644 index 0000000..fac7fb7 --- /dev/null +++ b/src/app/api/assets/[assetId]/update-label/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuth, requireUserAuth } from '@/lib/api-auth' +import { updateAssetRenderLabel } from '@/lib/assets/services/asset-label' +import type { AssetKind, AssetScope } from '@/lib/assets/contracts' + +type UpdateLabelBody = { + scope?: AssetScope + kind?: AssetKind + projectId?: string + newName?: string +} + +function isUpdatableKind(value: AssetKind | undefined): value is Extract { + return value === 'character' || value === 'location' || value === 'prop' +} + +export const POST = apiHandler(async ( + request: NextRequest, + context: { params: Promise<{ assetId: string }> }, +) => { + const { assetId } = await context.params + const body = await request.json() as UpdateLabelBody + + if (!body.scope || !body.newName || !isUpdatableKind(body.kind)) { + throw new ApiError('INVALID_PARAMS') + } + + if (body.scope === 'project') { + if (!body.projectId) { + throw new ApiError('INVALID_PARAMS', { details: 'projectId is required for project scope' }) + } + const authResult = await requireProjectAuth(body.projectId) + if (isErrorResponse(authResult)) return authResult + } else { + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + } + + await updateAssetRenderLabel({ + scope: body.scope, + kind: body.kind, + assetId, + projectId: body.projectId, + newName: body.newName, + }) + + return NextResponse.json({ success: true }) +}) diff --git a/src/app/api/assets/[assetId]/variants/[variantId]/route.ts b/src/app/api/assets/[assetId]/variants/[variantId]/route.ts new file mode 100644 index 0000000..28a0277 --- /dev/null +++ b/src/app/api/assets/[assetId]/variants/[variantId]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight, requireUserAuth } from '@/lib/api-auth' +import { updateAssetVariant } from '@/lib/assets/services/asset-actions' +import type { AssetKind, AssetScope } from '@/lib/assets/contracts' + +type UpdateVariantBody = { + scope?: AssetScope + kind?: Extract + projectId?: string +} & Record + +export const PATCH = apiHandler(async ( + request: NextRequest, + context: { params: Promise<{ assetId: string; variantId: string }> }, +) => { + const { assetId, variantId } = await context.params + const body = await request.json() as UpdateVariantBody + if ((body.scope !== 'global' && body.scope !== 'project') || (body.kind !== 'character' && body.kind !== 'location' && body.kind !== 'prop')) { + throw new ApiError('INVALID_PARAMS') + } + + if (body.scope === 'project') { + if (!body.projectId) throw new ApiError('INVALID_PARAMS') + const authResult = await requireProjectAuthLight(body.projectId) + if (isErrorResponse(authResult)) return authResult + const result = await updateAssetVariant({ + kind: body.kind, + assetId, + variantId, + body, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId: body.projectId, + }, + }) + return NextResponse.json(result) + } + + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + const result = await updateAssetVariant({ + kind: body.kind, + assetId, + variantId, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, + }) + return NextResponse.json(result) +}) diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts new file mode 100644 index 0000000..9e2647a --- /dev/null +++ b/src/app/api/assets/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight, requireUserAuth } from '@/lib/api-auth' +import { createAsset } from '@/lib/assets/services/asset-actions' +import { readAssets } from '@/lib/assets/services/read-assets' +import type { AssetKind, AssetScope } from '@/lib/assets/contracts' + +function isAssetScope(value: string | null): value is AssetScope { + return value === 'global' || value === 'project' +} + +function isAssetKind(value: string | null): value is AssetKind { + return value === 'character' || value === 'location' || value === 'prop' || value === 'voice' +} + +export const GET = apiHandler(async (request: NextRequest) => { + const searchParams = request.nextUrl.searchParams + const scope = searchParams.get('scope') + const projectId = searchParams.get('projectId') + const folderId = searchParams.get('folderId') + const kind = searchParams.get('kind') + + if (!isAssetScope(scope)) { + throw new ApiError('INVALID_PARAMS', { details: 'scope must be global or project' }) + } + + if (scope === 'project') { + if (!projectId) { + throw new ApiError('INVALID_PARAMS', { details: 'projectId is required for project scope' }) + } + const authResult = await requireProjectAuthLight(projectId) + if (isErrorResponse(authResult)) return authResult + const assets = await readAssets({ + scope, + projectId, + folderId, + kind: isAssetKind(kind) ? kind : null, + }) + return NextResponse.json({ assets }) + } else { + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + const assets = await readAssets({ + scope, + projectId, + folderId, + kind: isAssetKind(kind) ? kind : null, + }, { + userId: authResult.session.user.id, + }) + return NextResponse.json({ assets }) + } +}) + +type CreateAssetBody = { + scope?: AssetScope + kind?: AssetKind + projectId?: string +} & Record + +function isCreatableKind(value: AssetKind | undefined): value is Extract { + return value === 'location' || value === 'prop' +} + +export const POST = apiHandler(async (request: NextRequest) => { + const body = await request.json() as CreateAssetBody + if (!body.scope || !isCreatableKind(body.kind)) { + throw new ApiError('INVALID_PARAMS') + } + + if (body.scope === 'project') { + if (!body.projectId) { + throw new ApiError('INVALID_PARAMS', { details: 'projectId is required for project scope' }) + } + const authResult = await requireProjectAuthLight(body.projectId) + if (isErrorResponse(authResult)) return authResult + const result = await createAsset({ + kind: body.kind, + body, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId: body.projectId, + }, + }) + return NextResponse.json(result) + } + + const authResult = await requireUserAuth() + if (isErrorResponse(authResult)) return authResult + const result = await createAsset({ + kind: body.kind, + body, + access: { + scope: 'global', + userId: authResult.session.user.id, + }, + }) + return NextResponse.json(result) +}) diff --git a/src/app/api/novel-promotion/[projectId]/assets/route.ts b/src/app/api/novel-promotion/[projectId]/assets/route.ts index d5ec0d3..6d2a00c 100644 --- a/src/app/api/novel-promotion/[projectId]/assets/route.ts +++ b/src/app/api/novel-promotion/[projectId]/assets/route.ts @@ -4,6 +4,10 @@ import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth' import { apiHandler } from '@/lib/api-errors' import { attachMediaFieldsToProject } from '@/lib/media/attach' +function readAssetKind(value: Record): string { + return typeof value.assetKind === 'string' ? value.assetKind : 'location' +} + /** * GET - 获取项目资产(角色 + 场景) * 🔥 V6.5: 为 useProjectAssets hook 提供统一的资产数据接口 @@ -42,14 +46,17 @@ export const GET = apiHandler(async ( }) if (!novelData) { - return NextResponse.json({ characters: [], locations: [] }) + return NextResponse.json({ characters: [], locations: [], props: [] }) } // 为资产添加稳定媒体 URL(并保留兼容字段) const withSignedUrls = await attachMediaFieldsToProject(novelData) + const locations = (withSignedUrls.locations || []).filter((item) => readAssetKind(item) !== 'prop') + const props = (withSignedUrls.locations || []).filter((item) => readAssetKind(item) === 'prop') return NextResponse.json({ characters: withSignedUrls.characters || [], - locations: withSignedUrls.locations || [] + locations, + props, }) }) diff --git a/src/app/api/novel-promotion/[projectId]/clips/[clipId]/route.ts b/src/app/api/novel-promotion/[projectId]/clips/[clipId]/route.ts index d095a3f..ca6fe63 100644 --- a/src/app/api/novel-promotion/[projectId]/clips/[clipId]/route.ts +++ b/src/app/api/novel-promotion/[projectId]/clips/[clipId]/route.ts @@ -6,7 +6,7 @@ import { apiHandler } from '@/lib/api-errors' /** * PATCH /api/novel-promotion/[projectId]/clips/[clipId] * 更新单个 Clip 的信息 - * 支持更新:characters, location, content, screenplay + * 支持更新:characters, location, props, content, screenplay */ export const PATCH = apiHandler(async ( request: NextRequest, @@ -19,7 +19,10 @@ export const PATCH = apiHandler(async ( if (isErrorResponse(authResult)) return authResult const body = await request.json() - const { characters, location, content, screenplay } = body + const { characters, location, props, content, screenplay } = body + const clipModel = prisma.novelPromotionClip as unknown as { + update: (args: { where: { id: string }; data: Record }) => Promise + } // 验证 Clip 是否存在且属于该项目(间接验证) // 这里简化处理,直接通过 ID 更新,Prisma 会处理是否存在 @@ -28,15 +31,17 @@ export const PATCH = apiHandler(async ( const updateData: { characters?: string | null location?: string | null + props?: string | null content?: string screenplay?: string | null } = {} if (characters !== undefined) updateData.characters = characters // JSON string if (location !== undefined) updateData.location = location + if (props !== undefined) updateData.props = props if (content !== undefined) updateData.content = content if (screenplay !== undefined) updateData.screenplay = screenplay // JSON string - const clip = await prisma.novelPromotionClip.update({ + const clip = await clipModel.update({ where: { id: clipId }, data: updateData }) diff --git a/src/app/api/novel-promotion/[projectId]/copy-from-global/route.ts b/src/app/api/novel-promotion/[projectId]/copy-from-global/route.ts index f83d31e..1e678ed 100644 --- a/src/app/api/novel-promotion/[projectId]/copy-from-global/route.ts +++ b/src/app/api/novel-promotion/[projectId]/copy-from-global/route.ts @@ -1,321 +1,42 @@ -import { logInfo as _ulogInfo } from '@/lib/logging/core' import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth' -import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract' -import { updateCharacterAppearanceLabels, updateLocationImageLabels } from '@/lib/image-label' import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight } from '@/lib/api-auth' +import { copyAssetFromGlobal } from '@/lib/assets/services/asset-actions' -interface GlobalCharacterAppearanceSource { - appearanceIndex: number - changeReason: string - description: string | null - descriptions: string | null - imageUrl: string | null - imageUrls: string | null - selectedIndex: number | null +type LegacyCopyBody = { + type?: 'character' | 'location' | 'voice' + targetId?: string + globalAssetId?: string } -interface GlobalCharacterSource { - name: string - voiceId: string | null - voiceType: string | null - customVoiceUrl: string | null - appearances: GlobalCharacterAppearanceSource[] -} - -interface GlobalLocationImageSource { - imageIndex: number - description: string | null - imageUrl: string | null - isSelected: boolean -} - -interface GlobalLocationSource { - name: string - summary: string | null - images: GlobalLocationImageSource[] -} - -interface GlobalVoiceSource { - name: string - voiceId: string | null - voiceType: string | null - customVoiceUrl: string | null -} - -interface CopyFromGlobalDb { - globalCharacter: { - findFirst(args: Record): Promise - } - globalLocation: { - findFirst(args: Record): Promise - } - globalVoice: { - findFirst(args: Record): Promise - } -} - -/** - * POST /api/novel-promotion/[projectId]/copy-from-global - * 从资产中心复制角色/场景的形象数据到项目资产 - * - * 复制而非引用:即使全局资产被删除,项目资产也不受影响 - */ export const POST = apiHandler(async ( - request: NextRequest, - context: { params: Promise<{ projectId: string }> } + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, ) => { - const { projectId } = await context.params - const db = prisma as unknown as CopyFromGlobalDb + const { projectId } = await context.params + const authResult = await requireProjectAuthLight(projectId) + if (isErrorResponse(authResult)) return authResult - // 🔐 统一权限验证 - const authResult = await requireProjectAuthLight(projectId) - if (isErrorResponse(authResult)) return authResult - const session = authResult.session + const body = await request.json() as LegacyCopyBody + if ( + (body.type !== 'character' && body.type !== 'location' && body.type !== 'voice') + || typeof body.targetId !== 'string' + || body.targetId.trim().length === 0 + || typeof body.globalAssetId !== 'string' + || body.globalAssetId.trim().length === 0 + ) { + throw new ApiError('INVALID_PARAMS') + } - const body = await request.json() - const { type, targetId, globalAssetId } = body + const result = await copyAssetFromGlobal({ + kind: body.type, + targetId: body.targetId, + globalAssetId: body.globalAssetId, + access: { + userId: authResult.session.user.id, + projectId, + }, + }) - if (!type || !targetId || !globalAssetId) { - throw new ApiError('INVALID_PARAMS') - } - - if (type === 'character') { - return await copyCharacterFromGlobal(db, session.user.id, targetId, globalAssetId) - } else if (type === 'location') { - return await copyLocationFromGlobal(db, session.user.id, targetId, globalAssetId) - } else if (type === 'voice') { - return await copyVoiceFromGlobal(db, session.user.id, targetId, globalAssetId) - } else { - throw new ApiError('INVALID_PARAMS') - } + return NextResponse.json(result) }) - -/** - * 复制全局角色的形象到项目角色 - */ -async function copyCharacterFromGlobal(db: CopyFromGlobalDb, userId: string, targetId: string, globalCharacterId: string) { - _ulogInfo(`[Copy from Global] 复制角色: global=${globalCharacterId} -> project=${targetId}`) - - // 1. 获取全局角色及其形象 - const globalCharacter = await db.globalCharacter.findFirst({ - where: { id: globalCharacterId, userId }, - include: { appearances: true } - }) - - if (!globalCharacter) { - throw new ApiError('NOT_FOUND') - } - - // 2. 获取项目角色 - const projectCharacter = await prisma.novelPromotionCharacter.findUnique({ - where: { id: targetId }, - include: { appearances: true } - }) - - if (!projectCharacter) { - throw new ApiError('NOT_FOUND') - } - - // 3. 删除项目角色的旧形象 - if (projectCharacter.appearances.length > 0) { - await prisma.characterAppearance.deleteMany({ - where: { characterId: targetId } - }) - _ulogInfo(`[Copy from Global] 删除了 ${projectCharacter.appearances.length} 个旧形象`) - } - - // 4. 🔥 更新黑边标签:使用项目角色名替换资产中心的角色名 - _ulogInfo(`[Copy from Global] 更新黑边标签: ${globalCharacter.name} -> ${projectCharacter.name}`) - const updatedLabels = await updateCharacterAppearanceLabels( - globalCharacter.appearances.map((app) => ({ - imageUrl: app.imageUrl, - imageUrls: encodeImageUrls(decodeImageUrlsFromDb(app.imageUrls, 'globalCharacterAppearance.imageUrls')), - changeReason: app.changeReason - })), - projectCharacter.name - ) - - // 5. 复制全局形象到项目(使用更新后的图片URL) - const copiedAppearances = [] - for (let i = 0; i < globalCharacter.appearances.length; i++) { - const app = globalCharacter.appearances[i] - const labelUpdate = updatedLabels[i] - const originalImageUrls = decodeImageUrlsFromDb(app.imageUrls, 'globalCharacterAppearance.imageUrls') - - const newAppearance = await prisma.characterAppearance.create({ - data: { - characterId: targetId, - appearanceIndex: app.appearanceIndex, - changeReason: app.changeReason, - description: app.description, - descriptions: app.descriptions, - // 🔥 使用更新了标签的新图片URL - imageUrl: labelUpdate?.imageUrl || app.imageUrl, - imageUrls: labelUpdate?.imageUrls || encodeImageUrls(originalImageUrls), - previousImageUrls: encodeImageUrls([]), - selectedIndex: app.selectedIndex - } - }) - copiedAppearances.push(newAppearance) - } - _ulogInfo(`[Copy from Global] 复制了 ${copiedAppearances.length} 个形象(已更新标签)`) - - // 6. 更新项目角色:记录来源ID,并标记档案已确认 - const updatedCharacter = await prisma.novelPromotionCharacter.update({ - where: { id: targetId }, - data: { - sourceGlobalCharacterId: globalCharacterId, - // 使用已有形象相当于确认了角色档案 - profileConfirmed: true, - // 可选:复制语音设置 - voiceId: globalCharacter.voiceId, - voiceType: globalCharacter.voiceType, - customVoiceUrl: globalCharacter.customVoiceUrl - }, - include: { appearances: true } - }) - - _ulogInfo(`[Copy from Global] 角色复制完成: ${projectCharacter.name}`) - - return NextResponse.json({ - success: true, - character: updatedCharacter, - copiedAppearancesCount: copiedAppearances.length - }) -} - -/** - * 复制全局场景的图片到项目场景 - */ -async function copyLocationFromGlobal(db: CopyFromGlobalDb, userId: string, targetId: string, globalLocationId: string) { - _ulogInfo(`[Copy from Global] 复制场景: global=${globalLocationId} -> project=${targetId}`) - - // 1. 获取全局场景及其图片 - const globalLocation = await db.globalLocation.findFirst({ - where: { id: globalLocationId, userId }, - include: { images: true } - }) - - if (!globalLocation) { - throw new ApiError('NOT_FOUND') - } - - // 2. 获取项目场景 - const projectLocation = await prisma.novelPromotionLocation.findUnique({ - where: { id: targetId }, - include: { images: true } - }) - - if (!projectLocation) { - throw new ApiError('NOT_FOUND') - } - - // 3. 删除项目场景的旧图片 - if (projectLocation.images.length > 0) { - await prisma.locationImage.deleteMany({ - where: { locationId: targetId } - }) - _ulogInfo(`[Copy from Global] 删除了 ${projectLocation.images.length} 个旧图片`) - } - - // 4. 🔥 更新黑边标签:使用项目场景名替换资产中心的场景名 - _ulogInfo(`[Copy from Global] 更新黑边标签: ${globalLocation.name} -> ${projectLocation.name}`) - const updatedLabels = await updateLocationImageLabels( - globalLocation.images.map((img) => ({ - imageUrl: img.imageUrl - })), - projectLocation.name - ) - - // 5. 复制全局图片到项目(使用更新后的图片URL) - const copiedImages: Array<{ id: string; imageIndex: number; imageUrl: string | null }> = [] - for (let i = 0; i < globalLocation.images.length; i++) { - const img = globalLocation.images[i] - const labelUpdate = updatedLabels[i] - - const newImage = await prisma.locationImage.create({ - data: { - locationId: targetId, - imageIndex: img.imageIndex, - description: img.description, - // 🔥 使用更新了标签的新图片URL - imageUrl: labelUpdate?.imageUrl || img.imageUrl, - isSelected: img.isSelected - } - }) - copiedImages.push(newImage) - } - _ulogInfo(`[Copy from Global] 复制了 ${copiedImages.length} 个图片(已更新标签)`) - - const selectedFromGlobal = globalLocation.images.find((img) => img.isSelected) - const selectedImageId = selectedFromGlobal - ? copiedImages.find(i => i.imageIndex === selectedFromGlobal.imageIndex)?.id - : copiedImages.find(i => i.imageUrl)?.id || null - await prisma.novelPromotionLocation.update({ - where: { id: targetId }, - data: { selectedImageId } - }) - - // 6. 更新项目场景:记录来源ID 和 summary - const updatedLocation = await prisma.novelPromotionLocation.update({ - where: { id: targetId }, - data: { - sourceGlobalLocationId: globalLocationId, - summary: globalLocation.summary - }, - include: { images: true } - }) - - _ulogInfo(`[Copy from Global] 场景复制完成: ${projectLocation.name}`) - - return NextResponse.json({ - success: true, - location: updatedLocation, - copiedImagesCount: copiedImages.length - }) -} - -/** - * 复制全局音色到项目角色 - */ -async function copyVoiceFromGlobal(db: CopyFromGlobalDb, userId: string, targetCharacterId: string, globalVoiceId: string) { - _ulogInfo(`[Copy from Global] 复制音色: global=${globalVoiceId} -> project character=${targetCharacterId}`) - - // 1. 获取全局音色 - const globalVoice = await db.globalVoice.findFirst({ - where: { id: globalVoiceId, userId } - }) - - if (!globalVoice) { - throw new ApiError('NOT_FOUND') - } - - // 2. 获取项目角色 - const projectCharacter = await prisma.novelPromotionCharacter.findUnique({ - where: { id: targetCharacterId } - }) - - if (!projectCharacter) { - throw new ApiError('NOT_FOUND') - } - - // 3. 更新项目角色的音色设置 - const updatedCharacter = await prisma.novelPromotionCharacter.update({ - where: { id: targetCharacterId }, - data: { - voiceId: globalVoice.voiceId, - voiceType: globalVoice.voiceType, // 'qwen-designed' | 'custom' - customVoiceUrl: globalVoice.customVoiceUrl - } - }) - - _ulogInfo(`[Copy from Global] 音色复制完成: ${projectCharacter.name} <- ${globalVoice.name}`) - - return NextResponse.json({ - success: true, - character: updatedCharacter, - voiceName: globalVoice.name - }) -} diff --git a/src/app/api/novel-promotion/[projectId]/generate-character-image/route.ts b/src/app/api/novel-promotion/[projectId]/generate-character-image/route.ts index cf137c2..95a4489 100644 --- a/src/app/api/novel-promotion/[projectId]/generate-character-image/route.ts +++ b/src/app/api/novel-promotion/[projectId]/generate-character-image/route.ts @@ -1,4 +1,3 @@ -import { logError as _ulogError } from '@/lib/logging/core' import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth' @@ -6,102 +5,85 @@ import { apiHandler, ApiError } from '@/lib/api-errors' import { resolveTaskLocale } from '@/lib/task/resolve-locale' import { isArtStyleValue, type ArtStyleValue } from '@/lib/constants' import { normalizeImageGenerationCount } from '@/lib/image-generation/count' +import { submitAssetGenerateTask } from '@/lib/assets/services/asset-actions' function toObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - return value as Record + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record } function normalizeString(value: unknown): string { - return typeof value === 'string' ? value.trim() : '' + return typeof value === 'string' ? value.trim() : '' } -/** - * POST /api/novel-promotion/[projectId]/generate-character-image - * 专门用于后台触发角色图片生成的简化 API - * 内部调用 generate-image API - */ export const POST = apiHandler(async ( - request: NextRequest, - context: { params: Promise<{ projectId: string }> } + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, ) => { - const { projectId } = await context.params + const { projectId } = await context.params + const authResult = await requireProjectAuthLight(projectId) + if (isErrorResponse(authResult)) return authResult - // 🔐 统一权限验证 - const authResult = await requireProjectAuthLight(projectId) - if (isErrorResponse(authResult)) return authResult + const rawBody = await request.json().catch(() => ({})) + const body = toObject(rawBody) + const taskLocale = resolveTaskLocale(request, body) + const bodyMeta = toObject(body.meta) + const characterId = normalizeString(body.characterId) + const appearanceId = normalizeString(body.appearanceId) + const count = normalizeImageGenerationCount('character', body.count) - const rawBody = await request.json().catch(() => ({})) - const body = toObject(rawBody) - const taskLocale = resolveTaskLocale(request, body) - const bodyMeta = toObject(body.meta) - const acceptLanguage = request.headers.get('accept-language') || '' - const characterId = normalizeString(body.characterId) - const appearanceId = normalizeString(body.appearanceId) - const count = normalizeImageGenerationCount('character', body.count) - let artStyle: ArtStyleValue | undefined - if (Object.prototype.hasOwnProperty.call(body, 'artStyle')) { - const parsedArtStyle = normalizeString(body.artStyle) - if (!isArtStyleValue(parsedArtStyle)) { - throw new ApiError('INVALID_PARAMS', { - code: 'INVALID_ART_STYLE', - message: 'artStyle must be a supported value', - }) - } - artStyle = parsedArtStyle + let artStyle: ArtStyleValue | undefined + if (Object.prototype.hasOwnProperty.call(body, 'artStyle')) { + const parsedArtStyle = normalizeString(body.artStyle) + if (!isArtStyleValue(parsedArtStyle)) { + throw new ApiError('INVALID_PARAMS', { + code: 'INVALID_ART_STYLE', + message: 'artStyle must be a supported value', + }) } + artStyle = parsedArtStyle + } - if (!characterId) { - throw new ApiError('INVALID_PARAMS') - } + if (!characterId) { + throw new ApiError('INVALID_PARAMS') + } - // 如果没有传 appearanceId,获取第一个 appearance 的 id - let targetAppearanceId = appearanceId - if (!targetAppearanceId) { - const character = await prisma.novelPromotionCharacter.findUnique({ - where: { id: characterId }, - include: { appearances: { orderBy: { appearanceIndex: 'asc' } } } - }) - if (!character) { - throw new ApiError('NOT_FOUND') - } - const firstAppearance = character.appearances?.[0] - if (!firstAppearance) { - throw new ApiError('NOT_FOUND') - } - targetAppearanceId = firstAppearance.id - } - - // 调用 generate-image API - const { getBaseUrl } = await import('@/lib/env') - const baseUrl = getBaseUrl() - const generateRes = await fetch(`${baseUrl}/api/novel-promotion/${projectId}/generate-image`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Cookie': request.headers.get('cookie') || '', - ...(acceptLanguage ? { 'Accept-Language': acceptLanguage } : {}) - }, - body: JSON.stringify({ - type: 'character', - id: characterId, - appearanceId: targetAppearanceId, // 使用真正的 UUID - count, - artStyle, - locale: taskLocale || undefined, - meta: { - ...bodyMeta, - locale: taskLocale || bodyMeta.locale || undefined, - }, - }) + let targetAppearanceId = appearanceId + if (!targetAppearanceId) { + const character = await prisma.novelPromotionCharacter.findUnique({ + where: { id: characterId }, + include: { appearances: { orderBy: { appearanceIndex: 'asc' } } }, }) - - const result = await generateRes.json() - - if (!generateRes.ok) { - _ulogError('[Generate Character Image] 失败:', result.error) - throw new ApiError('GENERATION_FAILED') + if (!character) { + throw new ApiError('NOT_FOUND') } + const firstAppearance = character.appearances[0] + if (!firstAppearance) { + throw new ApiError('NOT_FOUND') + } + targetAppearanceId = firstAppearance.id + } - return NextResponse.json(result) + const result = await submitAssetGenerateTask({ + request, + kind: 'character', + assetId: characterId, + body: { + appearanceId: targetAppearanceId, + count, + artStyle, + locale: taskLocale || undefined, + meta: { + ...bodyMeta, + locale: taskLocale || bodyMeta.locale || undefined, + }, + }, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId, + }, + }) + + return NextResponse.json(result) }) diff --git a/src/app/api/novel-promotion/[projectId]/generate-image/route.ts b/src/app/api/novel-promotion/[projectId]/generate-image/route.ts index 7c987b2..6966c6f 100644 --- a/src/app/api/novel-promotion/[projectId]/generate-image/route.ts +++ b/src/app/api/novel-promotion/[projectId]/generate-image/route.ts @@ -1,45 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth' -import { apiHandler, ApiError, getRequestId } from '@/lib/api-errors' -import { submitTask } from '@/lib/task/submitter' -import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale' -import { TASK_TYPE } from '@/lib/task/types' -import { buildDefaultTaskBillingInfo } from '@/lib/billing' -import { withTaskUiPayload } from '@/lib/task/ui-payload' -import { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service' -import { isArtStyleValue, type ArtStyleValue } from '@/lib/constants' -import { normalizeImageGenerationCount } from '@/lib/image-generation/count' -import { ensureProjectLocationImageSlots } from '@/lib/image-generation/location-slots' -import { prisma } from '@/lib/prisma' -import { - hasCharacterAppearanceOutput, - hasLocationImageOutput -} from '@/lib/task/has-output' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight } from '@/lib/api-auth' +import { submitAssetGenerateTask } from '@/lib/assets/services/asset-actions' -function toObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - return value as Record -} - -function toNumber(value: unknown) { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : null -} - -function normalizeString(value: unknown): string { - return typeof value === 'string' ? value.trim() : '' -} - -function resolveArtStyle(body: Record): ArtStyleValue | undefined { - if (!Object.prototype.hasOwnProperty.call(body, 'artStyle')) return undefined - const artStyle = normalizeString(body.artStyle) - if (!isArtStyleValue(artStyle)) { - throw new ApiError('INVALID_PARAMS', { - code: 'INVALID_ART_STYLE', - message: 'artStyle must be a supported value', - }) - } - return artStyle +type LegacyProjectGenerateBody = Record & { + type?: 'character' | 'location' + id?: string } export const POST = apiHandler(async ( @@ -47,92 +13,24 @@ export const POST = apiHandler(async ( context: { params: Promise<{ projectId: string }> }, ) => { const { projectId } = await context.params - const authResult = await requireProjectAuthLight(projectId) if (isErrorResponse(authResult)) return authResult - const { session } = authResult - const rawBody = await request.json().catch(() => ({})) - const body = toObject(rawBody) - const locale = resolveRequiredTaskLocale(request, body) - const type = normalizeString(body.type) - const id = normalizeString(body.id) - const appearanceId = normalizeString(body.appearanceId) - const artStyle = resolveArtStyle(body) - const count = type === 'character' - ? normalizeImageGenerationCount('character', body.count) - : normalizeImageGenerationCount('location', body.count) - - if (!type || !id) { + const body = await request.json() as LegacyProjectGenerateBody + if ((body.type !== 'character' && body.type !== 'location') || typeof body.id !== 'string' || body.id.trim().length === 0) { throw new ApiError('INVALID_PARAMS') } - if (type !== 'character' && type !== 'location') { - throw new ApiError('INVALID_PARAMS') - } - - const taskType = type === 'character' ? TASK_TYPE.IMAGE_CHARACTER : TASK_TYPE.IMAGE_LOCATION - const targetType = type === 'character' ? 'CharacterAppearance' : 'LocationImage' - const targetId = type === 'character' ? (appearanceId || id) : id - - if (!targetId) { - throw new ApiError('INVALID_PARAMS') - } - const imageIndex = toNumber(body?.imageIndex) - if (type === 'location' && imageIndex === null) { - const location = await prisma.novelPromotionLocation.findUnique({ - where: { id }, - select: { name: true, summary: true }, - }) - if (!location) { - throw new ApiError('NOT_FOUND') - } - await ensureProjectLocationImageSlots({ - locationId: id, - count, - fallbackDescription: location.summary || location.name, - }) - } - const hasOutputAtStart = type === 'character' - ? await hasCharacterAppearanceOutput({ - appearanceId: targetId, - characterId: id, - appearanceIndex: toNumber(body?.appearanceIndex) - }) - : await hasLocationImageOutput({ - locationId: id, - imageIndex - }) - - const projectModelConfig = await getProjectModelConfig(projectId, session.user.id) - const imageModel = type === 'character' - ? projectModelConfig.characterModel - : projectModelConfig.locationModel - const payloadBase = artStyle ? { ...body, artStyle, count } : { ...body, count } - - let billingPayload: Record - try { - billingPayload = await buildImageBillingPayload({ + const result = await submitAssetGenerateTask({ + request, + kind: body.type, + assetId: body.id, + body, + access: { + scope: 'project', + userId: authResult.session.user.id, projectId, - userId: session.user.id, - imageModel, - basePayload: payloadBase, - }) - } catch (err) { - const message = err instanceof Error ? err.message : 'Image model capability not configured' - throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message }) - } - const result = await submitTask({ - userId: session.user.id, - locale, - requestId: getRequestId(request), - projectId, - type: taskType, - targetType, - targetId, - payload: withTaskUiPayload(billingPayload, { hasOutputAtStart }), - dedupeKey: `${taskType}:${targetId}:${imageIndex === null ? count : `single:${imageIndex}`}`, - billingInfo: buildDefaultTaskBillingInfo(taskType, billingPayload) + }, }) return NextResponse.json(result) diff --git a/src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts b/src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts index 36f5dde..f1b059d 100644 --- a/src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts +++ b/src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts @@ -1,26 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth' -import { apiHandler, ApiError, getRequestId } from '@/lib/api-errors' -import { submitTask } from '@/lib/task/submitter' -import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale' -import { TASK_TYPE } from '@/lib/task/types' -import { buildDefaultTaskBillingInfo } from '@/lib/billing' -import { withTaskUiPayload } from '@/lib/task/ui-payload' -import { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service' -import { sanitizeImageInputsForTaskPayload } from '@/lib/media/outbound-image' -import { - hasCharacterAppearanceOutput, - hasLocationImageOutput -} from '@/lib/task/has-output' +import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight } from '@/lib/api-auth' +import { submitAssetModifyTask } from '@/lib/assets/services/asset-actions' -function toNumber(value: unknown) { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : null -} - -function toObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - return value as Record +type LegacyProjectModifyBody = Record & { + type?: 'character' | 'location' + characterId?: string + locationId?: string } export const POST = apiHandler(async ( @@ -28,96 +14,29 @@ export const POST = apiHandler(async ( context: { params: Promise<{ projectId: string }> }, ) => { const { projectId } = await context.params - const authResult = await requireProjectAuthLight(projectId) if (isErrorResponse(authResult)) return authResult - const { session } = authResult - const body = await request.json() - const locale = resolveRequiredTaskLocale(request, body) - const type = body?.type - const modifyPrompt = body?.modifyPrompt - - if (!type || !modifyPrompt) { + const body = await request.json() as LegacyProjectModifyBody + const assetId = body.type === 'character' + ? body.characterId + : body.type === 'location' + ? body.locationId + : null + if ((body.type !== 'character' && body.type !== 'location') || typeof assetId !== 'string' || assetId.trim().length === 0) { throw new ApiError('INVALID_PARAMS') } - if (type !== 'character' && type !== 'location') { - throw new ApiError('INVALID_PARAMS') - } - - const targetType = type === 'character' ? 'CharacterAppearance' : 'LocationImage' - const targetId = type === 'character' - ? (body?.appearanceId || body?.characterId) - : (body?.locationImageId || body?.locationId) - - if (!targetId) { - throw new ApiError('INVALID_PARAMS') - } - - const hasOutputAtStart = type === 'character' - ? await hasCharacterAppearanceOutput({ - appearanceId: body?.appearanceId || null, - characterId: body?.characterId || null, - appearanceIndex: toNumber(body?.appearanceIndex) - }) - : await hasLocationImageOutput({ - imageId: body?.locationImageId || null, - locationId: body?.locationId || null, - imageIndex: toNumber(body?.imageIndex) - }) - - const extraImageAudit = sanitizeImageInputsForTaskPayload( - Array.isArray(body?.extraImageUrls) ? body.extraImageUrls : [], - ) - const rejectedRelativePathCount = extraImageAudit.issues.filter( - (issue) => issue.reason === 'relative_path_rejected', - ).length - if (rejectedRelativePathCount > 0) { - throw new ApiError('INVALID_PARAMS') - } - - const baseMeta = toObject(body?.meta) - const payload = { - ...body, - extraImageUrls: extraImageAudit.normalized, - meta: { - ...baseMeta, - outboundImageInputAudit: { - extraImageUrls: extraImageAudit.issues - } - } - } - - const projectModelConfig = await getProjectModelConfig(projectId, session.user.id) - const imageModel = projectModelConfig.editModel - - let billingPayload: Record - try { - billingPayload = await buildImageBillingPayload({ + const result = await submitAssetModifyTask({ + request, + kind: body.type, + assetId, + body, + access: { + scope: 'project', + userId: authResult.session.user.id, projectId, - userId: session.user.id, - imageModel, - basePayload: payload, - }) - } catch (err) { - const message = err instanceof Error ? err.message : 'Image model capability not configured' - throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message }) - } - const result = await submitTask({ - userId: session.user.id, - locale, - requestId: getRequestId(request), - projectId, - type: TASK_TYPE.MODIFY_ASSET_IMAGE, - targetType, - targetId, - payload: withTaskUiPayload(billingPayload, { - intent: 'modify', - hasOutputAtStart - }), - dedupeKey: `modify_asset_image:${targetType}:${targetId}:${body?.imageIndex ?? 'na'}`, - billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.MODIFY_ASSET_IMAGE, billingPayload) + }, }) return NextResponse.json(result) diff --git a/src/app/api/novel-promotion/[projectId]/panel/route.ts b/src/app/api/novel-promotion/[projectId]/panel/route.ts index 5e7a189..9e72e6c 100644 --- a/src/app/api/novel-promotion/[projectId]/panel/route.ts +++ b/src/app/api/novel-promotion/[projectId]/panel/route.ts @@ -38,6 +38,9 @@ export const POST = apiHandler(async ( if (isErrorResponse(authResult)) return authResult const body = await request.json() + const panelModel = prisma.novelPromotionPanel as unknown as { + create: (args: { data: Record }) => Promise + } const { storyboardId, shotType, @@ -45,6 +48,7 @@ export const POST = apiHandler(async ( description, location, characters, + props, srtStart, srtEnd, duration, @@ -77,7 +81,7 @@ export const POST = apiHandler(async ( const newPanelNumber = newPanelIndex + 1 // 创建新的 Panel 记录 - const newPanel = await prisma.novelPromotionPanel.create({ + const newPanel = await panelModel.create({ data: { storyboardId, panelIndex: newPanelIndex, @@ -87,6 +91,7 @@ export const POST = apiHandler(async ( description: description ?? null, location: location ?? null, characters: characters ?? null, + props: props ?? null, srtStart: srtStart ?? null, srtEnd: srtEnd ?? null, duration: duration ?? null, @@ -221,6 +226,9 @@ export const PATCH = apiHandler(async ( if (isErrorResponse(authResult)) return authResult const body = await request.json() + const panelModel = prisma.novelPromotionPanel as unknown as { + create: (args: { data: Record }) => Promise + } const { panelId, storyboardId, panelIndex, videoPrompt, firstLastFramePrompt } = body // 🔥 方式1:通过 panelId 直接更新(优先) @@ -287,7 +295,7 @@ export const PATCH = apiHandler(async ( // 如果 Panel 不存在,创建它(Panel 表是唯一数据源) if (updatedPanel.count === 0) { // 创建新的 Panel 记录 - await prisma.novelPromotionPanel.create({ + await panelModel.create({ data: { storyboardId, panelIndex, @@ -317,6 +325,9 @@ export const PUT = apiHandler(async ( if (isErrorResponse(authResult)) return authResult const body = await request.json() + const panelModel = prisma.novelPromotionPanel as unknown as { + create: (args: { data: Record }) => Promise + } const { storyboardId, panelIndex, @@ -326,6 +337,7 @@ export const PUT = apiHandler(async ( description, location, characters, + props, srtStart, srtEnd, duration, @@ -356,6 +368,7 @@ export const PUT = apiHandler(async ( description?: string | null location?: string | null characters?: string | null + props?: string | null srtStart?: number | null srtEnd?: number | null duration?: number | null @@ -370,6 +383,7 @@ export const PUT = apiHandler(async ( if (description !== undefined) updateData.description = description if (location !== undefined) updateData.location = location if (characters !== undefined) updateData.characters = characters + if (props !== undefined) updateData.props = props if (srtStart !== undefined) updateData.srtStart = parseNullableNumberField(srtStart) if (srtEnd !== undefined) updateData.srtEnd = parseNullableNumberField(srtEnd) if (duration !== undefined) updateData.duration = parseNullableNumberField(duration) @@ -401,7 +415,7 @@ export const PUT = apiHandler(async ( }) } else { // 创建新的 Panel 记录 - await prisma.novelPromotionPanel.create({ + await panelModel.create({ data: { storyboardId, panelIndex, @@ -411,6 +425,7 @@ export const PUT = apiHandler(async ( description: description ?? null, location: location ?? null, characters: characters ?? null, + props: props ?? null, srtStart: srtStart ?? null, srtEnd: srtEnd ?? null, duration: duration ?? null, diff --git a/src/app/api/novel-promotion/[projectId]/select-character-image/route.ts b/src/app/api/novel-promotion/[projectId]/select-character-image/route.ts index b6c49cb..15401e4 100644 --- a/src/app/api/novel-promotion/[projectId]/select-character-image/route.ts +++ b/src/app/api/novel-promotion/[projectId]/select-character-image/route.ts @@ -1,73 +1,40 @@ -import { logInfo as _ulogInfo } from '@/lib/logging/core' import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { getSignedUrl } from '@/lib/storage' -import { decodeImageUrlsFromDb } from '@/lib/contracts/image-urls-contract' -import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight } from '@/lib/api-auth' +import { selectAssetRender } from '@/lib/assets/services/asset-actions' + +type LegacyProjectCharacterSelectBody = { + characterId?: string + appearanceId?: string + selectedIndex?: number | null +} -/** - * POST - 选择角色形象的图片 - * 直接更新独立的 CharacterAppearance 表 - */ export const POST = apiHandler(async ( request: NextRequest, - context: { params: Promise<{ projectId: string }> } + context: { params: Promise<{ projectId: string }> }, ) => { const { projectId } = await context.params - - // 🔐 统一权限验证 const authResult = await requireProjectAuthLight(projectId) if (isErrorResponse(authResult)) return authResult - const { characterId, appearanceId, selectedIndex } = await request.json() - - if (!characterId || !appearanceId) { + const body = await request.json() as LegacyProjectCharacterSelectBody + if (typeof body.characterId !== 'string' || body.characterId.trim().length === 0) { throw new ApiError('INVALID_PARAMS') } - // 使用 UUID 直接查询 - const appearance = await prisma.characterAppearance.findUnique({ - where: { id: appearanceId }, - include: { character: true } + const result = await selectAssetRender({ + kind: 'character', + assetId: body.characterId, + body: { + appearanceId: body.appearanceId, + imageIndex: body.selectedIndex, + }, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId, + }, }) - if (!appearance) { - throw new ApiError('NOT_FOUND') - } - - // 解析图片URLs - const imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'characterAppearance.imageUrls') - - // 验证索引 - if (selectedIndex !== null) { - if (selectedIndex < 0 || selectedIndex >= imageUrls.length || !imageUrls[selectedIndex]) { - throw new ApiError('INVALID_PARAMS') - } - } - - const selectedImageKey = selectedIndex !== null ? imageUrls[selectedIndex] : null - - // 直接更新独立记录(无并发风险) - await prisma.characterAppearance.update({ - where: { id: appearance.id }, - data: { - selectedIndex: selectedIndex, - imageUrl: selectedImageKey - } - }) - - if (selectedIndex !== null) { - _ulogInfo(`✓ 角色 ${appearance.character.name} 形象 ${appearanceId}: 选择了索引 ${selectedIndex}`) - } else { - _ulogInfo(`✓ 角色 ${appearance.character.name} 形象 ${appearanceId}: 取消选择`) - } - - const signedUrl = selectedImageKey ? getSignedUrl(selectedImageKey, 7 * 24 * 3600) : null - - return NextResponse.json({ - success: true, - selectedIndex, - imageUrl: signedUrl - }) + return NextResponse.json(result) }) diff --git a/src/app/api/novel-promotion/[projectId]/select-location-image/route.ts b/src/app/api/novel-promotion/[projectId]/select-location-image/route.ts index b9dcda1..30b41b3 100644 --- a/src/app/api/novel-promotion/[projectId]/select-location-image/route.ts +++ b/src/app/api/novel-promotion/[projectId]/select-location-image/route.ts @@ -1,78 +1,38 @@ -import { logInfo as _ulogInfo } from '@/lib/logging/core' import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { getSignedUrl } from '@/lib/storage' -import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight } from '@/lib/api-auth' +import { selectAssetRender } from '@/lib/assets/services/asset-actions' + +type LegacyProjectLocationSelectBody = { + locationId?: string + selectedIndex?: number | null +} -/** - * POST - 选择场景图片 - * 直接更新独立的 LocationImage 表 - */ export const POST = apiHandler(async ( request: NextRequest, - context: { params: Promise<{ projectId: string }> } + context: { params: Promise<{ projectId: string }> }, ) => { const { projectId } = await context.params - - // 🔐 统一权限验证 const authResult = await requireProjectAuthLight(projectId) if (isErrorResponse(authResult)) return authResult - const { locationId, selectedIndex } = await request.json() - - if (!locationId) { + const body = await request.json() as LegacyProjectLocationSelectBody + if (typeof body.locationId !== 'string' || body.locationId.trim().length === 0) { throw new ApiError('INVALID_PARAMS') } - // 获取场景和所有图片 - const location = await prisma.novelPromotionLocation.findUnique({ - where: { id: locationId }, - include: { images: { orderBy: { imageIndex: 'asc' } } } + const result = await selectAssetRender({ + kind: 'location', + assetId: body.locationId, + body: { + imageIndex: body.selectedIndex, + }, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId, + }, }) - if (!location) { - throw new ApiError('NOT_FOUND') - } - - // 验证索引 - if (selectedIndex !== null) { - const targetImage = location.images.find(img => img.imageIndex === selectedIndex) - if (!targetImage || !targetImage.imageUrl) { - throw new ApiError('INVALID_PARAMS') - } - } - - // 先取消所有选中状态(兼容旧字段) - await prisma.locationImage.updateMany({ - where: { locationId }, - data: { isSelected: false } - }) - - // 选中指定的图片 - let signedUrl: string | null = null - if (selectedIndex !== null) { - const updated = await prisma.locationImage.update({ - where: { locationId_imageIndex: { locationId, imageIndex: selectedIndex } }, - data: { isSelected: true } - }) - signedUrl = updated.imageUrl ? getSignedUrl(updated.imageUrl, 7 * 24 * 3600) : null - await prisma.novelPromotionLocation.update({ - where: { id: locationId }, - data: { selectedImageId: updated.id } - }) - _ulogInfo(`✓ 场景 ${location.name}: 选择了索引 ${selectedIndex}`) - } else { - await prisma.novelPromotionLocation.update({ - where: { id: locationId }, - data: { selectedImageId: null } - }) - _ulogInfo(`✓ 场景 ${location.name}: 取消选择`) - } - - return NextResponse.json({ - success: true, - selectedIndex, - imageUrl: signedUrl - }) + return NextResponse.json(result) }) diff --git a/src/app/api/novel-promotion/[projectId]/undo-regenerate/route.ts b/src/app/api/novel-promotion/[projectId]/undo-regenerate/route.ts index 1238d47..36d17a1 100644 --- a/src/app/api/novel-promotion/[projectId]/undo-regenerate/route.ts +++ b/src/app/api/novel-promotion/[projectId]/undo-regenerate/route.ts @@ -1,255 +1,36 @@ -import { logError as _ulogError } from '@/lib/logging/core' -/** - * 撤回重新生成的图片,恢复到上一版本 - * POST /api/novel-promotion/[projectId]/undo-regenerate - */ - import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { deleteObject } from '@/lib/storage' -import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract' -import { resolveStorageKeyFromMediaValue } from '@/lib/media/service' -import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError } from '@/lib/api-errors' +import { isErrorResponse, requireProjectAuthLight } from '@/lib/api-auth' +import { revertAssetRender } from '@/lib/assets/services/asset-actions' -interface CharacterAppearanceRecord { - id: string - imageUrl: string | null - imageUrls: string | null - previousImageUrl: string | null - previousImageUrls: string | null - description: string | null - descriptions: unknown - previousDescription: string | null - previousDescriptions: unknown -} - -interface LocationImageRecord { - id: string - imageUrl: string | null - previousImageUrl: string | null - description: string | null - previousDescription: string | null -} - -interface LocationRecord { - images?: LocationImageRecord[] -} - -interface PanelRecord { - id: string - imageUrl: string | null - previousImageUrl: string | null -} - -interface UndoRegenerateTx { - characterAppearance: { - update(args: Record): Promise - } - locationImage: { - update(args: Record): Promise - } -} - -interface UndoRegenerateDb extends UndoRegenerateTx { - characterAppearance: { - findUnique(args: Record): Promise - update(args: Record): Promise - } - novelPromotionLocation: { - findUnique(args: Record): Promise - } - novelPromotionPanel: { - findUnique(args: Record): Promise - update(args: Record): Promise - } - $transaction(fn: (tx: UndoRegenerateTx) => Promise): Promise +type LegacyProjectRevertBody = Record & { + type?: 'character' | 'location' + id?: string } export const POST = apiHandler(async ( - request: NextRequest, - context: { params: Promise<{ projectId: string }> } + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, ) => { - const { projectId } = await context.params - const db = prisma as unknown as UndoRegenerateDb - - // 🔐 统一权限验证 - const authResult = await requireProjectAuthLight(projectId) - if (isErrorResponse(authResult)) return authResult - - const { type, id, appearanceId } = await request.json() - - // 🔒 UUID 格式验证辅助函数 - const isValidUUID = (str: unknown): boolean => { - if (typeof str !== 'string') return false - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - return uuidRegex.test(str) - } - - if (!type || !id) { - throw new ApiError('INVALID_PARAMS') - } - - if (type === 'character') { - // 🔒 验证 appearanceId 是有效的 UUID - if (!appearanceId || !isValidUUID(appearanceId)) { - _ulogError(`[undo-regenerate] 收到无效的 appearanceId: ${appearanceId} (类型: ${typeof appearanceId})`) - throw new ApiError('INVALID_PARAMS') - } - return await undoCharacterRegenerate(db, appearanceId) - } else if (type === 'location') { - return await undoLocationRegenerate(db, id) - } else if (type === 'panel') { - return await undoPanelRegenerate(db, id) - } + const { projectId } = await context.params + const authResult = await requireProjectAuthLight(projectId) + if (isErrorResponse(authResult)) return authResult + const body = await request.json() as LegacyProjectRevertBody + if ((body.type !== 'character' && body.type !== 'location') || typeof body.id !== 'string' || body.id.trim().length === 0) { throw new ApiError('INVALID_PARAMS') + } + + const result = await revertAssetRender({ + kind: body.type, + assetId: body.id, + body, + access: { + scope: 'project', + userId: authResult.session.user.id, + projectId, + }, + }) + + return NextResponse.json(result) }) - -async function undoCharacterRegenerate(db: UndoRegenerateDb, appearanceId: string) { - // 使用 UUID 直接查询形象 - const appearance = await db.characterAppearance.findUnique({ - where: { id: appearanceId }, - include: { character: true } - }) - - if (!appearance) { - throw new ApiError('NOT_FOUND') - } - - const previousImageUrls = decodeImageUrlsFromDb(appearance.previousImageUrls, 'characterAppearance.previousImageUrls') - - // 检查是否有上一版本 - if (!appearance.previousImageUrl && previousImageUrls.length === 0) { - throw new ApiError('INVALID_PARAMS') - } - - // 删除当前图片 - const currentImageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'characterAppearance.imageUrls') - for (const key of currentImageUrls) { - if (key) { - try { - const storageKey = await resolveStorageKeyFromMediaValue(key) - if (storageKey) await deleteObject(storageKey) - } catch { } - } - } - - const restoredImageUrls = previousImageUrls.length > 0 - ? previousImageUrls - : (appearance.previousImageUrl ? [appearance.previousImageUrl] : []) - - await db.$transaction(async (tx) => { - await tx.characterAppearance.update({ - where: { id: appearance.id }, - data: { - imageUrl: appearance.previousImageUrl || restoredImageUrls[0] || null, - imageUrls: encodeImageUrls(restoredImageUrls), - previousImageUrl: null, - previousImageUrls: encodeImageUrls([]), - selectedIndex: null, - // 🔥 同时恢复描述词 - description: appearance.previousDescription ?? appearance.description, - descriptions: appearance.previousDescriptions ?? appearance.descriptions, - previousDescription: null, - previousDescriptions: null - } - }) - }) - - return NextResponse.json({ - success: true, - message: '已撤回到上一版本(图片和描述词)' - }) -} - -async function undoLocationRegenerate(db: UndoRegenerateDb, locationId: string) { - // 获取场景和图片 - const location = await db.novelPromotionLocation.findUnique({ - where: { id: locationId }, - include: { images: { orderBy: { imageIndex: 'asc' } } } - }) - - if (!location) { - throw new ApiError('NOT_FOUND') - } - - // 检查是否有上一版本 - const hasPrevious = location.images?.some((img) => img.previousImageUrl) - if (!hasPrevious) { - throw new ApiError('INVALID_PARAMS') - } - - // 删除当前图片并恢复上一版本 - await db.$transaction(async (tx) => { - for (const img of location.images || []) { - if (img.previousImageUrl) { - // 删除当前图片 - if (img.imageUrl) { - try { - const storageKey = await resolveStorageKeyFromMediaValue(img.imageUrl) - if (storageKey) await deleteObject(storageKey) - } catch { } - } - // 恢复上一版本(图片 + 描述词) - await tx.locationImage.update({ - where: { id: img.id }, - data: { - imageUrl: img.previousImageUrl, - previousImageUrl: null, - // 🔥 同时恢复描述词 - description: img.previousDescription ?? img.description, - previousDescription: null - } - }) - } - } - }) - - return NextResponse.json({ - success: true, - message: '已撤回到上一版本(图片和描述词)' - }) -} - -/** - * 撤回 Panel 镜头图片到上一版本 - */ -async function undoPanelRegenerate(db: UndoRegenerateDb, panelId: string) { - // 获取镜头 - const panel = await db.novelPromotionPanel.findUnique({ - where: { id: panelId } - }) - - if (!panel) { - throw new ApiError('NOT_FOUND') - } - - // 检查是否有上一版本 - if (!panel.previousImageUrl) { - throw new ApiError('INVALID_PARAMS') - } - - // 删除当前图片(如果存在) - if (panel.imageUrl) { - try { - const storageKey = await resolveStorageKeyFromMediaValue(panel.imageUrl) - if (storageKey) await deleteObject(storageKey) - } catch { } - } - - // 恢复上一版本 - await db.novelPromotionPanel.update({ - where: { id: panelId }, - data: { - imageUrl: panel.previousImageUrl, - previousImageUrl: null, - candidateImages: null // 清空候选图片 - } - }) - - return NextResponse.json({ - success: true, - message: '镜头图片已撤回到上一版本' - }) -} diff --git a/src/app/api/novel-promotion/[projectId]/update-asset-label/route.ts b/src/app/api/novel-promotion/[projectId]/update-asset-label/route.ts index ad2d791..afaa832 100644 --- a/src/app/api/novel-promotion/[projectId]/update-asset-label/route.ts +++ b/src/app/api/novel-promotion/[projectId]/update-asset-label/route.ts @@ -1,13 +1,7 @@ -import { logError as _ulogError } from '@/lib/logging/core' import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { uploadObject, getSignedUrl, toFetchableUrl, generateUniqueKey } from '@/lib/storage' -import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract' -import { resolveStorageKeyFromMediaValue } from '@/lib/media/service' -import sharp from 'sharp' -import { initializeFonts, createLabelSVG } from '@/lib/fonts' import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError } from '@/lib/api-errors' +import { updateAssetRenderLabel } from '@/lib/assets/services/asset-label' /** * POST /api/novel-promotion/[projectId]/update-asset-label @@ -19,168 +13,28 @@ export const POST = apiHandler(async ( ) => { const { projectId } = await context.params - // 初始化字体(在 Vercel 环境中需要) - await initializeFonts() - // 🔐 统一权限验证 const authResult = await requireProjectAuthLight(projectId) if (isErrorResponse(authResult)) return authResult const body = await request.json() const { type, id, newName, appearanceIndex } = body - // type: 'character' | 'location' - // id: characterId 或 locationId - // newName: 新名字 - // appearanceIndex: 角色形象索引(仅角色需要) - if (!type || !id || !newName) { throw new ApiError('INVALID_PARAMS') } - if (type === 'character') { - // 获取角色的所有形象 - const character = await prisma.novelPromotionCharacter.findUnique({ - where: { id: id }, - include: { appearances: true } + void appearanceIndex + + if (type === 'character' || type === 'location') { + await updateAssetRenderLabel({ + scope: 'project', + kind: type, + assetId: id, + projectId, + newName, }) - - if (!character) { - throw new ApiError('NOT_FOUND') - } - - // 更新每个形象的图片标签 - const updatePromises = character.appearances.map(async (appearance) => { - // 如果指定了 appearanceIndex,只更新该形象 - if (appearanceIndex !== undefined && appearance.appearanceIndex !== appearanceIndex) { - return null - } - - // 获取图片 URLs - let imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'characterAppearance.imageUrls') - if (imageUrls.length === 0 && appearance.imageUrl) { - imageUrls = [appearance.imageUrl] - } - - if (imageUrls.length === 0) return null - - // 更新每张图片的标签 - const newLabelText = `${newName} - ${appearance.changeReason}` - const newImageUrls: string[] = await Promise.all( - imageUrls.map(async (url, i) => { - if (!url) return '' - try { - return await updateImageLabel(url, newLabelText) - } catch (e) { - _ulogError(`Failed to update label for image ${i}:`, e) - return url // 保留原 URL - } - }) - ) - - const firstUrl = newImageUrls.find((u) => !!u) || null - - // 更新数据库 - await prisma.characterAppearance.update({ - where: { id: appearance.id }, - data: { - imageUrls: encodeImageUrls(newImageUrls), - imageUrl: firstUrl - } - }) - - return { appearanceIndex: appearance.appearanceIndex, imageUrls: newImageUrls } - }) - - const results = await Promise.all(updatePromises) - return NextResponse.json({ success: true, results: results.filter(r => r !== null) }) - - } else if (type === 'location') { - // 获取场景 - const location = await prisma.novelPromotionLocation.findUnique({ - where: { id: id }, - include: { images: true } - }) - - if (!location) { - throw new ApiError('NOT_FOUND') - } - - // 更新每张图片的标签 - const updatePromises = location.images.map(async (image) => { - if (!image.imageUrl) return null - - const newLabelText = newName - try { - const newImageUrl = await updateImageLabel( - image.imageUrl, - newLabelText - ) - - // 更新数据库 - await prisma.locationImage.update({ - where: { id: image.id }, - data: { imageUrl: newImageUrl } - }) - - return { imageIndex: image.imageIndex, imageUrl: newImageUrl } - } catch (e) { - _ulogError(`Failed to update label for location image ${image.imageIndex}:`, e) - return null - } - }) - - const results = await Promise.all(updatePromises) - return NextResponse.json({ success: true, results: results.filter(r => r !== null) }) + return NextResponse.json({ success: true }) } throw new ApiError('INVALID_PARAMS') }) - -/** - * 更新图片的黑边标签 - * 🔥 生成新的 COS key 上传,使 URL 发生变化,浏览器缓存自动失效,前端能看到新标签 - */ -async function updateImageLabel(imageUrl: string, newLabelText: string): Promise { - const originalKey = await resolveStorageKeyFromMediaValue(imageUrl) - if (!originalKey) { - throw new Error(`无法归一化媒体 key: ${imageUrl}`) - } - const signedUrl = getSignedUrl(originalKey, 3600) - - // 下载图片 - const response = await fetch(toFetchableUrl(signedUrl)) - if (!response.ok) { - throw new Error(`Failed to download image: ${response.status}`) - } - const buffer = Buffer.from(await response.arrayBuffer()) - - // 获取图片元数据 - const meta = await sharp(buffer).metadata() - const w = meta.width || 2160 - const h = meta.height || 2160 - - // 计算标签条高度(与生成时一致:高度的 4%) - const fontSize = Math.floor(h * 0.04) - const pad = Math.floor(fontSize * 0.5) - const barH = fontSize + pad * 2 - - // 裁剪掉顶部的旧标签条 - const croppedBuffer = await sharp(buffer) - .extract({ left: 0, top: barH, width: w, height: h - barH }) - .toBuffer() - - // 创建新的 SVG 标签条 - const svg = await createLabelSVG(w, barH, fontSize, pad, newLabelText) - - // 添加新标签条到图片顶部 - const processed = await sharp(croppedBuffer) - .extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } }) - .composite([{ input: svg, top: 0, left: 0 }]) - .jpeg({ quality: 90, mozjpeg: true }) - .toBuffer() - - // 🔥 生成新 key 上传,使图片 URL 发生变化,强制浏览器绕过缓存,确保前端能看到新标签 - const newKey = generateUniqueKey('labeled-rename', 'jpg') - await uploadObject(processed, newKey) - return newKey -} diff --git a/src/app/api/projects/[projectId]/assets/route.ts b/src/app/api/projects/[projectId]/assets/route.ts index 81bba77..6a15cf2 100644 --- a/src/app/api/projects/[projectId]/assets/route.ts +++ b/src/app/api/projects/[projectId]/assets/route.ts @@ -4,6 +4,10 @@ import { requireUserAuth, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError } from '@/lib/api-errors' import { attachMediaFieldsToProject } from '@/lib/media/attach' +function readAssetKind(value: Record): string { + return typeof value.assetKind === 'string' ? value.assetKind : 'location' +} + /** * ⚡ 延迟加载 API - 获取项目的 characters 和 locations 资产 * 用于资产管理页面,避免首次加载时的性能开销 @@ -55,8 +59,12 @@ export const GET = apiHandler(async ( // 转换为稳定媒体 URL(并保留兼容字段) const dataWithSignedUrls = await attachMediaFieldsToProject(novelPromotionData) + const locations = (dataWithSignedUrls.locations || []).filter((item) => readAssetKind(item) !== 'prop') + const props = (dataWithSignedUrls.locations || []).filter((item) => readAssetKind(item) === 'prop') + return NextResponse.json({ characters: dataWithSignedUrls.characters || [], - locations: dataWithSignedUrls.locations || [] + locations, + props, }) }) diff --git a/src/app/api/projects/[projectId]/data/route.ts b/src/app/api/projects/[projectId]/data/route.ts index a3c529c..8f7494d 100644 --- a/src/app/api/projects/[projectId]/data/route.ts +++ b/src/app/api/projects/[projectId]/data/route.ts @@ -5,6 +5,10 @@ import { requireUserAuth, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError } from '@/lib/api-errors' import { attachMediaFieldsToProject } from '@/lib/media/attach' +function readAssetKind(value: Record): string { + return typeof value.assetKind === 'string' ? value.assetKind : 'location' +} + /** * 统一的项目数据加载API * 返回项目基础信息、全局配置、全局资产和剧集列表 @@ -71,10 +75,15 @@ export const GET = apiHandler(async ( // 转换为稳定媒体 URL(并保留兼容字段) const novelPromotionDataWithSignedUrls = await attachMediaFieldsToProject(novelPromotionData) + const filteredNovelPromotionData = { + ...novelPromotionDataWithSignedUrls, + locations: (novelPromotionDataWithSignedUrls.locations || []).filter((item) => readAssetKind(item) !== 'prop'), + props: (novelPromotionDataWithSignedUrls.locations || []).filter((item) => readAssetKind(item) === 'prop'), + } const fullProject = { ...project, - novelPromotionData: novelPromotionDataWithSignedUrls + novelPromotionData: filteredNovelPromotionData // 🔥 不再用 userPreference 覆盖任何字段 // editModel 等配置应该直接使用 novelPromotionData 中的值 } diff --git a/src/components/shared/assets/GlobalAssetPicker.tsx b/src/components/shared/assets/GlobalAssetPicker.tsx index c4eed81..3e22c1a 100644 --- a/src/components/shared/assets/GlobalAssetPicker.tsx +++ b/src/components/shared/assets/GlobalAssetPicker.tsx @@ -13,7 +13,7 @@ interface GlobalAssetPickerProps { isOpen: boolean onClose: () => void onSelect: (globalAssetId: string) => void - type: 'character' | 'location' | 'voice' + type: 'character' | 'location' | 'prop' | 'voice' loading?: boolean } @@ -47,6 +47,8 @@ interface GlobalLocation { images: GlobalLocationImage[] } +type GlobalProp = GlobalLocation + interface GlobalVoice { id: string name: string @@ -117,41 +119,54 @@ export default function GlobalAssetPicker({ const charactersQuery = useQuery({ queryKey: ['global-assets', 'characters'], queryFn: async () => { - const res = await apiFetch('/api/asset-hub/characters') + const res = await apiFetch('/api/assets?scope=global&kind=character') if (!res.ok) throw new Error('Failed to fetch characters') const data = await res.json() - return data.characters as GlobalCharacter[] + return data.assets as GlobalCharacter[] }, enabled: type === 'character', }) const locationsQuery = useQuery({ queryKey: ['global-assets', 'locations'], queryFn: async () => { - const res = await apiFetch('/api/asset-hub/locations') + const res = await apiFetch('/api/assets?scope=global&kind=location') if (!res.ok) throw new Error('Failed to fetch locations') const data = await res.json() - return data.locations as GlobalLocation[] + return data.assets as GlobalLocation[] }, enabled: type === 'location', }) + const propsQuery = useQuery({ + queryKey: ['global-assets', 'props'], + queryFn: async () => { + const res = await apiFetch('/api/assets?scope=global&kind=prop') + if (!res.ok) throw new Error('Failed to fetch props') + const data = await res.json() + return data.assets as GlobalProp[] + }, + enabled: type === 'prop', + }) const voicesQuery = useQuery({ queryKey: ['global-assets', 'voices'], queryFn: async () => { - const res = await apiFetch('/api/asset-hub/voices') + const res = await apiFetch('/api/assets?scope=global&kind=voice') if (!res.ok) throw new Error('Failed to fetch voices') const data = await res.json() - return data.voices as GlobalVoice[] + return data.assets as GlobalVoice[] }, enabled: type === 'voice', }) const characters = (charactersQuery.data || []) as GlobalCharacter[] const locations = (locationsQuery.data || []) as GlobalLocation[] + const props = (propsQuery.data || []) as GlobalProp[] const voices = (voicesQuery.data || []) as GlobalVoice[] const isLoading = type === 'character' ? charactersQuery.isFetching : type === 'location' ? locationsQuery.isFetching + : type === 'prop' + ? propsQuery.isFetching : voicesQuery.isFetching const loadingState = isLoading ? resolveTaskPresentationState({ @@ -179,6 +194,7 @@ export default function GlobalAssetPicker({ // 提取稳定的 refetch 引用,避免 useEffect 无限循环 const refetchCharacters = charactersQuery.refetch const refetchLocations = locationsQuery.refetch + const refetchProps = propsQuery.refetch const refetchVoices = voicesQuery.refetch // 停止音频播放的辅助函数 @@ -200,6 +216,8 @@ export default function GlobalAssetPicker({ refetchCharacters() } else if (type === 'location') { refetchLocations() + } else if (type === 'prop') { + refetchProps() } else { refetchVoices() } @@ -225,6 +243,10 @@ export default function GlobalAssetPicker({ l.name.toLowerCase().includes(searchQuery.toLowerCase()) ) + const filteredProps = props.filter(l => + l.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + const filteredVoices = voices.filter(v => v.name.toLowerCase().includes(searchQuery.toLowerCase()) || (v.description && v.description.toLowerCase().includes(searchQuery.toLowerCase())) @@ -263,8 +285,20 @@ export default function GlobalAssetPicker({ if (!isOpen) return null - const items = type === 'character' ? filteredCharacters : type === 'location' ? filteredLocations : filteredVoices - const hasNoAssets = type === 'character' ? characters.length === 0 : type === 'location' ? locations.length === 0 : voices.length === 0 + const items = type === 'character' + ? filteredCharacters + : type === 'location' + ? filteredLocations + : type === 'prop' + ? filteredProps + : filteredVoices + const hasNoAssets = type === 'character' + ? characters.length === 0 + : type === 'location' + ? locations.length === 0 + : type === 'prop' + ? props.length === 0 + : voices.length === 0 return (
@@ -272,7 +306,7 @@ export default function GlobalAssetPicker({ {/* 头部 */}

- {type === 'character' ? t('selectCharacter') : type === 'location' ? t('selectLocation') : t('selectVoice')} + {type === 'character' ? t('selectCharacter') : type === 'location' ? t('selectLocation') : type === 'prop' ? t('selectProp') : t('selectVoice')}

+
+ +
+
+ + setName(event.target.value)} + placeholder={t('prop.namePlaceholder')} + className="glass-input-base w-full px-3 py-2 text-sm" + /> +
+ +
+ +