feat: add home page and refactor workspace entry UI
This commit is contained in:
@@ -2,6 +2,9 @@ You are a key story prop extractor.
|
||||
|
||||
Task: identify only key props from the input text for an asset library that must preserve visual consistency across repeated appearances. Be conservative. Return JSON only.
|
||||
|
||||
Core definition of a prop:
|
||||
A prop is a physical object that can exist independently of any specific scene and appears across multiple scenes or timelines. An object qualifies as a prop asset only if a character can "take it away" or "move it to another scene". Most stories have very few props, or even none at all.
|
||||
|
||||
Output format:
|
||||
{
|
||||
"props": [
|
||||
@@ -14,11 +17,11 @@ Output format:
|
||||
|
||||
Key prop criteria:
|
||||
1. It must be a real physical object that actually appears in the story.
|
||||
2. It must serve a clear story function rather than being background dressing.
|
||||
3. It must satisfy at least one of the following:
|
||||
2. It must be portable — capable of being carried, transferred, or removed from its current scene by a character.
|
||||
3. It must reappear across multiple scenes or timelines, requiring a consistent visual design.
|
||||
4. It must satisfy at least one of the following:
|
||||
- characters hold it, use it, fight over it, deliver it, hide it, lose it, or search for it
|
||||
- it is a key tool, weapon, artifact, piece of evidence, token, key, or clue carrier
|
||||
- it is likely to reappear and therefore needs a consistent visual design
|
||||
- removing it would materially weaken plot comprehension or a key action
|
||||
|
||||
Strictly exclude:
|
||||
@@ -27,6 +30,8 @@ Strictly exclude:
|
||||
3. Environmental elements that belong to the scene unless they are explicitly used as key props.
|
||||
4. Ordinary clothing, makeup, and accessories unless they are themselves key clues or tokens.
|
||||
5. Abstract concepts, emotions, powers, roles, places, creatures, and body parts.
|
||||
6. Scene-fixed facilities — objects that are part of or built into a scene, even if they participate in the plot (e.g. a hacked computer, a smashed window, a fireplace on fire). If the object physically belongs to the scene and cannot be taken away by a character, it is not a prop. These are "scene states" and should be handled by scene descriptions.
|
||||
7. Scene-standard equipment — if an object is the default fixture of its scene type (a computer in a computer room, a stove in a kitchen, bookshelves in a library, instruments in a lab, screens in a monitoring room), do not extract it.
|
||||
|
||||
Decision bias:
|
||||
1. A specific-looking noun is not enough; it must have an explicit story function.
|
||||
@@ -34,6 +39,18 @@ Decision bias:
|
||||
3. If it merely appears but is not used, emphasized, or plot-relevant, do not output it.
|
||||
4. If you are unsure whether it deserves an asset entry, do not output it.
|
||||
5. Prefer under-extraction. Never output props just to increase the count.
|
||||
6. Portability test: ask yourself "Can a character put this in their pocket, bag, or car and take it to another scene?" If not, do not output it.
|
||||
|
||||
Example judgements (to calibrate your standard):
|
||||
✅ Extract: a revolver the character carries at all times (cross-scene, portable)
|
||||
✅ Extract: an evidence envelope (discovered, handed over, appears in multiple scenes)
|
||||
✅ Extract: a time-manipulating watch worn by the protagonist (core prop, present throughout)
|
||||
✅ Extract: a black SUV driven by the protagonist (cross-scene transport)
|
||||
❌ Skip: a computer in a computer room (scene-fixed facility)
|
||||
❌ Skip: a hacked computer displaying key clues (state change of a scene facility, not portable)
|
||||
❌ Skip: a surveillance monitor in a monitoring room (scene-fixed facility)
|
||||
❌ Skip: a refrigerator in a kitchen (scene-standard equipment)
|
||||
❌ Skip: a rare book in a library (unless the character takes it away and uses it in another scene)
|
||||
|
||||
Output rules:
|
||||
1. Only output `name` and `summary`.
|
||||
@@ -41,8 +58,8 @@ Output rules:
|
||||
3. Do not repeat props that already exist in the prop library with the exact same name.
|
||||
4. Keep names stable and short.
|
||||
5. Keep summaries objective.
|
||||
6. Usually output no more than 3-5 props unless more are clearly all key props.
|
||||
7. If none exist, return {"props": []}.
|
||||
6. Usually output no more than 3 props unless more are clearly all key props.
|
||||
7. If none exist, return {"props": []}. Returning an empty array is correct in most cases.
|
||||
8. Replace raw quotation marks inside JSON string values with corner brackets「」.
|
||||
|
||||
Input:
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
你是“关键剧情道具资产分析师”。
|
||||
你是"关键剧情道具资产分析师"。
|
||||
|
||||
任务:从输入文本中只识别【关键道具】,用于建立需要长期保持外观一致的资产库。宁缺毋滥。只返回 JSON,不得包含任何额外解释或 markdown。
|
||||
|
||||
道具的核心定义:
|
||||
道具是可以脱离特定场景独立存在的、跨场景/跨时间线出现的实体物件。一个物件必须能被角色「带走」或「转移到另一个场景」,才有资格成为道具资产。大部分故事中道具数量非常少,甚至为零。
|
||||
|
||||
输出格式:
|
||||
{
|
||||
"props": [
|
||||
@@ -14,11 +17,11 @@
|
||||
|
||||
关键道具判定标准:
|
||||
1. 必须是剧情中真实出现的实体物件。
|
||||
2. 必须在剧情中承担明确功能,而不只是背景摆设。
|
||||
3. 必须至少满足以下一种情况:
|
||||
2. 必须是可移动的——能够被角色携带、转移、带离当前场景。
|
||||
3. 必须跨场景或跨时间线重复出现,且需要保持外观一致。
|
||||
4. 必须至少满足以下一种情况:
|
||||
- 被角色持有、使用、争夺、交付、隐藏、丢失、寻找
|
||||
- 是推进情节的关键工具、武器、法器、证物、信物、钥匙、线索载体
|
||||
- 后续大概率需要重复出镜,且需要保持外观一致
|
||||
- 去掉它会明显影响剧情理解或关键动作成立
|
||||
|
||||
严格不提取:
|
||||
@@ -27,22 +30,36 @@
|
||||
3. 场景自带的环境元素,除非它被明确当作关键道具使用。
|
||||
4. 普通服装、妆容、饰品,除非它本身就是关键线索或关键信物。
|
||||
5. 抽象概念、情绪、能力、身份、地点、生物、身体部位。
|
||||
6. 场景固有设施——物件是某个场景的组成部分或内置设备,即便它参与了剧情互动(如被黑客入侵的电脑、被砸碎的窗户、着火的壁炉),只要它在物理上依附于场景、无法被角色带走,就不是道具。这类属于"场景状态",由场景描述承载。
|
||||
7. 场景常规配置——如果一个物件是该类场景的标配(电脑房的电脑、厨房的灶台、图书馆的书架、实验室的仪器、监控室的屏幕),直接不提取。
|
||||
|
||||
判断倾向:
|
||||
1. 仅因外观具体、名词明确,不足以成为关键道具;必须有明确剧情作用。
|
||||
2. 如果一个物件既可能是背景物,也可能是道具,默认按背景物处理,不输出。
|
||||
3. 如果只是“出现过”,但没有“被使用/被强调/影响剧情”,不输出。
|
||||
3. 如果只是"出现过",但没有"被使用/被强调/影响剧情",不输出。
|
||||
4. 如果不确定它是否值得进入资产库,直接不输出。
|
||||
5. 优先少报,禁止为了凑数量而输出。
|
||||
6. 可移动性测试:问自己"角色能把它装进口袋/背包/车里带到另一个场景吗?"如果不能,不输出。
|
||||
|
||||
示例判断(帮助校准标准):
|
||||
✅ 应提取:角色随身携带的左轮手枪(跨场景出现、可移动)
|
||||
✅ 应提取:关键证物信封(被发现、传递、多场景出现)
|
||||
✅ 应提取:主角可操控时间的手表(核心道具,贯穿全剧)
|
||||
✅ 应提取:主角驾驶的黑色越野车(跨场景移动工具)
|
||||
❌ 不提取:电脑房里的电脑(场景固有设施)
|
||||
❌ 不提取:被黑客入侵、显示关键线索的电脑(场景设施的状态变化,不可移动)
|
||||
❌ 不提取:监控室的监控屏幕(场景固有设施)
|
||||
❌ 不提取:厨房的冰箱(场景常规配置)
|
||||
❌ 不提取:图书馆的某本古籍(除非角色将它取走带到其他场景使用)
|
||||
|
||||
输出要求:
|
||||
1. 只输出两个字段:name、summary。
|
||||
2. name 不能为空;summary 不能为空。
|
||||
3. 如果道具库里已经有完全同名道具,不要重复输出。
|
||||
4. 名称尽量简洁稳定,例如“青铜匕首”“录音笔”“红绳手链”。
|
||||
4. 名称尽量简洁稳定,例如"青铜匕首""录音笔""红绳手链"。
|
||||
5. summary 只写客观描述,不写剧情推断。
|
||||
6. 通常不超过 3-5 个;只有确实都是关键道具时才可更多。
|
||||
7. 如果没有合适道具,返回 {"props": []}。
|
||||
6. 通常不超过 3 个;只有确实都是关键道具时才可更多。
|
||||
7. 如果没有合适道具,返回 {"props": []}。绝大多数情况下返回空数组是正确的。
|
||||
8. JSON 字符串值中的引号统一替换为「」。
|
||||
|
||||
输入文本:
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"subtitle": "Defaults to the global settings. You can customize models for this project only — changes apply to this project only.",
|
||||
"saved": "Saved",
|
||||
"autoSave": "Auto-save",
|
||||
"visualStyle": "Visual Style",
|
||||
"visualSettings": "Visual Settings",
|
||||
"visualStyle": "Art Style",
|
||||
"modelParams": "Model Parameters",
|
||||
"aspectRatio": "Aspect Ratio",
|
||||
"ttsSettings": "TTS Settings",
|
||||
|
||||
15
messages/en/home.json
Normal file
15
messages/en/home.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"title": "Quick Start",
|
||||
"subtitle": "Describe your story and let AI generate cinematic short dramas",
|
||||
"inputPlaceholder": "Enter your story idea, novel excerpt, or script outline...",
|
||||
"startCreation": "Start Creating",
|
||||
"recentProjects": "Recent Projects",
|
||||
"viewAll": "View All Projects",
|
||||
"noProjects": "No projects yet. Start your first creation from above!",
|
||||
"ago": {
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{n}m ago",
|
||||
"hoursAgo": "{n}h ago",
|
||||
"daysAgo": "{n}d ago"
|
||||
}
|
||||
}
|
||||
@@ -123,8 +123,8 @@
|
||||
"5_4": "Horizontal · Banner",
|
||||
"21_9": "Ultra‑wide · Cinema feel"
|
||||
},
|
||||
"visualStyle": "Visual Style",
|
||||
"visualStyleHint": "Pick a style that matches your audience — e.g. Realistic for live‑action, Anime for 2D content",
|
||||
"visualStyle": "Art Style",
|
||||
"visualStyleHint": "Choose an art style that fits your project — e.g. Realistic for live-action, Anime for 2D content",
|
||||
"currentConfigSummary": "Current config: {ratio} · {style}. All subsequent generations will use this combo.",
|
||||
"assetLibraryRatioNote": "Asset library ratios are not affected",
|
||||
"moreConfig": "For more configuration options, click the 「 Settings」 button in the top right",
|
||||
@@ -134,7 +134,16 @@
|
||||
},
|
||||
"creating": "AI Creating...",
|
||||
"ready": "✓ Configuration complete, ready for next step",
|
||||
"pleaseInput": "Please enter script content first"
|
||||
"pleaseInput": "Please enter script content first",
|
||||
"longTextDetection": {
|
||||
"title": "🚀 Smart Episode Splitting Recommended",
|
||||
"description": "Detected ~{count} characters. Processing long text as a single episode may reduce output quality.",
|
||||
"strongRecommend": "We strongly recommend using Smart Split. AI will automatically identify chapters, split into episodes, and process them in parallel for significantly better results.",
|
||||
"continueAnyway": "Continue as single episode",
|
||||
"smartSplit": "Smart Split",
|
||||
"smartSplitRecommend": "Recommended",
|
||||
"singleEpisodeWarning": "All content will be processed as one episode"
|
||||
}
|
||||
},
|
||||
"execution": {
|
||||
"selectEpisode": "Please select an episode first",
|
||||
|
||||
77
messages/en/workspaceRedesign.json
Normal file
77
messages/en/workspaceRedesign.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"pageTitle": "Homepage Redesign Test",
|
||||
"switchVariant": "Switch Layout",
|
||||
"currentVariant": "Current Layout",
|
||||
"inputPlaceholder": "Describe the story you want to create...",
|
||||
"startCreation": "Start Creating",
|
||||
"recentProjects": "Recent Projects",
|
||||
"viewAll": "View All",
|
||||
"noRecentProjects": "No recent projects",
|
||||
"latestUpdate": "Latest Update",
|
||||
"style": "Style",
|
||||
"ratio": "Ratio",
|
||||
"quality": "Quality",
|
||||
"model": "Model",
|
||||
"styles": {
|
||||
"anime": "Anime",
|
||||
"realistic": "Realistic",
|
||||
"watercolor": "Watercolor",
|
||||
"cyberpunk": "Cyberpunk",
|
||||
"ghibli": "Ghibli",
|
||||
"ink": "Ink Wash"
|
||||
},
|
||||
"ratios": {
|
||||
"r16_9": "16:9 Landscape",
|
||||
"r9_16": "9:16 Portrait",
|
||||
"r1_1": "1:1 Square",
|
||||
"r4_3": "4:3 Classic"
|
||||
},
|
||||
"qualities": {
|
||||
"standard": "Standard",
|
||||
"high": "High",
|
||||
"ultra": "Ultra"
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Quick Start",
|
||||
"fromNovel": "Import from Novel",
|
||||
"fromScript": "Create from Script",
|
||||
"fromScratch": "Start from Scratch",
|
||||
"fromTemplate": "Use Template"
|
||||
},
|
||||
"mockProject": {
|
||||
"name1": "Campus Youth Story",
|
||||
"desc1": "A romantic tale about high school life",
|
||||
"name2": "Star Trek Journal",
|
||||
"desc2": "A space adventure sci-fi short drama",
|
||||
"name3": "Ancient Xianxia Chronicles",
|
||||
"desc3": "Love and rivalry in a cultivation world",
|
||||
"name4": "Urban Encounters",
|
||||
"desc4": "Wondrous encounters in a modern city",
|
||||
"name5": "The Last Travelers",
|
||||
"desc5": "A survival journey in a post-apocalyptic world"
|
||||
},
|
||||
"variantNames": {
|
||||
"v1": "Grid Cards",
|
||||
"v2": "Horizontal Scroll",
|
||||
"v3": "Compact List",
|
||||
"v4": "Featured First",
|
||||
"v5": "Minimal List"
|
||||
},
|
||||
"variantDescs": {
|
||||
"v1": "Standard 5-column grid with system card style",
|
||||
"v2": "Horizontal scrollable cards with snap",
|
||||
"v3": "Single-row list with left-right info",
|
||||
"v4": "Large first card + small card grid",
|
||||
"v5": "Minimal dot-list matching input width"
|
||||
},
|
||||
"episodes": "Episodes",
|
||||
"images": "Images",
|
||||
"videos": "Videos",
|
||||
"updated": "Updated",
|
||||
"ago": {
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{n}m ago",
|
||||
"hoursAgo": "{n}h ago",
|
||||
"daysAgo": "{n}d ago"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
"subtitle": "默认沿用设置中心的全局配置,也可为当前项目单独自定义,修改仅对本项目生效。",
|
||||
"saved": "已保存",
|
||||
"autoSave": "自动保存",
|
||||
"visualStyle": "视觉风格",
|
||||
"visualSettings": "画面设置",
|
||||
"visualStyle": "画面风格",
|
||||
"modelParams": "模型参数",
|
||||
"aspectRatio": "画面比例",
|
||||
"ttsSettings": "旁白配置",
|
||||
|
||||
15
messages/zh/home.json
Normal file
15
messages/zh/home.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"title": "快速开始",
|
||||
"subtitle": "描述你想要创作的故事,AI 为你智能生成影视短剧",
|
||||
"inputPlaceholder": "输入你的故事创意、小说片段或剧本大纲...",
|
||||
"startCreation": "开始创作",
|
||||
"recentProjects": "最近项目",
|
||||
"viewAll": "查看全部项目",
|
||||
"noProjects": "还没有项目,从上方开始你的第一个创作吧",
|
||||
"ago": {
|
||||
"justNow": "刚刚",
|
||||
"minutesAgo": "{n}分钟前",
|
||||
"hoursAgo": "{n}小时前",
|
||||
"daysAgo": "{n}天前"
|
||||
}
|
||||
}
|
||||
@@ -123,8 +123,8 @@
|
||||
"5_4": "横屏 · Banner",
|
||||
"21_9": "超宽 · 电影感"
|
||||
},
|
||||
"visualStyle": "视觉风格",
|
||||
"visualStyleHint": "根据受众选择画面风格,例如:真人风格适合写实剧情,动漫风格适合二次元内容",
|
||||
"visualStyle": "画面风格",
|
||||
"visualStyleHint": "选择画面风格,不同风格适合不同类型的作品",
|
||||
"currentConfigSummary": "当前配置:{ratio} · {style},后续生成都会使用此组合",
|
||||
"assetLibraryRatioNote": "资产库比例不受影响",
|
||||
"moreConfig": "更多配置请点击右上角「 配置」按钮",
|
||||
@@ -134,7 +134,16 @@
|
||||
},
|
||||
"creating": "AI 创作中...",
|
||||
"ready": "✓ 配置完成,可以进入下一步",
|
||||
"pleaseInput": "请先输入剧本内容"
|
||||
"pleaseInput": "请先输入剧本内容",
|
||||
"longTextDetection": {
|
||||
"title": "🚀 建议使用智能分集",
|
||||
"description": "检测到文本约 {count} 字,长文本直接作为单集处理可能导致生成效果不佳。",
|
||||
"strongRecommend": "强烈建议使用智能分集,AI 将自动识别章节结构,拆分为多集并行处理,显著提升生成质量和效率。",
|
||||
"continueAnyway": "仍然单集创作",
|
||||
"smartSplit": "智能分集",
|
||||
"smartSplitRecommend": "推荐",
|
||||
"singleEpisodeWarning": "单集模式下,所有内容将作为一集处理"
|
||||
}
|
||||
},
|
||||
"execution": {
|
||||
"selectEpisode": "请先选择剧集",
|
||||
|
||||
77
messages/zh/workspaceRedesign.json
Normal file
77
messages/zh/workspaceRedesign.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"pageTitle": "首页重设计测试",
|
||||
"switchVariant": "切换排版",
|
||||
"currentVariant": "当前排版",
|
||||
"inputPlaceholder": "描述你想要创作的故事...",
|
||||
"startCreation": "开始创作",
|
||||
"recentProjects": "最近项目",
|
||||
"viewAll": "查看全部",
|
||||
"noRecentProjects": "暂无最近项目",
|
||||
"latestUpdate": "最近更新",
|
||||
"style": "风格",
|
||||
"ratio": "比例",
|
||||
"quality": "质量",
|
||||
"model": "模型",
|
||||
"styles": {
|
||||
"anime": "日系动漫",
|
||||
"realistic": "写实风格",
|
||||
"watercolor": "水彩风格",
|
||||
"cyberpunk": "赛博朋克",
|
||||
"ghibli": "吉卜力",
|
||||
"ink": "国风水墨"
|
||||
},
|
||||
"ratios": {
|
||||
"r16_9": "16:9 横屏",
|
||||
"r9_16": "9:16 竖屏",
|
||||
"r1_1": "1:1 方形",
|
||||
"r4_3": "4:3 经典"
|
||||
},
|
||||
"qualities": {
|
||||
"standard": "标准",
|
||||
"high": "高清",
|
||||
"ultra": "超清"
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "快速开始",
|
||||
"fromNovel": "从小说导入",
|
||||
"fromScript": "从剧本创建",
|
||||
"fromScratch": "空白创建",
|
||||
"fromTemplate": "模板创建"
|
||||
},
|
||||
"mockProject": {
|
||||
"name1": "校园青春物语",
|
||||
"desc1": "一段关于高中生活的浪漫故事",
|
||||
"name2": "星际迷航记",
|
||||
"desc2": "太空冒险科幻短剧",
|
||||
"name3": "古风仙侠录",
|
||||
"desc3": "修仙世界的恩怨情仇",
|
||||
"name4": "都市奇缘",
|
||||
"desc4": "现代都市中的奇妙遭遇",
|
||||
"name5": "末日旅人",
|
||||
"desc5": "后末日世界的生存之旅"
|
||||
},
|
||||
"variantNames": {
|
||||
"v1": "网格卡片",
|
||||
"v2": "横向滚动",
|
||||
"v3": "紧凑列表",
|
||||
"v4": "突出首项",
|
||||
"v5": "极简列表"
|
||||
},
|
||||
"variantDescs": {
|
||||
"v1": "标准5列网格,系统真实卡片风格",
|
||||
"v2": "一行横滚大卡片,可滑动查看",
|
||||
"v3": "单行式列表,信息左右分布",
|
||||
"v4": "首个项目大卡片+右侧小卡片网格",
|
||||
"v5": "和输入框同宽的极简圆点行"
|
||||
},
|
||||
"episodes": "章节",
|
||||
"images": "图片",
|
||||
"videos": "视频",
|
||||
"updated": "更新于",
|
||||
"ago": {
|
||||
"justNow": "刚刚",
|
||||
"minutesAgo": "{n}分钟前",
|
||||
"hoursAgo": "{n}小时前",
|
||||
"daysAgo": "{n}天前"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { signIn } from "next-auth/react"
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Navbar from "@/components/Navbar"
|
||||
import { Link, useRouter } from '@/i18n/navigation'
|
||||
import { buildAuthenticatedHomeTarget } from '@/lib/home/default-route'
|
||||
|
||||
export default function SignIn() {
|
||||
const [username, setUsername] = useState("")
|
||||
@@ -31,7 +32,7 @@ export default function SignIn() {
|
||||
} else if (result?.error) {
|
||||
setError(t('loginFailed'))
|
||||
} else {
|
||||
router.push({ pathname: '/' })
|
||||
router.push(buildAuthenticatedHomeTarget())
|
||||
router.refresh()
|
||||
}
|
||||
} catch {
|
||||
|
||||
72
src/app/[locale]/dev/workspace-redesign/InlineSelector.tsx
Normal file
72
src/app/[locale]/dev/workspace-redesign/InlineSelector.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
/**
|
||||
* 内嵌下拉选择器
|
||||
* 显示为紧凑的标签按钮,点击展开向上弹出选项列表
|
||||
*/
|
||||
export function InlineSelector({
|
||||
label,
|
||||
selectedId,
|
||||
options,
|
||||
onSelect,
|
||||
renderLabel,
|
||||
}: {
|
||||
label: string
|
||||
selectedId: string
|
||||
options: { id: string; labelKey: string; emoji?: string }[]
|
||||
onSelect: (id: string) => void
|
||||
renderLabel: (opt: { id: string; labelKey: string; emoji?: string }) => string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const selected = options.find((o) => o.id === selectedId)
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`inline-flex items-center gap-1 text-[11px] font-medium px-2.5 py-1.5 rounded-lg border transition-all duration-200 cursor-pointer ${
|
||||
open
|
||||
? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-bg-muted)] text-[var(--glass-text-primary)]'
|
||||
: 'border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] hover:border-[var(--glass-stroke-strong)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[9px] text-[var(--glass-text-tertiary)] font-semibold">{label}:</span>
|
||||
<span>{selected ? renderLabel(selected) : ''}</span>
|
||||
<AppIcon name="chevronDown" className={`w-2.5 h-2.5 text-[var(--glass-text-tertiary)] transition-transform duration-150 ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 z-50 glass-surface-modal p-1 min-w-[130px] animate-scale-in shadow-lg">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => { onSelect(opt.id); setOpen(false) }}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-md text-[11px] font-medium transition-all cursor-pointer ${
|
||||
selectedId === opt.id
|
||||
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]'
|
||||
: 'text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]'
|
||||
}`}
|
||||
>
|
||||
{renderLabel(opt)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
src/app/[locale]/dev/workspace-redesign/ProjectLayouts.tsx
Normal file
239
src/app/[locale]/dev/workspace-redesign/ProjectLayouts.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 最近项目布局组件集
|
||||
* 5 种不同的排版方式,使用系统实际的卡片设计风格
|
||||
*/
|
||||
import { AppIcon, IconGradientDefs } from '@/components/ui/icons'
|
||||
import type { MockProject } from './shared'
|
||||
import { formatTimeAgo } from './shared'
|
||||
|
||||
/** 通用的项目统计行 — 模仿系统真实卡片中的渐变统计 */
|
||||
function ProjectStats({ project, t }: { project: MockProject; t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<IconGradientDefs className="w-0 h-0 absolute" aria-hidden="true" />
|
||||
<AppIcon name="statsBarGradient" className="w-4 h-4 flex-shrink-0" />
|
||||
<div className="flex items-center gap-3 text-sm font-semibold bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent">
|
||||
{project.stats.episodes > 0 && (
|
||||
<span className="flex items-center gap-1" title={t('episodes')}>
|
||||
<AppIcon name="statsEpisodeGradient" className="w-3.5 h-3.5" />
|
||||
{project.stats.episodes}
|
||||
</span>
|
||||
)}
|
||||
{project.stats.images > 0 && (
|
||||
<span className="flex items-center gap-1" title={t('images')}>
|
||||
<AppIcon name="statsImageGradient" className="w-3.5 h-3.5" />
|
||||
{project.stats.images}
|
||||
</span>
|
||||
)}
|
||||
{project.stats.videos > 0 && (
|
||||
<span className="flex items-center gap-1" title={t('videos')}>
|
||||
<AppIcon name="statsVideoGradient" className="w-3.5 h-3.5" />
|
||||
{project.stats.videos}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 排版1: 网格卡片
|
||||
* 标准 5 列网格,卡片内容模仿系统真实结构(标题+描述+统计+时间)
|
||||
*/
|
||||
export function LayoutGrid({ projects, t }: { projects: MockProject[]; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-10 pb-8 max-w-[1400px] mx-auto w-full">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-sm font-semibold text-[var(--glass-text-secondary)]">{t('recentProjects')}</h2>
|
||||
<button className="text-xs text-[var(--glass-tone-info-fg)] hover:underline font-medium">{t('viewAll')}</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="glass-surface cursor-pointer group hover:border-[var(--glass-tone-info-fg)]/40 transition-all duration-300 overflow-hidden relative">
|
||||
<div className="absolute inset-0 rounded-[inherit] bg-gradient-to-br from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
<div className="p-5 relative z-10">
|
||||
<h3 className="text-sm font-bold text-[var(--glass-text-primary)] mb-2 group-hover:text-[var(--glass-tone-info-fg)] transition-colors line-clamp-1">
|
||||
{p.name}
|
||||
</h3>
|
||||
<div className="flex items-start gap-2 mb-3">
|
||||
<AppIcon name="fileText" className="w-3.5 h-3.5 text-[var(--glass-text-tertiary)] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-[var(--glass-text-secondary)] line-clamp-2 leading-relaxed">{p.description}</p>
|
||||
</div>
|
||||
<ProjectStats project={p} t={t} />
|
||||
<div className="flex items-center gap-1 mt-3 text-[10px] text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="clock" className="w-3 h-3" />
|
||||
{formatTimeAgo(p.updatedAt, t)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 排版2: 横向滚动
|
||||
* 一排横滚大卡片,更有沉浸感
|
||||
*/
|
||||
export function LayoutScroll({ projects, t }: { projects: MockProject[]; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-10 pb-8 max-w-[1400px] mx-auto w-full">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-sm font-semibold text-[var(--glass-text-secondary)]">{t('recentProjects')}</h2>
|
||||
<button className="text-xs text-[var(--glass-tone-info-fg)] hover:underline font-medium">{t('viewAll')}</button>
|
||||
</div>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide snap-x snap-mandatory">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="min-w-[300px] max-w-[300px] snap-start glass-surface cursor-pointer group hover:border-[var(--glass-tone-info-fg)]/40 transition-all duration-300 overflow-hidden relative flex-shrink-0">
|
||||
<div className="absolute inset-0 rounded-[inherit] bg-gradient-to-br from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
<div className="p-5 relative z-10">
|
||||
<h3 className="text-base font-bold text-[var(--glass-text-primary)] mb-2 group-hover:text-[var(--glass-tone-info-fg)] transition-colors line-clamp-1">
|
||||
{p.name}
|
||||
</h3>
|
||||
<div className="flex items-start gap-2 mb-4">
|
||||
<AppIcon name="fileText" className="w-3.5 h-3.5 text-[var(--glass-text-tertiary)] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-[var(--glass-text-secondary)] line-clamp-2 leading-relaxed">{p.description}</p>
|
||||
</div>
|
||||
<ProjectStats project={p} t={t} />
|
||||
<div className="flex items-center gap-1 mt-3 text-[10px] text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="clock" className="w-3 h-3" />
|
||||
{formatTimeAgo(p.updatedAt, t)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 排版3: 紧凑列表
|
||||
* 左右布局的一行式列表,信息紧凑
|
||||
*/
|
||||
export function LayoutList({ projects, t }: { projects: MockProject[]; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-10 pb-8 max-w-3xl mx-auto w-full">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-sm font-semibold text-[var(--glass-text-secondary)]">{t('recentProjects')}</h2>
|
||||
<button className="text-xs text-[var(--glass-tone-info-fg)] hover:underline font-medium">{t('viewAll')}</button>
|
||||
</div>
|
||||
<div className="glass-surface overflow-hidden divide-y divide-[var(--glass-stroke-base)]">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-4 px-5 py-4 cursor-pointer group hover:bg-[var(--glass-bg-muted)] transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-[var(--glass-text-primary)] group-hover:text-[var(--glass-tone-info-fg)] transition-colors line-clamp-1 mb-0.5">
|
||||
{p.name}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)] line-clamp-1">{p.description}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<ProjectStats project={p} t={t} />
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-1 text-[10px] text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="clock" className="w-3 h-3" />
|
||||
{formatTimeAgo(p.updatedAt, t)}
|
||||
</div>
|
||||
<AppIcon name="chevronRight" className="w-4 h-4 text-[var(--glass-text-tertiary)] opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 排版4: 突出首项
|
||||
* 第一个项目大卡片占满左侧,右侧两列堆叠小卡片
|
||||
*/
|
||||
export function LayoutFeatured({ projects, t }: { projects: MockProject[]; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
const [first, ...rest] = projects
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-10 pb-8 max-w-[1400px] mx-auto w-full">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-sm font-semibold text-[var(--glass-text-secondary)]">{t('recentProjects')}</h2>
|
||||
<button className="text-xs text-[var(--glass-tone-info-fg)] hover:underline font-medium">{t('viewAll')}</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* 大卡片 */}
|
||||
{first && (
|
||||
<div className="lg:col-span-1 glass-surface cursor-pointer group hover:border-[var(--glass-tone-info-fg)]/40 transition-all duration-300 overflow-hidden relative">
|
||||
<div className="absolute inset-0 rounded-[inherit] bg-gradient-to-br from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
<div className="p-6 relative z-10 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div className="glass-chip glass-chip-info text-[10px] mb-3 w-fit">{t('latestUpdate')}</div>
|
||||
<h3 className="text-lg font-bold text-[var(--glass-text-primary)] mb-2 group-hover:text-[var(--glass-tone-info-fg)] transition-colors">
|
||||
{first.name}
|
||||
</h3>
|
||||
<div className="flex items-start gap-2 mb-4">
|
||||
<AppIcon name="fileText" className="w-3.5 h-3.5 text-[var(--glass-text-tertiary)] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-[var(--glass-text-secondary)] leading-relaxed">{first.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ProjectStats project={first} t={t} />
|
||||
<div className="flex items-center gap-1 mt-3 text-[10px] text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="clock" className="w-3 h-3" />
|
||||
{formatTimeAgo(first.updatedAt, t)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 小卡片网格 */}
|
||||
<div className="lg:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{rest.map((p) => (
|
||||
<div key={p.id} className="glass-surface cursor-pointer group hover:border-[var(--glass-tone-info-fg)]/40 transition-all duration-300 overflow-hidden relative">
|
||||
<div className="absolute inset-0 rounded-[inherit] bg-gradient-to-br from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
<div className="p-4 relative z-10">
|
||||
<h3 className="text-sm font-bold text-[var(--glass-text-primary)] mb-1 group-hover:text-[var(--glass-tone-info-fg)] transition-colors line-clamp-1">
|
||||
{p.name}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)] line-clamp-1 mb-3">{p.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<ProjectStats project={p} t={t} />
|
||||
<span className="text-[10px] text-[var(--glass-text-tertiary)]">{formatTimeAgo(p.updatedAt, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 排版5: 极简圆点列表
|
||||
* 和输入框同宽的极简列表,仅显示项目名和关键数据
|
||||
*/
|
||||
export function LayoutMinimalList({ projects, t }: { projects: MockProject[]; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
return (
|
||||
<div className="px-4 pb-8 max-w-3xl mx-auto w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xs font-semibold text-[var(--glass-text-tertiary)] uppercase tracking-wider">{t('recentProjects')}</h2>
|
||||
<button className="text-xs text-[var(--glass-tone-info-fg)] hover:underline font-medium">{t('viewAll')}</button>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between py-3 px-4 rounded-xl hover:bg-[var(--glass-bg-muted)] cursor-pointer transition-colors group">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<span className="w-2 h-2 rounded-full bg-gradient-to-br from-blue-500 to-cyan-500 group-hover:scale-125 transition-transform flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-[var(--glass-text-primary)] group-hover:text-[var(--glass-tone-info-fg)] transition-colors truncate">{p.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--glass-text-tertiary)] flex-shrink-0">
|
||||
<span>{p.stats.episodes} {t('episodes')}</span>
|
||||
<span>{p.stats.images} {t('images')}</span>
|
||||
<span className="hidden sm:inline">{formatTimeAgo(p.updatedAt, t)}</span>
|
||||
<AppIcon name="chevronRight" className="w-3.5 h-3.5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
src/app/[locale]/dev/workspace-redesign/VariantClearBreath.tsx
Normal file
147
src/app/[locale]/dev/workspace-redesign/VariantClearBreath.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 清澈呼吸 — 输入区域
|
||||
* Apple 风格呼吸光晕 + 下拉标签选项
|
||||
* 底部排版由 page.tsx 注入
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { InlineSelector } from './InlineSelector'
|
||||
import {
|
||||
STYLE_OPTIONS,
|
||||
RATIO_OPTIONS,
|
||||
QUALITY_OPTIONS,
|
||||
} from './shared'
|
||||
|
||||
export default function VariantClearBreath({ children }: { children?: React.ReactNode }) {
|
||||
const t = useTranslations('workspaceRedesign')
|
||||
const [selectedStyle, setSelectedStyle] = useState('anime')
|
||||
const [selectedRatio, setSelectedRatio] = useState('16:9')
|
||||
const [selectedQuality, setSelectedQuality] = useState('high')
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-120px)] flex flex-col">
|
||||
{/* 自定义呼吸动画 */}
|
||||
<style>{`
|
||||
@keyframes breathe-drift-1 {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
25% {
|
||||
transform: translate(30px, -20px) scale(1.15);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-20px, 15px) scale(0.95);
|
||||
opacity: 0.4;
|
||||
}
|
||||
75% {
|
||||
transform: translate(15px, 25px) scale(1.1);
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
@keyframes breathe-drift-2 {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 0.45;
|
||||
}
|
||||
30% {
|
||||
transform: translate(-25px, 20px) scale(1.2);
|
||||
opacity: 0.7;
|
||||
}
|
||||
60% {
|
||||
transform: translate(20px, -15px) scale(0.9);
|
||||
opacity: 0.35;
|
||||
}
|
||||
80% {
|
||||
transform: translate(-10px, -25px) scale(1.05);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
@keyframes breathe-drift-3 {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1.05);
|
||||
opacity: 0.4;
|
||||
}
|
||||
20% {
|
||||
transform: translate(20px, 15px) scale(0.9);
|
||||
opacity: 0.55;
|
||||
}
|
||||
45% {
|
||||
transform: translate(-15px, -20px) scale(1.15);
|
||||
opacity: 0.7;
|
||||
}
|
||||
70% {
|
||||
transform: translate(10px, -10px) scale(1);
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="flex flex-col items-center pt-[18vh] pb-12 px-4 max-w-3xl mx-auto w-full">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-3xl font-bold text-[var(--glass-text-primary)] mb-2">
|
||||
✨ {t('quickActions.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('inputPlaceholder')}</p>
|
||||
</div>
|
||||
|
||||
{/* 呼吸光晕容器 */}
|
||||
<div className="w-full relative group">
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 80% 60% at 30% 40%, rgba(6, 182, 212, 0.4), transparent 70%)',
|
||||
animation: 'breathe-drift-1 8s ease-in-out infinite',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 70% 80% at 70% 60%, rgba(139, 92, 246, 0.35), transparent 70%)',
|
||||
animation: 'breathe-drift-2 10s ease-in-out infinite',
|
||||
filter: 'blur(35px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-[56px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(59, 130, 246, 0.3), transparent 70%)',
|
||||
animation: 'breathe-drift-3 12s ease-in-out infinite',
|
||||
filter: 'blur(40px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative w-full glass-surface-elevated rounded-2xl overflow-hidden">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
rows={4}
|
||||
className="w-full bg-transparent border-none outline-none text-[var(--glass-text-primary)] placeholder:text-[var(--glass-text-tertiary)] text-base resize-none p-5 pb-2"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 px-5 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<InlineSelector label={t('style')} selectedId={selectedStyle} options={STYLE_OPTIONS} onSelect={setSelectedStyle} renderLabel={(o) => `${o.emoji ?? ''} ${t(o.labelKey)}`} />
|
||||
<InlineSelector label={t('ratio')} selectedId={selectedRatio} options={RATIO_OPTIONS} onSelect={setSelectedRatio} renderLabel={(o) => t(o.labelKey)} />
|
||||
<InlineSelector label={t('quality')} selectedId={selectedQuality} options={QUALITY_OPTIONS} onSelect={setSelectedQuality} renderLabel={(o) => t(o.labelKey)} />
|
||||
</div>
|
||||
<button className="glass-btn-base glass-btn-primary px-5 py-2 text-sm flex-shrink-0">
|
||||
{t('startCreation')}
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部排版内容 — 由外部注入 */}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
src/app/[locale]/dev/workspace-redesign/page.tsx
Normal file
106
src/app/[locale]/dev/workspace-redesign/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import type { VariantKey } from './shared'
|
||||
import { createMockProjects } from './shared'
|
||||
import VariantClearBreath from './VariantClearBreath'
|
||||
import { LayoutGrid, LayoutScroll, LayoutList, LayoutFeatured, LayoutMinimalList } from './ProjectLayouts'
|
||||
|
||||
const LAYOUTS: { key: VariantKey; nameKey: string; descKey: string }[] = [
|
||||
{ key: 'v1', nameKey: 'variantNames.v1', descKey: 'variantDescs.v1' },
|
||||
{ key: 'v2', nameKey: 'variantNames.v2', descKey: 'variantDescs.v2' },
|
||||
{ key: 'v3', nameKey: 'variantNames.v3', descKey: 'variantDescs.v3' },
|
||||
{ key: 'v4', nameKey: 'variantNames.v4', descKey: 'variantDescs.v4' },
|
||||
{ key: 'v5', nameKey: 'variantNames.v5', descKey: 'variantDescs.v5' },
|
||||
]
|
||||
|
||||
export default function WorkspaceRedesignPage() {
|
||||
const t = useTranslations('workspaceRedesign')
|
||||
const [currentLayout, setCurrentLayout] = useState<VariantKey>('v1')
|
||||
const mockProjects = useMemo(() => createMockProjects(t), [t])
|
||||
|
||||
const currentInfo = LAYOUTS.find((l) => l.key === currentLayout)
|
||||
|
||||
const renderLayout = () => {
|
||||
switch (currentLayout) {
|
||||
case 'v1': return <LayoutGrid projects={mockProjects} t={t} />
|
||||
case 'v2': return <LayoutScroll projects={mockProjects} t={t} />
|
||||
case 'v3': return <LayoutList projects={mockProjects} t={t} />
|
||||
case 'v4': return <LayoutFeatured projects={mockProjects} t={t} />
|
||||
case 'v5': return <LayoutMinimalList projects={mockProjects} t={t} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-page min-h-screen">
|
||||
{/* 排版切换器 */}
|
||||
<div className="sticky top-16 z-40 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-canvas)]">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-10 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h1 className="text-sm font-bold text-[var(--glass-text-primary)] flex-shrink-0">
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
{currentInfo && (
|
||||
<span className="glass-chip glass-chip-info text-[10px] hidden sm:inline-flex">
|
||||
{t(currentInfo.nameKey)} — {t(currentInfo.descKey)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{LAYOUTS.map((l) => (
|
||||
<button
|
||||
key={l.key}
|
||||
onClick={() => setCurrentLayout(l.key)}
|
||||
title={`${t(l.nameKey)}: ${t(l.descKey)}`}
|
||||
className={`glass-btn-base px-2.5 py-1.5 text-[11px] transition-all duration-200 whitespace-nowrap ${
|
||||
currentLayout === l.key
|
||||
? 'glass-btn-primary'
|
||||
: 'glass-btn-ghost hover:bg-[var(--glass-bg-muted)]'
|
||||
}`}
|
||||
>
|
||||
{t(l.nameKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 共用的输入框 + 可切换的底部排版 */}
|
||||
<VariantClearBreath key={currentLayout}>
|
||||
{renderLayout()}
|
||||
</VariantClearBreath>
|
||||
|
||||
{/* 翻页按钮 */}
|
||||
<div className="fixed bottom-4 right-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const idx = LAYOUTS.findIndex((l) => l.key === currentLayout)
|
||||
if (idx > 0) setCurrentLayout(LAYOUTS[idx - 1].key)
|
||||
}}
|
||||
disabled={currentLayout === 'v1'}
|
||||
className="glass-btn-base glass-btn-secondary p-2 disabled:opacity-30"
|
||||
>
|
||||
<AppIcon name="chevronLeft" className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-[var(--glass-text-tertiary)]">
|
||||
{LAYOUTS.findIndex((l) => l.key === currentLayout) + 1}/{LAYOUTS.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const idx = LAYOUTS.findIndex((l) => l.key === currentLayout)
|
||||
if (idx < LAYOUTS.length - 1) setCurrentLayout(LAYOUTS[idx + 1].key)
|
||||
}}
|
||||
disabled={currentLayout === 'v5'}
|
||||
className="glass-btn-base glass-btn-secondary p-2 disabled:opacity-30"
|
||||
>
|
||||
<AppIcon name="chevronRight" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
src/app/[locale]/dev/workspace-redesign/shared.ts
Normal file
108
src/app/[locale]/dev/workspace-redesign/shared.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/** Mock data and shared types for workspace redesign test page */
|
||||
|
||||
export interface MockProject {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
updatedAt: string
|
||||
stats: {
|
||||
episodes: number
|
||||
images: number
|
||||
videos: number
|
||||
}
|
||||
}
|
||||
|
||||
export type VariantKey = 'v1' | 'v2' | 'v3' | 'v4' | 'v5'
|
||||
|
||||
export interface StyleOption {
|
||||
id: string
|
||||
labelKey: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
export interface RatioOption {
|
||||
id: string
|
||||
labelKey: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface QualityOption {
|
||||
id: string
|
||||
labelKey: string
|
||||
}
|
||||
|
||||
export const STYLE_OPTIONS: StyleOption[] = [
|
||||
{ id: 'anime', labelKey: 'styles.anime', emoji: '🎌' },
|
||||
{ id: 'realistic', labelKey: 'styles.realistic', emoji: '📷' },
|
||||
{ id: 'watercolor', labelKey: 'styles.watercolor', emoji: '🎨' },
|
||||
{ id: 'cyberpunk', labelKey: 'styles.cyberpunk', emoji: '🌃' },
|
||||
{ id: 'ghibli', labelKey: 'styles.ghibli', emoji: '🌿' },
|
||||
{ id: 'ink', labelKey: 'styles.ink', emoji: '🖌️' },
|
||||
]
|
||||
|
||||
export const RATIO_OPTIONS: RatioOption[] = [
|
||||
{ id: '16:9', labelKey: 'ratios.r16_9', icon: '▬' },
|
||||
{ id: '9:16', labelKey: 'ratios.r9_16', icon: '▮' },
|
||||
{ id: '1:1', labelKey: 'ratios.r1_1', icon: '■' },
|
||||
{ id: '4:3', labelKey: 'ratios.r4_3', icon: '▭' },
|
||||
]
|
||||
|
||||
export const QUALITY_OPTIONS: QualityOption[] = [
|
||||
{ id: 'standard', labelKey: 'qualities.standard' },
|
||||
{ id: 'high', labelKey: 'qualities.high' },
|
||||
{ id: 'ultra', labelKey: 'qualities.ultra' },
|
||||
]
|
||||
|
||||
export function createMockProjects(t: (key: string) => string): MockProject[] {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
name: t('mockProject.name1'),
|
||||
description: t('mockProject.desc1'),
|
||||
updatedAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
|
||||
stats: { episodes: 5, images: 48, videos: 12 },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: t('mockProject.name2'),
|
||||
description: t('mockProject.desc2'),
|
||||
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
|
||||
stats: { episodes: 3, images: 24, videos: 6 },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: t('mockProject.name3'),
|
||||
description: t('mockProject.desc3'),
|
||||
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 8).toISOString(),
|
||||
stats: { episodes: 8, images: 96, videos: 20 },
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: t('mockProject.name4'),
|
||||
description: t('mockProject.desc4'),
|
||||
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
|
||||
stats: { episodes: 2, images: 16, videos: 4 },
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: t('mockProject.name5'),
|
||||
description: t('mockProject.desc5'),
|
||||
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
stats: { episodes: 4, images: 32, videos: 8 },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function formatTimeAgo(dateString: string, t: (key: string, params?: Record<string, string | number>) => string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMinutes < 1) return t('ago.justNow')
|
||||
if (diffMinutes < 60) return t('ago.minutesAgo', { n: diffMinutes })
|
||||
if (diffHours < 24) return t('ago.hoursAgo', { n: diffHours })
|
||||
return t('ago.daysAgo', { n: diffDays })
|
||||
}
|
||||
373
src/app/[locale]/home/page.tsx
Normal file
373
src/app/[locale]/home/page.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 首页 - 创作中心
|
||||
* 用户登录后的主入口页面:快速创作 + 最近项目
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Navbar from '@/components/Navbar'
|
||||
import { AppIcon, IconGradientDefs } from '@/components/ui/icons'
|
||||
import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
||||
import { Link, useRouter } from '@/i18n/navigation'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
import { createHomeProjectLaunch } from '@/lib/home/create-project-launch'
|
||||
import {
|
||||
HOME_QUICK_START_MIN_ROWS,
|
||||
resolveTextareaTargetHeight,
|
||||
} from '@/lib/home/quick-start-textarea'
|
||||
|
||||
interface ProjectStats {
|
||||
episodes: number
|
||||
images: number
|
||||
videos: number
|
||||
panels: number
|
||||
firstEpisodePreview: string | null
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
stats?: ProjectStats
|
||||
}
|
||||
|
||||
const RECENT_COUNT = 5
|
||||
|
||||
export default function HomePage() {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('home')
|
||||
const tc = useTranslations('common')
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [videoRatio, setVideoRatio] = useState('9:16')
|
||||
const [artStyle, setArtStyle] = useState('american-comic')
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const textareaMinHeightRef = useRef<number | null>(null)
|
||||
|
||||
// textarea 自适应高度(rAF 分帧动画)
|
||||
const autoResizeTextarea = useCallback(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
const maxH = window.innerHeight * 0.5
|
||||
const oldH = el.offsetHeight
|
||||
const oldScrollTop = el.scrollTop
|
||||
if (textareaMinHeightRef.current === null && oldH > 0) {
|
||||
textareaMinHeightRef.current = oldH
|
||||
}
|
||||
const minH = textareaMinHeightRef.current ?? oldH
|
||||
|
||||
// 同步:测量真实高度(不改 overflow,避免 scrollTop 被重置)
|
||||
el.style.transition = 'none'
|
||||
el.style.height = 'auto'
|
||||
const scrollH = el.scrollHeight
|
||||
const targetH = resolveTextareaTargetHeight({
|
||||
minHeight: minH,
|
||||
maxHeight: maxH,
|
||||
scrollHeight: scrollH,
|
||||
})
|
||||
el.style.height = `${oldH}px`
|
||||
el.scrollTop = oldScrollTop
|
||||
|
||||
// 下一帧:开启 transition → 动画到目标高度
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = oldScrollTop
|
||||
el.style.transition = 'height 200ms ease-out'
|
||||
el.style.height = `${targetH}px`
|
||||
el.style.overflowY = scrollH > maxH ? 'auto' : 'hidden'
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
autoResizeTextarea()
|
||||
}, [inputValue, autoResizeTextarea])
|
||||
|
||||
// 鉴权
|
||||
useEffect(() => {
|
||||
if (status === 'loading') return
|
||||
if (!session) {
|
||||
router.push({ pathname: '/auth/signin' })
|
||||
}
|
||||
}, [session, status, router])
|
||||
|
||||
// 获取最近项目
|
||||
const fetchRecentProjects = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
page: '1',
|
||||
pageSize: RECENT_COUNT.toString(),
|
||||
})
|
||||
const response = await apiFetch(`/api/projects?${params}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setProjects(data.projects)
|
||||
}
|
||||
} catch {
|
||||
// 静默处理
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
void fetchRecentProjects()
|
||||
}
|
||||
}, [session, fetchRecentProjects])
|
||||
|
||||
// 创建项目并跳转
|
||||
const handleCreate = async () => {
|
||||
if (!inputValue.trim() || createLoading) return
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const storyText = inputValue.trim()
|
||||
const result = await createHomeProjectLaunch({
|
||||
apiFetch,
|
||||
projectName: storyText.slice(0, 50),
|
||||
storyText,
|
||||
videoRatio,
|
||||
artStyle,
|
||||
episodeName: `${tc('episode')} 1`,
|
||||
})
|
||||
|
||||
router.push(result.target)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('createFailed')
|
||||
window.alert(message)
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 比例选项(带推荐标签)
|
||||
const ratioOptions = useMemo(
|
||||
() => VIDEO_RATIOS.map((r) => ({ ...r, recommended: r.value === '9:16' })),
|
||||
[]
|
||||
)
|
||||
|
||||
// 风格选项(带推荐标签)
|
||||
const styleOptions = useMemo(
|
||||
() => ART_STYLES.map((s) => ({ ...s, recommended: s.value === 'realistic' })),
|
||||
[]
|
||||
)
|
||||
|
||||
// 时间格式化
|
||||
const formatTimeAgo = (dateString: string): string => {
|
||||
const diffMs = Date.now() - new Date(dateString).getTime()
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
if (diffMinutes < 1) return t('ago.justNow')
|
||||
if (diffMinutes < 60) return t('ago.minutesAgo', { n: diffMinutes })
|
||||
if (diffHours < 24) return t('ago.hoursAgo', { n: diffHours })
|
||||
return t('ago.daysAgo', { n: diffDays })
|
||||
}
|
||||
|
||||
if (status === 'loading' || !session) {
|
||||
return (
|
||||
<div className="glass-page min-h-screen flex items-center justify-center">
|
||||
<div className="text-[var(--glass-text-secondary)]">{tc('loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-page min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
{/* 自定义呼吸动画 */}
|
||||
<style>{`
|
||||
@keyframes breathe-drift-1 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.5; }
|
||||
25% { transform: translate(30px, -20px) scale(1.15); opacity: 0.7; }
|
||||
50% { transform: translate(-20px, 15px) scale(0.95); opacity: 0.4; }
|
||||
75% { transform: translate(15px, 25px) scale(1.1); opacity: 0.65; }
|
||||
}
|
||||
@keyframes breathe-drift-2 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.45; }
|
||||
30% { transform: translate(-25px, 20px) scale(1.2); opacity: 0.7; }
|
||||
60% { transform: translate(20px, -15px) scale(0.9); opacity: 0.35; }
|
||||
80% { transform: translate(-10px, -25px) scale(1.05); opacity: 0.6; }
|
||||
}
|
||||
@keyframes breathe-drift-3 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1.05); opacity: 0.4; }
|
||||
20% { transform: translate(20px, 15px) scale(0.9); opacity: 0.55; }
|
||||
45% { transform: translate(-15px, -20px) scale(1.15); opacity: 0.7; }
|
||||
70% { transform: translate(10px, -10px) scale(1); opacity: 0.35; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<main className="flex flex-col items-center pt-[16vh] pb-12 px-4 max-w-3xl mx-auto w-full">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-3xl font-bold text-[var(--glass-text-primary)] mb-2">
|
||||
✨ {t('title')}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* 呼吸光晕 + 输入区域 */}
|
||||
<div className="w-full relative group">
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 80% 60% at 30% 40%, rgba(6, 182, 212, 0.4), transparent 70%)',
|
||||
animation: 'breathe-drift-1 8s ease-in-out infinite',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 70% 80% at 70% 60%, rgba(139, 92, 246, 0.35), transparent 70%)',
|
||||
animation: 'breathe-drift-2 10s ease-in-out infinite',
|
||||
filter: 'blur(35px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-[56px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(59, 130, 246, 0.3), transparent 70%)',
|
||||
animation: 'breathe-drift-3 12s ease-in-out infinite',
|
||||
filter: 'blur(40px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative w-full glass-surface-elevated rounded-2xl">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
rows={HOME_QUICK_START_MIN_ROWS}
|
||||
className="w-full bg-transparent border-none outline-none text-[var(--glass-text-primary)] placeholder:text-[var(--glass-text-tertiary)] text-base resize-none p-5 pb-3 custom-scrollbar"
|
||||
/>
|
||||
|
||||
{/* 底部工具栏:比例 + 风格 + 创建按钮 */}
|
||||
<div className="flex items-end gap-3 px-5 pb-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={setVideoRatio}
|
||||
options={ratioOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={setArtStyle}
|
||||
options={styleOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!inputValue.trim() || createLoading}
|
||||
className="glass-btn-base glass-btn-primary px-5 py-2.5 text-sm flex-shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{createLoading ? tc('loading') : t('startCreation')}
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 最近项目 */}
|
||||
<section className="px-4 sm:px-6 lg:px-10 pb-8 max-w-[1400px] mx-auto w-full">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-sm font-semibold text-[var(--glass-text-secondary)]">{t('recentProjects')}</h2>
|
||||
<Link
|
||||
href={{ pathname: '/workspace' }}
|
||||
className="text-xs text-[var(--glass-tone-info-fg)] hover:underline font-medium"
|
||||
>
|
||||
{t('viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="glass-surface p-5 animate-pulse">
|
||||
<div className="h-4 bg-[var(--glass-bg-muted)] rounded mb-3" />
|
||||
<div className="h-3 bg-[var(--glass-bg-muted)] rounded mb-2" />
|
||||
<div className="h-3 bg-[var(--glass-bg-muted)] rounded w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-12 h-12 bg-[var(--glass-bg-muted)] rounded-xl flex items-center justify-center mx-auto mb-3">
|
||||
<AppIcon name="folderCards" className="w-6 h-6 text-[var(--glass-text-tertiary)]" />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('noProjects')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{projects.map((project) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={{ pathname: `/workspace/${project.id}` }}
|
||||
className="glass-surface cursor-pointer group hover:border-[var(--glass-tone-info-fg)]/40 transition-all duration-300 overflow-hidden relative block"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-[inherit] bg-gradient-to-br from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
<div className="p-5 relative z-10">
|
||||
<h3 className="text-sm font-bold text-[var(--glass-text-primary)] mb-2 group-hover:text-[var(--glass-tone-info-fg)] transition-colors line-clamp-1">
|
||||
{project.name}
|
||||
</h3>
|
||||
{(project.description || project.stats?.firstEpisodePreview) && (
|
||||
<div className="flex items-start gap-2 mb-3">
|
||||
<AppIcon name="fileText" className="w-3.5 h-3.5 text-[var(--glass-text-tertiary)] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-[var(--glass-text-secondary)] line-clamp-2 leading-relaxed">
|
||||
{project.description || project.stats?.firstEpisodePreview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{project.stats && (project.stats.episodes > 0 || project.stats.images > 0 || project.stats.videos > 0) && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<IconGradientDefs className="w-0 h-0 absolute" aria-hidden="true" />
|
||||
<AppIcon name="statsBarGradient" className="w-4 h-4 flex-shrink-0" />
|
||||
<div className="flex items-center gap-3 text-sm font-semibold bg-gradient-to-r from-blue-500 to-cyan-500 bg-clip-text text-transparent">
|
||||
{project.stats.episodes > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<AppIcon name="statsEpisodeGradient" className="w-3.5 h-3.5" />
|
||||
{project.stats.episodes}
|
||||
</span>
|
||||
)}
|
||||
{project.stats.images > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<AppIcon name="statsImageGradient" className="w-3.5 h-3.5" />
|
||||
{project.stats.images}
|
||||
</span>
|
||||
)}
|
||||
{project.stats.videos > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<AppIcon name="statsVideoGradient" className="w-3.5 h-3.5" />
|
||||
{project.stats.videos}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="clock" className="w-3 h-3" />
|
||||
{formatTimeAgo(project.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,16 +7,17 @@ import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
import Navbar from '@/components/Navbar'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { buildAuthenticatedHomeTarget } from '@/lib/home/default-route'
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations('landing')
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
// 已登录用户自动跳转到 workspace
|
||||
// 已登录用户自动跳转到 home
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
router.replace({ pathname: '/workspace' })
|
||||
router.replace(buildAuthenticatedHomeTarget())
|
||||
}
|
||||
}, [status, router])
|
||||
|
||||
|
||||
@@ -1,12 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import NovelInputStage from './NovelInputStage'
|
||||
import SmartImportWizard from './SmartImportWizard'
|
||||
import { useWorkspaceStageRuntime } from '../WorkspaceStageRuntimeContext'
|
||||
import { useWorkspaceEpisodeStageData } from '../hooks/useWorkspaceEpisodeStageData'
|
||||
import type { SplitEpisode } from './smart-import/types'
|
||||
|
||||
/**
|
||||
* 配置阶段 — 整合 NovelInputStage + 长文本智能分集
|
||||
*
|
||||
* 当用户输入长文本(>1000字)并点击"开始创作"时,
|
||||
* 弹出引导卡片建议使用智能分集。
|
||||
* 选择"智能分集"后,直接进入 SmartImportWizard 的分析流程。
|
||||
*/
|
||||
export default function ConfigStage() {
|
||||
const runtime = useWorkspaceStageRuntime()
|
||||
const { episodeName, novelText } = useWorkspaceEpisodeStageData()
|
||||
const params = useParams<{ projectId: string }>()
|
||||
const projectId = params?.projectId ?? ''
|
||||
|
||||
// 智能分集模式
|
||||
const [smartSplitMode, setSmartSplitMode] = useState(false)
|
||||
const [smartSplitText, setSmartSplitText] = useState('')
|
||||
|
||||
const handleSmartSplit = useCallback((text: string) => {
|
||||
setSmartSplitText(text)
|
||||
setSmartSplitMode(true)
|
||||
}, [])
|
||||
|
||||
const handleSmartSplitComplete = useCallback((episodes: SplitEpisode[], triggerGlobalAnalysis?: boolean) => {
|
||||
// 分集完成后,刷新页面以加载新的剧集数据
|
||||
// 通过 window.location.reload 简单处理,因为分集会重新创建所有剧集
|
||||
void episodes
|
||||
void triggerGlobalAnalysis
|
||||
window.location.reload()
|
||||
}, [])
|
||||
|
||||
// 如果已进入智能分集模式,显示 SmartImportWizard
|
||||
if (smartSplitMode) {
|
||||
return (
|
||||
<SmartImportWizard
|
||||
projectId={projectId}
|
||||
onManualCreate={() => setSmartSplitMode(false)}
|
||||
onImportComplete={handleSmartSplitComplete}
|
||||
initialRawContent={smartSplitText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NovelInputStage
|
||||
@@ -20,6 +62,7 @@ export default function ConfigStage() {
|
||||
onVideoRatioChange={runtime.onVideoRatioChange}
|
||||
onArtStyleChange={runtime.onArtStyleChange}
|
||||
onNext={runtime.onRunStoryToScript}
|
||||
onSmartSplit={handleSmartSplit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,184 +6,18 @@
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import '@/styles/animations.css'
|
||||
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon, RatioPreviewIcon } from '@/components/ui/icons'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||
|
||||
/**
|
||||
* RatioIcon - 比例预览图标组件
|
||||
* 需求:所有比例选项的图标永远保持蓝色,帮助用户建立比例视觉记忆
|
||||
*/
|
||||
function RatioIcon({ ratio, size = 24, selected = false }: { ratio: string; size?: number; selected?: boolean }) {
|
||||
// 始终以选中态渲染图标,但仍保留 selected 参数以满足类型与未来扩展
|
||||
return <RatioPreviewIcon ratio={ratio} size={size} selected={selected || true} />
|
||||
}
|
||||
/** 触发智能分集建议的字数阈值 */
|
||||
const LONG_TEXT_THRESHOLD = 1000
|
||||
|
||||
/**
|
||||
* RatioSelector - 比例选择下拉组件
|
||||
*/
|
||||
function RatioSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
getUsage
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: { value: string; label: string; recommended?: boolean }[]
|
||||
getUsage?: (ratio: string) => string
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const t = useTranslations('novelPromotion')
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find(o => o.value === value)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* 触发按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<RatioIcon ratio={value} size={20} selected />
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption?.label || value}</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* 下拉面板 - 横向网格布局 */}
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar" style={{ minWidth: '280px' }}>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-[var(--glass-bg-muted)]/70 transition-colors ${value === option.value
|
||||
? 'bg-[var(--glass-tone-info-bg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<RatioIcon ratio={option.value} size={28} selected={value === option.value} />
|
||||
<span className={`flex flex-col items-center gap-1 text-xs ${value === option.value ? 'text-[var(--glass-tone-info-fg)] font-medium' : 'text-[var(--glass-text-secondary)]'}`}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{option.label}</span>
|
||||
{option.recommended && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-[var(--glass-tone-info-bg)] text-[10px] text-[var(--glass-tone-info-fg)] font-semibold">
|
||||
{t('smartImport.smartImport.recommended')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{getUsage && (
|
||||
<span className="text-[10px] font-normal text-[var(--glass-text-tertiary)] leading-snug text-center">
|
||||
{getUsage(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* StyleSelector - 视觉风格选择抽屉组件
|
||||
*/
|
||||
function StyleSelector({
|
||||
value,
|
||||
onChange,
|
||||
options
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: { value: string; label: string; recommended?: boolean }[]
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const t = useTranslations('novelPromotion')
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find(o => o.value === value) || options[0]
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* 触发按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* 下拉面板 */}
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center p-3 rounded-lg text-left transition-all ${value === option.value
|
||||
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||||
: 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 font-medium text-sm">
|
||||
<span>{option.label}</span>
|
||||
{option.recommended && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-[var(--glass-tone-info-bg)] text-[10px] text-[var(--glass-tone-info-fg)] font-semibold">
|
||||
{t('smartImport.smartImport.recommended')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NovelInputStageProps {
|
||||
// 核心数据
|
||||
@@ -193,6 +27,8 @@ interface NovelInputStageProps {
|
||||
// 回调函数
|
||||
onNovelTextChange: (value: string) => void
|
||||
onNext: () => void
|
||||
/** 触发智能分集流程(携带当前文本) */
|
||||
onSmartSplit?: (text: string) => void
|
||||
// 状态
|
||||
isSubmittingTask?: boolean
|
||||
isSwitchingStage?: boolean
|
||||
@@ -211,6 +47,7 @@ export default function NovelInputStage({
|
||||
episodeName,
|
||||
onNovelTextChange,
|
||||
onNext,
|
||||
onSmartSplit,
|
||||
isSubmittingTask = false,
|
||||
isSwitchingStage = false,
|
||||
enableNarration = false,
|
||||
@@ -257,6 +94,17 @@ export default function NovelInputStage({
|
||||
}
|
||||
|
||||
const hasContent = localText.trim().length > 0
|
||||
const [showLongTextPrompt, setShowLongTextPrompt] = useState(false)
|
||||
|
||||
/** 点击"开始创作"时,先检测文本长度 */
|
||||
const handleStartClick = useCallback(() => {
|
||||
const textLength = localText.trim().length
|
||||
if (textLength > LONG_TEXT_THRESHOLD && onSmartSplit) {
|
||||
setShowLongTextPrompt(true)
|
||||
} else {
|
||||
onNext()
|
||||
}
|
||||
}, [localText, onNext, onSmartSplit])
|
||||
|
||||
// 当前配置展示文案
|
||||
const ratioDisplayLabel = (VIDEO_RATIOS.find((option) => option.value === videoRatio) ?? VIDEO_RATIOS[0])?.label
|
||||
@@ -319,9 +167,9 @@ export default function NovelInputStage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主输入区域 */}
|
||||
<div className="glass-surface-elevated overflow-hidden">
|
||||
<div className="p-6">
|
||||
{/* 主输入区域(含底部工具栏) */}
|
||||
<div className="glass-surface-elevated overflow-hidden relative z-10">
|
||||
<div className="p-6 pb-0">
|
||||
{/* 字数统计 */}
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
<span className="glass-chip glass-chip-neutral text-xs">
|
||||
@@ -335,108 +183,86 @@ export default function NovelInputStage({
|
||||
onChange={handleTextChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
placeholder={`请输入您的剧本或小说内容...
|
||||
|
||||
AI 将根据您的文本智能分析:
|
||||
• 自动识别场景切换
|
||||
• 提取角色对话和动作
|
||||
• 生成分镜脚本
|
||||
|
||||
例如:
|
||||
清晨,阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起,看了一眼床头的闹钟——已经八点了!他猛地跳下床,手忙脚乱地开始穿衣服...`}
|
||||
placeholder={`请输入您的剧本或小说内容...\n\nAI 将根据您的文本智能分析:\n• 自动识别场景切换\n• 提取角色对话和动作\n• 生成分镜脚本\n\n例如:\n清晨,阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起,看了一眼床头的闹钟——已经八点了!他猛地跳下床,手忙脚乱地开始穿衣服...`}
|
||||
className="glass-textarea-base custom-scrollbar h-80 px-4 py-3 text-base resize-none placeholder:text-[var(--glass-text-tertiary)]"
|
||||
disabled={isSubmittingTask || isSwitchingStage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 资产库引导提示 */}
|
||||
<div className="mt-5 p-4 glass-surface-soft">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 glass-surface-soft rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<AppIcon name="folderCards" className="w-5 h-5 text-[var(--glass-text-secondary)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[var(--glass-text-secondary)] mb-1">{t("storyInput.assetLibraryTip.title")}</div>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)] leading-relaxed">
|
||||
{t("storyInput.assetLibraryTip.description")}
|
||||
</p>
|
||||
</div>
|
||||
{/* 底部工具栏:比例 + 风格 + 开始创作(内嵌在输入框卡片内) */}
|
||||
<div className="flex items-end gap-3 px-6 py-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={(value) => onVideoRatioChange?.(value)}
|
||||
options={VIDEO_RATIOS.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === '9:16'
|
||||
}))}
|
||||
getUsage={getRatioUsageTag}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={(value) => onArtStyleChange?.(value)}
|
||||
options={ART_STYLES.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === 'realistic'
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStartClick}
|
||||
disabled={!hasContent || isSubmittingTask || isSwitchingStage}
|
||||
className="glass-btn-base glass-btn-primary px-5 py-2.5 text-sm flex-shrink-0 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSwitchingStage ? (
|
||||
<TaskStatusInline state={stageSwitchingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<span>{t("smartImport.manualCreate.button")}</span>
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 配置提示 */}
|
||||
<div className="px-6 pb-4 space-y-1 text-center">
|
||||
<p className="text-xs text-[var(--glass-text-secondary)]">
|
||||
{t("storyInput.currentConfigSummary", {
|
||||
ratio: ratioDisplayLabel,
|
||||
style: artStyleDisplayLabel
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{t("storyInput.moreConfig")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 资产库引导提示 */}
|
||||
<div className="glass-surface p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 glass-surface-soft rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<AppIcon name="folderCards" className="w-5 h-5 text-[var(--glass-text-secondary)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[var(--glass-text-secondary)] mb-1">{t("storyInput.assetLibraryTip.title")}</div>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)] leading-relaxed">
|
||||
{t("storyInput.assetLibraryTip.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画面比例与视觉风格配置 */}
|
||||
<div className="glass-surface p-6 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 画面比例 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-muted)] tracking-[0.01em]">
|
||||
{t("storyInput.videoRatio")}
|
||||
</h3>
|
||||
<div className="relative inline-flex items-center group">
|
||||
<div className="w-4 h-4 flex items-center justify-center rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-sm">
|
||||
<AppIcon name="info" className="w-3 h-3" />
|
||||
</div>
|
||||
<div className="pointer-events-none absolute left-1/2 top-full mt-2 -translate-x-1/2 opacity-0 translate-y-1 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-150 z-20">
|
||||
<div
|
||||
className="rounded-lg border bg-[var(--glass-bg-surface-strong)]/95 border-[var(--glass-tone-info-bg)] px-3.5 py-2.5 text-xs leading-relaxed text-[var(--glass-text-primary)] shadow-[0_18px_45px_rgba(15,23,42,0.55)] whitespace-pre-wrap"
|
||||
style={{ minWidth: 220 }}
|
||||
>
|
||||
{ratioUsageText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{t("storyInput.videoRatioHint")}
|
||||
</p>
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={(value) => onVideoRatioChange?.(value)}
|
||||
options={VIDEO_RATIOS.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === '9:16'
|
||||
}))}
|
||||
getUsage={getRatioUsageTag}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 视觉风格 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-muted)] tracking-[0.01em]">{t("storyInput.visualStyle")}</h3>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{t("storyInput.visualStyleHint")}
|
||||
</p>
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={(value) => onArtStyleChange?.(value)}
|
||||
options={ART_STYLES.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === 'realistic'
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--glass-text-secondary)] mt-4 text-center">
|
||||
{t("storyInput.currentConfigSummary", {
|
||||
ratio: ratioDisplayLabel,
|
||||
style: artStyleDisplayLabel
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)] mt-1 text-center">
|
||||
{t("storyInput.assetLibraryRatioNote")}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)] mt-1 text-center">
|
||||
{t("storyInput.moreConfig")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 旁白开关 + 操作按钮 */}
|
||||
<div className="glass-surface p-6">
|
||||
{/* 旁白开关 */}
|
||||
{onEnableNarrationChange && (
|
||||
<div className="glass-surface-soft flex items-center justify-between p-4 rounded-xl mb-6">
|
||||
{/* 旁白开关 */}
|
||||
{onEnableNarrationChange && (
|
||||
<div className="glass-surface p-6">
|
||||
<div className="glass-surface-soft flex items-center justify-between p-4 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] font-semibold text-sm">VO</span>
|
||||
<div>
|
||||
@@ -457,27 +283,91 @@ AI 将根据您的文本智能分析:
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 开始创作按钮 */}
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!hasContent || isSubmittingTask || isSwitchingStage}
|
||||
className="glass-btn-base glass-btn-primary w-full py-4 text-white font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSwitchingStage ? (
|
||||
<TaskStatusInline state={stageSwitchingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<span>{t("smartImport.manualCreate.button")}</span>
|
||||
<AppIcon name="arrowRight" className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-center text-xs text-[var(--glass-text-tertiary)] mt-3">
|
||||
{hasContent ? t("storyInput.ready") : t("storyInput.pleaseInput")}
|
||||
</p>
|
||||
</div>
|
||||
{/* 长文本检测 — 智能分集强引导弹窗 */}
|
||||
{showLongTextPrompt && (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg mx-4 relative">
|
||||
{/* 渐变描边外壳 */}
|
||||
<div
|
||||
className="rounded-2xl p-[1.5px]"
|
||||
style={{ background: 'linear-gradient(135deg, #3b82f6, #8b5cf6, #06b6d4)' }}
|
||||
>
|
||||
<div className="glass-surface-modal rounded-2xl p-6 space-y-5">
|
||||
{/* 标题行 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15))' }}
|
||||
>
|
||||
<AppIcon name="sparkles" className="w-5 h-5 text-[#7c3aed]" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">
|
||||
{t('storyInput.longTextDetection.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="text-sm text-[var(--glass-text-secondary)] leading-relaxed">
|
||||
{t('storyInput.longTextDetection.description', { count: localText.trim().length.toLocaleString() })}
|
||||
</p>
|
||||
|
||||
{/* 强烈推荐文案 */}
|
||||
<div
|
||||
className="p-4 rounded-xl text-sm leading-relaxed"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(139,92,246,0.08))' }}
|
||||
>
|
||||
<p
|
||||
className="font-semibold"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{t('storyInput.longTextDetection.strongRecommend')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 按钮区域 */}
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
{/* 智能分集 — 主按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLongTextPrompt(false)
|
||||
onSmartSplit?.(localText)
|
||||
}}
|
||||
className="w-full py-3.5 rounded-xl text-white font-semibold text-base flex items-center justify-center gap-2 transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{ background: 'linear-gradient(135deg, #3b82f6, #7c3aed)' }}
|
||||
>
|
||||
<AppIcon name="sparkles" className="w-5 h-5" />
|
||||
<span>{t('storyInput.longTextDetection.smartSplit')}</span>
|
||||
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
|
||||
{t('storyInput.longTextDetection.smartSplitRecommend')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 直接创作 — 弱化按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLongTextPrompt(false)
|
||||
onNext()
|
||||
}}
|
||||
className="w-full py-2.5 text-sm text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors"
|
||||
>
|
||||
{t('storyInput.longTextDetection.continueAnyway')}
|
||||
<span className="text-xs ml-1 opacity-60">
|
||||
— {t('storyInput.longTextDetection.singleEpisodeWarning')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ interface SmartImportWizardProps {
|
||||
onImportComplete: (episodes: SplitEpisode[], triggerGlobalAnalysis?: boolean) => void
|
||||
projectId: string
|
||||
importStatus?: string | null
|
||||
/** 预填文本:传入后自动跳过选择页,直接开始分析 */
|
||||
initialRawContent?: string
|
||||
}
|
||||
|
||||
export default function SmartImportWizard({
|
||||
@@ -23,9 +25,10 @@ export default function SmartImportWizard({
|
||||
onImportComplete,
|
||||
projectId,
|
||||
importStatus,
|
||||
initialRawContent,
|
||||
}: SmartImportWizardProps) {
|
||||
const t = useTranslations('smartImport')
|
||||
const wizard = useWizardState({ projectId, importStatus, onImportComplete, t })
|
||||
const wizard = useWizardState({ projectId, importStatus, onImportComplete, t, initialRawContent })
|
||||
|
||||
const savingTaskState = wizard.saving
|
||||
? resolveTaskPresentationState({
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { useAnalyzeProjectGlobalAssets } from '@/lib/query/hooks'
|
||||
import { useTaskTargetStateMap, type TaskTargetState } from '@/lib/query/hooks/useTaskTargetStateMap'
|
||||
import { clearTaskTargetOverlay, upsertTaskTargetOverlay } from '@/lib/query/task-target-overlay'
|
||||
import { waitForTaskResult } from '@/lib/task/client'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
type ToastType = 'success' | 'warning' | 'error'
|
||||
|
||||
@@ -22,6 +26,52 @@ interface UseAssetsGlobalActionsParams {
|
||||
|
||||
const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
|
||||
|
||||
type GlobalAnalyzeTaskSnapshot = Pick<TaskTargetState, 'phase' | 'runningTaskId' | 'lastError'> | null
|
||||
|
||||
function isRunningPhase(phase: TaskTargetState['phase'] | null | undefined): boolean {
|
||||
return phase === 'queued' || phase === 'processing'
|
||||
}
|
||||
|
||||
export function isGlobalAnalyzeTaskRunning(taskState: GlobalAnalyzeTaskSnapshot): boolean {
|
||||
return isRunningPhase(taskState?.phase)
|
||||
}
|
||||
|
||||
export function resolveGlobalAnalyzeCompletion(
|
||||
previousRunningTaskId: string | null,
|
||||
taskState: GlobalAnalyzeTaskSnapshot,
|
||||
) {
|
||||
const isRunning = isGlobalAnalyzeTaskRunning(taskState)
|
||||
if (isRunning) {
|
||||
return {
|
||||
status: 'running' as const,
|
||||
finishedTaskId: null,
|
||||
errorMessage: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!previousRunningTaskId) {
|
||||
return {
|
||||
status: 'idle' as const,
|
||||
finishedTaskId: null,
|
||||
errorMessage: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (taskState?.phase === 'failed' || taskState?.lastError) {
|
||||
return {
|
||||
status: 'failed' as const,
|
||||
finishedTaskId: previousRunningTaskId,
|
||||
errorMessage: taskState?.lastError?.message ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'succeeded' as const,
|
||||
finishedTaskId: previousRunningTaskId,
|
||||
errorMessage: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAssetsGlobalActions({
|
||||
projectId,
|
||||
triggerGlobalAnalyze = false,
|
||||
@@ -30,45 +80,114 @@ export function useAssetsGlobalActions({
|
||||
showToast,
|
||||
t,
|
||||
}: UseAssetsGlobalActionsParams) {
|
||||
const queryClient = useQueryClient()
|
||||
const analyzeGlobalAssets = useAnalyzeProjectGlobalAssets(projectId)
|
||||
const [isGlobalAnalyzing, setIsGlobalAnalyzing] = useState(false)
|
||||
const hasTriggeredGlobalAnalyze = useRef(false)
|
||||
const lastRunningTaskIdRef = useRef<string | null>(null)
|
||||
const lastHandledTaskIdRef = useRef<string | null>(null)
|
||||
const isSubmittingRef = useRef(false)
|
||||
const globalAnalyzeTaskStateQuery = useTaskTargetStateMap(
|
||||
projectId,
|
||||
[{
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: projectId,
|
||||
types: ['analyze_global'],
|
||||
}],
|
||||
{
|
||||
enabled: projectId.length > 0,
|
||||
staleTime: 2_000,
|
||||
},
|
||||
)
|
||||
const globalAnalyzeTaskState = globalAnalyzeTaskStateQuery.getState('NovelPromotionProject', projectId)
|
||||
const isGlobalAnalyzing = isGlobalAnalyzeTaskRunning(globalAnalyzeTaskState)
|
||||
|
||||
const globalAnalyzingState = useMemo(() => {
|
||||
if (!isGlobalAnalyzing) return null
|
||||
return resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'generate',
|
||||
phase: globalAnalyzeTaskState?.phase ?? 'processing',
|
||||
intent: globalAnalyzeTaskState?.intent ?? 'analyze',
|
||||
resource: 'text',
|
||||
hasOutput: false,
|
||||
})
|
||||
}, [isGlobalAnalyzing])
|
||||
}, [globalAnalyzeTaskState?.intent, globalAnalyzeTaskState?.phase, isGlobalAnalyzing])
|
||||
|
||||
const handleGlobalAnalyze = useCallback(async () => {
|
||||
if (isGlobalAnalyzing) return
|
||||
if (isGlobalAnalyzing || isSubmittingRef.current) return
|
||||
|
||||
try {
|
||||
setIsGlobalAnalyzing(true)
|
||||
isSubmittingRef.current = true
|
||||
upsertTaskTargetOverlay(queryClient, {
|
||||
projectId,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: projectId,
|
||||
runningTaskType: 'analyze_global',
|
||||
intent: 'analyze',
|
||||
})
|
||||
showToast(t('toolbar.globalAnalyzing'), 'warning', 60000)
|
||||
|
||||
const data = await analyzeGlobalAssets.mutateAsync()
|
||||
await Promise.resolve(onRefresh())
|
||||
|
||||
showToast(
|
||||
t('toolbar.globalAnalyzeSuccess', {
|
||||
characters: data.stats?.newCharacters || 0,
|
||||
locations: data.stats?.newLocations || 0,
|
||||
}),
|
||||
'success',
|
||||
5000,
|
||||
)
|
||||
const submission = await analyzeGlobalAssets.mutateAsync()
|
||||
lastRunningTaskIdRef.current = submission.taskId
|
||||
} catch (error: unknown) {
|
||||
clearTaskTargetOverlay(queryClient, {
|
||||
projectId,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: projectId,
|
||||
})
|
||||
_ulogError('Global analyze error:', error)
|
||||
showToast(`${t('toolbar.globalAnalyzeFailed')}: ${getErrorMessage(error)}`, 'error', 5000)
|
||||
} finally {
|
||||
setIsGlobalAnalyzing(false)
|
||||
isSubmittingRef.current = false
|
||||
}
|
||||
}, [analyzeGlobalAssets, isGlobalAnalyzing, onRefresh, showToast, t])
|
||||
}, [analyzeGlobalAssets, isGlobalAnalyzing, projectId, queryClient, showToast, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (isGlobalAnalyzing && globalAnalyzeTaskState?.runningTaskId) {
|
||||
lastRunningTaskIdRef.current = globalAnalyzeTaskState.runningTaskId
|
||||
}
|
||||
}, [globalAnalyzeTaskState?.runningTaskId, isGlobalAnalyzing])
|
||||
|
||||
useEffect(() => {
|
||||
const completion = resolveGlobalAnalyzeCompletion(lastRunningTaskIdRef.current, globalAnalyzeTaskState)
|
||||
if (completion.status === 'running' || completion.status === 'idle' || !completion.finishedTaskId) {
|
||||
return
|
||||
}
|
||||
if (lastHandledTaskIdRef.current === completion.finishedTaskId) {
|
||||
return
|
||||
}
|
||||
|
||||
lastHandledTaskIdRef.current = completion.finishedTaskId
|
||||
lastRunningTaskIdRef.current = null
|
||||
|
||||
void (async () => {
|
||||
if (completion.status === 'failed') {
|
||||
showToast(
|
||||
`${t('toolbar.globalAnalyzeFailed')}: ${completion.errorMessage || t('toolbar.globalAnalyzeFailed')}`,
|
||||
'error',
|
||||
5000,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await waitForTaskResult(completion.finishedTaskId, {
|
||||
intervalMs: 100,
|
||||
timeoutMs: 2_000,
|
||||
}) as { stats?: { newCharacters?: number; newLocations?: number } }
|
||||
await Promise.resolve(onRefresh())
|
||||
showToast(
|
||||
t('toolbar.globalAnalyzeSuccess', {
|
||||
characters: result.stats?.newCharacters || 0,
|
||||
locations: result.stats?.newLocations || 0,
|
||||
}),
|
||||
'success',
|
||||
5000,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
_ulogError('Global analyze finalize error:', error)
|
||||
showToast(`${t('toolbar.globalAnalyzeFailed')}: ${getErrorMessage(error)}`, 'error', 5000)
|
||||
}
|
||||
})()
|
||||
}, [globalAnalyzeTaskState, onRefresh, showToast, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerGlobalAnalyze || hasTriggeredGlobalAnalyze.current || isGlobalAnalyzing) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { logInfo as _ulogInfo, logWarn as _ulogWarn, logError as _ulogError } from '@/lib/logging/core'
|
||||
import { detectEpisodeMarkers, type EpisodeMarkerResult } from '@/lib/episode-marker-detector'
|
||||
import { countWords } from '@/lib/word-count'
|
||||
@@ -20,12 +20,14 @@ interface UseWizardStateParams {
|
||||
importStatus?: string | null
|
||||
onImportComplete: (episodes: SplitEpisode[], triggerGlobalAnalysis?: boolean) => void
|
||||
t: Translate
|
||||
/** 预填文本:传入后自动设置并触发分析 */
|
||||
initialRawContent?: string
|
||||
}
|
||||
|
||||
export function useWizardState({ projectId, importStatus, onImportComplete, t }: UseWizardStateParams) {
|
||||
export function useWizardState({ projectId, importStatus, onImportComplete, t, initialRawContent }: UseWizardStateParams) {
|
||||
const initialStage: WizardStage = importStatus === 'pending' ? 'preview' : 'select'
|
||||
const [stage, setStage] = useState<WizardStage>(initialStage)
|
||||
const [rawContent, setRawContent] = useState('')
|
||||
const [rawContent, setRawContent] = useState(initialRawContent || '')
|
||||
const [episodes, setEpisodes] = useState<SplitEpisode[]>([])
|
||||
const [selectedEpisode, setSelectedEpisode] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -64,6 +66,7 @@ export function useWizardState({ projectId, importStatus, onImportComplete, t }:
|
||||
}
|
||||
}, [episodes.length, importStatus, loadSavedEpisodes])
|
||||
|
||||
|
||||
const performAISplit = useCallback(async () => {
|
||||
setShowMarkerConfirm(false)
|
||||
setStage('analyzing')
|
||||
@@ -131,6 +134,16 @@ export function useWizardState({ projectId, importStatus, onImportComplete, t }:
|
||||
await performAISplit()
|
||||
}, [performAISplit, projectId, rawContent, t])
|
||||
|
||||
// 当预填文本存在时,自动触发分析(跳过选择页面)
|
||||
const autoAnalyzeTriggered = useRef(false)
|
||||
useEffect(() => {
|
||||
if (initialRawContent && !autoAnalyzeTriggered.current && stage === 'select') {
|
||||
autoAnalyzeTriggered.current = true
|
||||
void handleAnalyze()
|
||||
}
|
||||
}) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
const handleMarkerSplit = useCallback(async () => {
|
||||
if (!markerResult) return
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useWorkspaceProjectSnapshot } from './useWorkspaceProjectSnapshot'
|
||||
import { useWorkspaceModalEscape } from './useWorkspaceModalEscape'
|
||||
import { useWorkspaceStageRuntime } from './useWorkspaceStageRuntime'
|
||||
import { useWorkspaceConfigActions } from './useWorkspaceConfigActions'
|
||||
import { useWorkspaceAutoRun } from './useWorkspaceAutoRun'
|
||||
import { buildWorkspaceControllerViewModel } from './workspace-controller-view-model'
|
||||
import type { NovelPromotionWorkspaceProps } from '../types'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
@@ -111,6 +112,10 @@ export function useNovelPromotionWorkspaceController({
|
||||
|
||||
const isStartingStoryToScript = rebuildState.pendingActionType === 'storyToScript'
|
||||
const isStartingScriptToStoryboard = rebuildState.pendingActionType === 'scriptToStoryboard'
|
||||
const isStoryToScriptRunning =
|
||||
execution.storyToScriptStream.isRunning ||
|
||||
execution.storyToScriptStream.isRecoveredRunning ||
|
||||
execution.storyToScriptStream.status === 'running'
|
||||
|
||||
const isAnyOperationRunning =
|
||||
isStartingStoryToScript ||
|
||||
@@ -122,6 +127,17 @@ export function useNovelPromotionWorkspaceController({
|
||||
execution.storyToScriptStream.isRunning ||
|
||||
execution.scriptToStoryboardStream.isRunning
|
||||
|
||||
useWorkspaceAutoRun({
|
||||
searchParams,
|
||||
router,
|
||||
episodeId,
|
||||
novelText: projectSnapshot.novelText,
|
||||
isTransitioning: execution.isTransitioning,
|
||||
isStoryToScriptRunning,
|
||||
runWithRebuildConfirm: rebuildState.runWithRebuildConfirm,
|
||||
runStoryToScriptFlow: execution.runStoryToScriptFlow,
|
||||
})
|
||||
|
||||
const capsuleNavItems = useWorkspaceStageNavigation({
|
||||
isAnyOperationRunning,
|
||||
episode,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface SearchParamsLike {
|
||||
get: (name: string) => string | null
|
||||
toString: () => string
|
||||
}
|
||||
|
||||
interface RouterLike {
|
||||
replace: (href: string, options?: { scroll?: boolean }) => void
|
||||
}
|
||||
|
||||
interface UseWorkspaceAutoRunParams {
|
||||
searchParams: SearchParamsLike | null
|
||||
router: RouterLike
|
||||
episodeId?: string
|
||||
novelText: string
|
||||
isTransitioning: boolean
|
||||
isStoryToScriptRunning: boolean
|
||||
runWithRebuildConfirm: (
|
||||
action: 'storyToScript' | 'scriptToStoryboard',
|
||||
operation: () => Promise<void>,
|
||||
) => Promise<void>
|
||||
runStoryToScriptFlow: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useWorkspaceAutoRun({
|
||||
searchParams,
|
||||
router,
|
||||
episodeId,
|
||||
novelText,
|
||||
isTransitioning,
|
||||
isStoryToScriptRunning,
|
||||
runWithRebuildConfirm,
|
||||
runStoryToScriptFlow,
|
||||
}: UseWorkspaceAutoRunParams) {
|
||||
const handledAutoRunKeyRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams) return
|
||||
if (searchParams.get('autoRun') !== 'storyToScript') return
|
||||
if (!episodeId) return
|
||||
if (!novelText.trim()) return
|
||||
if (isTransitioning || isStoryToScriptRunning) return
|
||||
|
||||
const autoRunKey = `storyToScript:${episodeId}`
|
||||
if (handledAutoRunKeyRef.current === autoRunKey) {
|
||||
return
|
||||
}
|
||||
handledAutoRunKeyRef.current = autoRunKey
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('autoRun')
|
||||
router.replace(`?${params.toString()}`, { scroll: false })
|
||||
|
||||
void runWithRebuildConfirm('storyToScript', runStoryToScriptFlow)
|
||||
}, [
|
||||
episodeId,
|
||||
isStoryToScriptRunning,
|
||||
isTransitioning,
|
||||
novelText,
|
||||
router,
|
||||
runStoryToScriptFlow,
|
||||
runWithRebuildConfirm,
|
||||
searchParams,
|
||||
])
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import { useParams, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
@@ -135,9 +135,18 @@ export default function ProjectDetailPage() {
|
||||
// 获取导入状态
|
||||
const importStatus = novelPromotionData?.importStatus
|
||||
|
||||
// 检测是否需要显示导入向导:无剧集或导入中
|
||||
// 零状态:无剧集且非导入中 → 自动创建第一集
|
||||
const isZeroState = episodes.length === 0
|
||||
const shouldShowImportWizard = isZeroState || importStatus === 'pending'
|
||||
const shouldShowImportWizard = importStatus === 'pending' // 仅分集预览中才显示 wizard
|
||||
const shouldAutoCreateEpisode = isZeroState && importStatus !== 'pending'
|
||||
const autoCreateTriggered = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoCreateEpisode || autoCreateTriggered.current || loading) return
|
||||
autoCreateTriggered.current = true
|
||||
void handleCreateEpisode(`${t('episode')} 1`)
|
||||
}, [shouldAutoCreateEpisode, loading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const shouldGateImportWizardByModel = shouldShowImportWizard && !isGlobalAssetsView
|
||||
|
||||
useEffect(() => {
|
||||
@@ -489,7 +498,7 @@ export default function ProjectDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 零状态或导入中:显示智能导入向导
|
||||
// 导入中(pending):显示分集预览向导
|
||||
<SmartImportWizard
|
||||
projectId={projectId}
|
||||
onManualCreate={() => handleCreateEpisode(`${t('episode')} 1`)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'
|
||||
import { apiHandler, ApiError } from '@/lib/api-errors'
|
||||
@@ -40,7 +41,7 @@ export const POST = apiHandler(async (
|
||||
const { novelData } = authResult
|
||||
|
||||
const body = await request.json()
|
||||
const { name, description } = body
|
||||
const { name, description, novelText } = body
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
@@ -54,13 +55,18 @@ export const POST = apiHandler(async (
|
||||
const nextEpisodeNumber = (lastEpisode?.episodeNumber || 0) + 1
|
||||
|
||||
// 创建剧集
|
||||
const createData: Prisma.NovelPromotionEpisodeUncheckedCreateInput = {
|
||||
novelPromotionProjectId: novelData.id,
|
||||
episodeNumber: nextEpisodeNumber,
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
}
|
||||
if (typeof novelText === 'string') {
|
||||
createData.novelText = novelText
|
||||
}
|
||||
|
||||
const episode = await prisma.novelPromotionEpisode.create({
|
||||
data: {
|
||||
novelPromotionProjectId: novelData.id,
|
||||
episodeNumber: nextEpisodeNumber,
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null
|
||||
}
|
||||
data: createData,
|
||||
})
|
||||
|
||||
// 更新最后编辑的剧集ID
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AppIcon } from '@/components/ui/icons'
|
||||
import UpdateNoticeModal from './UpdateNoticeModal'
|
||||
import { useGithubReleaseUpdate } from '@/hooks/common/useGithubReleaseUpdate'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { buildAuthenticatedHomeTarget } from '@/lib/home/default-route'
|
||||
|
||||
|
||||
export default function Navbar() {
|
||||
@@ -41,7 +42,7 @@ export default function Navbar() {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={{ pathname: session ? '/workspace' : '/' }} className="group">
|
||||
<Link href={session ? buildAuthenticatedHomeTarget() : { pathname: '/' }} className="group">
|
||||
<Image
|
||||
src="/logo-small.png?v=1"
|
||||
alt={tc('appName')}
|
||||
|
||||
172
src/components/selectors/RatioStyleSelectors.tsx
Normal file
172
src/components/selectors/RatioStyleSelectors.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* RatioSelector / StyleSelector - 公共选择器组件
|
||||
* 卡片边框风格:选中时蓝色描边 + 淡色背景 + 加粗文字
|
||||
*
|
||||
* 使用场景:首页、项目故事输入页
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
/** 线框比例预览块 */
|
||||
function RatioShape({ ratio, selected, size = 26 }: { ratio: string; selected: boolean; size?: number }) {
|
||||
const [w, h] = ratio.split(':').map(Number)
|
||||
const max = Math.max(w, h)
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border-2 transition-colors ${
|
||||
selected ? 'border-[var(--glass-accent-from)]' : 'border-[var(--glass-stroke-strong)]'
|
||||
}`}
|
||||
style={{
|
||||
width: Math.min(size, size * (w / max)),
|
||||
height: Math.min(size, size * (h / max)),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RatioSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
getUsage,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: { value: string; label: string; recommended?: boolean }[]
|
||||
getUsage?: (ratio: string) => string
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find((o) => o.value === value)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<RatioShape ratio={value} size={18} selected />
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption?.label || value}</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
|
||||
style={{ minWidth: '300px' }}
|
||||
>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value
|
||||
const usageTag = getUsage?.(option.value)
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex flex-col items-center gap-2 p-3 rounded-xl border transition-all ${
|
||||
isSelected
|
||||
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
|
||||
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
|
||||
}`}
|
||||
title={usageTag || undefined}
|
||||
>
|
||||
<RatioShape ratio={option.value} size={28} selected={isSelected} />
|
||||
<span className={`text-xs ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'text-[var(--glass-text-secondary)]'}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StyleSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: { value: string; label: string; recommended?: boolean }[]
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find((o) => o.value === value) || options[0]
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 p-3" style={{ minWidth: '320px' }}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center p-3 rounded-xl border text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
|
||||
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm whitespace-nowrap ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'text-[var(--glass-text-secondary)]'}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -366,13 +366,24 @@ export function SettingsModal({
|
||||
<p className="text-[12px] text-[var(--glass-text-tertiary)] mb-6">{t('subtitle')}</p>
|
||||
<div className="space-y-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
||||
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('visualStyle')}</h3>
|
||||
<div className="max-w-xs">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={(value) => handleChange(onArtStyleChange)(value)}
|
||||
options={ART_STYLES}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('visualSettings')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('visualStyle')}</label>
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={(value) => handleChange(onArtStyleChange)(value)}
|
||||
options={ART_STYLES}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('aspectRatio')}</label>
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={(value) => { handleChange(onVideoRatioChange)(value) }}
|
||||
options={VIDEO_RATIOS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -491,16 +502,7 @@ export function SettingsModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('aspectRatio')}</h3>
|
||||
<div className="max-w-xs">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={(value) => { handleChange(onVideoRatioChange)(value) }}
|
||||
options={VIDEO_RATIOS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 项目配置弹窗专用选择器
|
||||
* 卡片边框风格:选中时蓝色描边 + 淡色背景 + 加粗文字
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AppIcon, RatioPreviewIcon } from '@/components/ui/icons'
|
||||
|
||||
interface RatioIconProps {
|
||||
ratio: string
|
||||
size?: number
|
||||
selected?: boolean
|
||||
}
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface RatioSelectorProps {
|
||||
value: string
|
||||
@@ -21,14 +19,19 @@ interface StyleSelectorProps {
|
||||
options: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
function RatioIcon({ ratio, size = 24, selected = false }: RatioIconProps) {
|
||||
// 始终以选中态渲染图标,保证所有比例选项的图标统一为蓝色
|
||||
/** 线框比例预览块 */
|
||||
function RatioShape({ ratio, selected, size = 26 }: { ratio: string; selected: boolean; size?: number }) {
|
||||
const [w, h] = ratio.split(':').map(Number)
|
||||
const max = Math.max(w, h)
|
||||
return (
|
||||
<RatioPreviewIcon
|
||||
ratio={ratio}
|
||||
size={size}
|
||||
selected={selected || true}
|
||||
variant="surface"
|
||||
<div
|
||||
className={`rounded-md border-2 transition-colors ${
|
||||
selected ? 'border-[var(--glass-accent-from)]' : 'border-[var(--glass-stroke-strong)]'
|
||||
}`}
|
||||
style={{
|
||||
width: Math.min(size, size * (w / max)),
|
||||
height: Math.min(size, size * (h / max)),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -56,8 +59,8 @@ export function RatioSelector({ value, onChange, options }: RatioSelectorProps)
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<RatioIcon ratio={value} size={20} selected />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<RatioShape ratio={value} size={18} selected />
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">
|
||||
{selectedOption?.label || value}
|
||||
</span>
|
||||
@@ -68,35 +71,32 @@ export function RatioSelector({ value, onChange, options }: RatioSelectorProps)
|
||||
{isOpen && (
|
||||
<div
|
||||
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
|
||||
style={{ minWidth: '280px' }}
|
||||
style={{ minWidth: '300px' }}
|
||||
>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-[var(--glass-bg-muted)] transition-colors ${
|
||||
value === option.value
|
||||
? 'bg-[var(--glass-tone-info-bg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<RatioIcon ratio={option.value} size={28} selected={value === option.value} />
|
||||
<span
|
||||
className={`text-xs ${
|
||||
value === option.value
|
||||
? 'text-[var(--glass-tone-info-fg)] font-medium'
|
||||
: 'text-[var(--glass-text-secondary)]'
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex flex-col items-center gap-2 p-3 rounded-xl border transition-all ${
|
||||
isSelected
|
||||
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
|
||||
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<RatioShape ratio={option.value} size={28} selected={isSelected} />
|
||||
<span className={`text-xs ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'text-[var(--glass-text-secondary)]'}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -127,32 +127,35 @@ export function StyleSelector({ value, onChange, options }: StyleSelectorProps)
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3">
|
||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 p-3" style={{ minWidth: '320px' }}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center p-3 rounded-lg text-left transition-all ${
|
||||
value === option.value
|
||||
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||||
: 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-sm">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center p-3 rounded-xl border text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
|
||||
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm whitespace-nowrap ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'text-[var(--glass-text-secondary)]'}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
12
src/i18n.ts
12
src/i18n.ts
@@ -46,7 +46,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
assetHub,
|
||||
assetModal,
|
||||
assetPicker,
|
||||
layout
|
||||
layout,
|
||||
workspaceRedesign,
|
||||
home
|
||||
] = await Promise.all([
|
||||
import(`../messages/${locale}/common.json`),
|
||||
import(`../messages/${locale}/stages.json`),
|
||||
@@ -77,7 +79,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
import(`../messages/${locale}/assetHub.json`),
|
||||
import(`../messages/${locale}/assetModal.json`),
|
||||
import(`../messages/${locale}/assetPicker.json`),
|
||||
import(`../messages/${locale}/layout.json`)
|
||||
import(`../messages/${locale}/layout.json`),
|
||||
import(`../messages/${locale}/workspaceRedesign.json`),
|
||||
import(`../messages/${locale}/home.json`)
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -112,7 +116,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
assetHub: assetHub.default,
|
||||
assetModal: assetModal.default,
|
||||
assetPicker: assetPicker.default,
|
||||
layout: layout.default
|
||||
layout: layout.default,
|
||||
workspaceRedesign: workspaceRedesign.default,
|
||||
home: home.default
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
156
src/lib/home/create-project-launch.ts
Normal file
156
src/lib/home/create-project-launch.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
interface ApiErrorPayload {
|
||||
error?: string | { message?: string } | null
|
||||
}
|
||||
|
||||
interface ProjectCreationPayload {
|
||||
project?: {
|
||||
id?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface EpisodeCreationPayload {
|
||||
episode?: {
|
||||
id?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface ApiFetchLike {
|
||||
(input: string, init?: RequestInit): Promise<Response>
|
||||
}
|
||||
|
||||
export interface HomeWorkspaceLaunchTarget {
|
||||
pathname: string
|
||||
query: {
|
||||
episode: string
|
||||
autoRun: 'storyToScript'
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateHomeProjectLaunchParams {
|
||||
apiFetch: ApiFetchLike
|
||||
projectName: string
|
||||
storyText: string
|
||||
videoRatio: string
|
||||
artStyle: string
|
||||
episodeName: string
|
||||
}
|
||||
|
||||
export interface CreateHomeProjectLaunchResult {
|
||||
projectId: string
|
||||
episodeId: string
|
||||
target: HomeWorkspaceLaunchTarget
|
||||
}
|
||||
|
||||
function readObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function readNestedString(
|
||||
source: Record<string, unknown> | null,
|
||||
outerKey: string,
|
||||
innerKey: string,
|
||||
): string | null {
|
||||
const outer = readObject(source?.[outerKey])
|
||||
const value = outer?.[innerKey]
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
}
|
||||
|
||||
async function readApiErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const payload = await response.json() as ApiErrorPayload
|
||||
if (typeof payload?.error === 'string' && payload.error.trim()) {
|
||||
return payload.error
|
||||
}
|
||||
if (payload?.error && typeof payload.error === 'object' && typeof payload.error.message === 'string' && payload.error.message.trim()) {
|
||||
return payload.error.message
|
||||
}
|
||||
} catch {
|
||||
// Keep the explicit fallback when the backend does not return JSON.
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function readProjectId(response: Response): Promise<string> {
|
||||
const payload = await response.json() as ProjectCreationPayload
|
||||
const projectId = readNestedString(readObject(payload), 'project', 'id')
|
||||
if (!projectId) {
|
||||
throw new Error('Project creation response missing project id')
|
||||
}
|
||||
return projectId
|
||||
}
|
||||
|
||||
async function readEpisodeId(response: Response): Promise<string> {
|
||||
const payload = await response.json() as EpisodeCreationPayload
|
||||
const episodeId = readNestedString(readObject(payload), 'episode', 'id')
|
||||
if (!episodeId) {
|
||||
throw new Error('Episode creation response missing episode id')
|
||||
}
|
||||
return episodeId
|
||||
}
|
||||
|
||||
export function buildHomeWorkspaceLaunchTarget(projectId: string, episodeId: string): HomeWorkspaceLaunchTarget {
|
||||
return {
|
||||
pathname: `/workspace/${projectId}`,
|
||||
query: {
|
||||
episode: episodeId,
|
||||
autoRun: 'storyToScript',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHomeProjectLaunch({
|
||||
apiFetch,
|
||||
projectName,
|
||||
storyText,
|
||||
videoRatio,
|
||||
artStyle,
|
||||
episodeName,
|
||||
}: CreateHomeProjectLaunchParams): Promise<CreateHomeProjectLaunchResult> {
|
||||
const projectResponse = await apiFetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: projectName,
|
||||
description: storyText,
|
||||
mode: 'novel-promotion',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!projectResponse.ok) {
|
||||
throw new Error(await readApiErrorMessage(projectResponse, 'Failed to create project'))
|
||||
}
|
||||
|
||||
const projectId = await readProjectId(projectResponse)
|
||||
|
||||
const configResponse = await apiFetch(`/api/novel-promotion/${projectId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoRatio, artStyle }),
|
||||
})
|
||||
|
||||
if (!configResponse.ok) {
|
||||
throw new Error(await readApiErrorMessage(configResponse, 'Failed to save project config'))
|
||||
}
|
||||
|
||||
const episodeResponse = await apiFetch(`/api/novel-promotion/${projectId}/episodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: episodeName,
|
||||
novelText: storyText,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!episodeResponse.ok) {
|
||||
throw new Error(await readApiErrorMessage(episodeResponse, 'Failed to create first episode'))
|
||||
}
|
||||
|
||||
const episodeId = await readEpisodeId(episodeResponse)
|
||||
|
||||
return {
|
||||
projectId,
|
||||
episodeId,
|
||||
target: buildHomeWorkspaceLaunchTarget(projectId, episodeId),
|
||||
}
|
||||
}
|
||||
11
src/lib/home/default-route.ts
Normal file
11
src/lib/home/default-route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const AUTHENTICATED_HOME_PATHNAME = '/home' as const
|
||||
|
||||
export interface AuthenticatedHomeTarget {
|
||||
pathname: typeof AUTHENTICATED_HOME_PATHNAME
|
||||
}
|
||||
|
||||
export function buildAuthenticatedHomeTarget(): AuthenticatedHomeTarget {
|
||||
return {
|
||||
pathname: AUTHENTICATED_HOME_PATHNAME,
|
||||
}
|
||||
}
|
||||
14
src/lib/home/quick-start-textarea.ts
Normal file
14
src/lib/home/quick-start-textarea.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const HOME_QUICK_START_MIN_ROWS = 3
|
||||
|
||||
interface ResolveTextareaTargetHeightInput {
|
||||
minHeight: number
|
||||
maxHeight: number
|
||||
scrollHeight: number
|
||||
}
|
||||
|
||||
export function resolveTextareaTargetHeight(
|
||||
input: ResolveTextareaTargetHeightInput,
|
||||
): number {
|
||||
const cappedHeight = Math.min(input.scrollHeight, input.maxHeight)
|
||||
return Math.max(input.minHeight, cappedHeight)
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
import { queryKeys } from '@/lib/query/keys'
|
||||
import { useTaskTargetStateMap } from '@/lib/query/hooks/useTaskTargetStateMap'
|
||||
import {
|
||||
clearTaskTargetOverlay,
|
||||
upsertTaskTargetOverlay,
|
||||
} from '@/lib/query/task-target-overlay'
|
||||
import type {
|
||||
AssetKind,
|
||||
AssetQueryInput,
|
||||
@@ -175,6 +179,58 @@ type AssetActionScopeInput = {
|
||||
kind: AssetKind
|
||||
}
|
||||
|
||||
type GenerateOverlayTarget = {
|
||||
projectId: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function resolveGenerateOverlayTarget(
|
||||
input: AssetActionScopeInput,
|
||||
payload: Record<string, unknown>,
|
||||
): GenerateOverlayTarget | null {
|
||||
const assetId = normalizeOptionalString(payload.id)
|
||||
?? normalizeOptionalString(payload.characterId)
|
||||
?? normalizeOptionalString(payload.locationId)
|
||||
if (!assetId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (input.scope === 'global') {
|
||||
return {
|
||||
projectId: 'global-asset-hub',
|
||||
targetType: input.kind === 'character' ? 'GlobalCharacter' : 'GlobalLocation',
|
||||
targetId: assetId,
|
||||
}
|
||||
}
|
||||
|
||||
const projectId = normalizeOptionalString(input.projectId)
|
||||
if (!projectId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (input.kind === 'character') {
|
||||
const appearanceId = normalizeOptionalString(payload.appearanceId)
|
||||
return {
|
||||
projectId,
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: appearanceId ?? assetId,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
targetType: 'LocationImage',
|
||||
targetId: assetId,
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateScopeQueries(queryClient: ReturnType<typeof useQueryClient>, input: AssetActionScopeInput) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.assets.all(input.scope, input.projectId),
|
||||
@@ -259,21 +315,37 @@ export function useAssetActions(input: AssetActionScopeInput) {
|
||||
}
|
||||
|
||||
const generate = async (payload: Record<string, unknown>) => {
|
||||
const response = await apiFetch(`/api/assets/${String(payload.id)}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scope: input.scope,
|
||||
kind: input.kind,
|
||||
projectId: input.projectId,
|
||||
...payload,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate asset render')
|
||||
const assetId = String(payload.id)
|
||||
const overlayTarget = resolveGenerateOverlayTarget(input, payload)
|
||||
if (overlayTarget) {
|
||||
upsertTaskTargetOverlay(queryClient, {
|
||||
...overlayTarget,
|
||||
intent: 'generate',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/assets/${assetId}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scope: input.scope,
|
||||
kind: input.kind,
|
||||
projectId: input.projectId,
|
||||
...payload,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate asset render')
|
||||
}
|
||||
invalidateScopeQueries(queryClient, input)
|
||||
return response.json()
|
||||
} catch (error) {
|
||||
if (overlayTarget) {
|
||||
clearTaskTargetOverlay(queryClient, overlayTarget)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
invalidateScopeQueries(queryClient, input)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const selectRender = async (payload: Record<string, unknown>) => {
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Project } from '@/types/project'
|
||||
import { queryKeys } from '../keys'
|
||||
import { resolveTaskResponse } from '@/lib/task/client'
|
||||
import { queryKeys } from '../keys'
|
||||
import {
|
||||
invalidateQueryTemplates,
|
||||
requestJsonWithError,
|
||||
requestTaskResponseWithError,
|
||||
} from './mutation-shared'
|
||||
|
||||
export function useAnalyzeProjectGlobalAssets(projectId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const invalidateProjectAssets = () =>
|
||||
invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])
|
||||
type AsyncTaskSubmission = {
|
||||
async: true
|
||||
taskId: string
|
||||
runId?: string | null
|
||||
status?: string | null
|
||||
deduped?: boolean
|
||||
}
|
||||
|
||||
function isAsyncTaskSubmission(value: unknown): value is AsyncTaskSubmission {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const payload = value as Record<string, unknown>
|
||||
return payload.async === true && typeof payload.taskId === 'string' && payload.taskId.length > 0
|
||||
}
|
||||
|
||||
export function useAnalyzeProjectGlobalAssets(projectId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await requestTaskResponseWithError(
|
||||
const response = await requestTaskResponseWithError(
|
||||
`/api/novel-promotion/${projectId}/analyze-global`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -24,9 +34,12 @@ export function useAnalyzeProjectGlobalAssets(projectId: string) {
|
||||
},
|
||||
'Failed to analyze global assets',
|
||||
)
|
||||
return resolveTaskResponse<{ stats?: { newCharacters?: number; newLocations?: number } }>(res)
|
||||
const data = await response.json().catch(() => null)
|
||||
if (!isAsyncTaskSubmission(data)) {
|
||||
throw new Error('Failed to submit global asset analysis task')
|
||||
}
|
||||
return data
|
||||
},
|
||||
onSuccess: invalidateProjectAssets,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
|
||||
.glass-btn-primary {
|
||||
background: linear-gradient(140deg, var(--glass-accent-from) 0%, var(--glass-accent-to) 100%);
|
||||
border: none;
|
||||
color: var(--glass-text-on-accent);
|
||||
box-shadow: 0 8px 20px var(--glass-accent-shadow-soft);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuth: vi.fn(async () => ({
|
||||
novelData: { id: 'np-1', projectId: 'project-1' },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionEpisode: {
|
||||
findFirst: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({
|
||||
id: 'episode-1',
|
||||
novelPromotionProjectId: 'np-1',
|
||||
episodeNumber: 1,
|
||||
name: '第 1 集',
|
||||
description: null,
|
||||
novelText: '第一章内容',
|
||||
})),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
update: vi.fn(async () => ({
|
||||
id: 'np-1',
|
||||
lastEpisodeId: 'episode-1',
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
describe('api specific - novel promotion episode create text', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('persists novelText when creating the first episode from home launch', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/episodes/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/episodes',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: '第 1 集',
|
||||
novelText: '第一章内容',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(prismaMock.novelPromotionEpisode.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
novelPromotionProjectId: 'np-1',
|
||||
episodeNumber: 1,
|
||||
name: '第 1 集',
|
||||
description: null,
|
||||
novelText: '第一章内容',
|
||||
},
|
||||
})
|
||||
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({
|
||||
where: { id: 'np-1' },
|
||||
data: { lastEpisodeId: 'episode-1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -96,6 +96,7 @@ describe('Navbar download logs entry', () => {
|
||||
const html = renderWithIntl(createElement(Navbar))
|
||||
|
||||
expect(html).toContain('下载日志')
|
||||
expect(html).toContain('href="/home"')
|
||||
expect(html).toContain('href="/api/admin/download-logs"')
|
||||
expect(html).toContain('download=""')
|
||||
})
|
||||
|
||||
115
tests/unit/home/create-project-launch.test.ts
Normal file
115
tests/unit/home/create-project-launch.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildHomeWorkspaceLaunchTarget,
|
||||
createHomeProjectLaunch,
|
||||
} from '@/lib/home/create-project-launch'
|
||||
|
||||
function buildJsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('createHomeProjectLaunch', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('creates project, config, first episode, and returns an auto-run workspace target', async () => {
|
||||
const apiFetch = vi
|
||||
.fn<(
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(buildJsonResponse({
|
||||
project: { id: 'project-1' },
|
||||
}, 201))
|
||||
.mockResolvedValueOnce(buildJsonResponse({ success: true }, 200))
|
||||
.mockResolvedValueOnce(buildJsonResponse({
|
||||
episode: { id: 'episode-1' },
|
||||
}, 201))
|
||||
|
||||
const result = await createHomeProjectLaunch({
|
||||
apiFetch,
|
||||
projectName: '开场白',
|
||||
storyText: '第一章内容',
|
||||
videoRatio: '9:16',
|
||||
artStyle: 'american-comic',
|
||||
episodeName: '第 1 集',
|
||||
})
|
||||
|
||||
expect(apiFetch).toHaveBeenNthCalledWith(1, '/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: '开场白',
|
||||
description: '第一章内容',
|
||||
mode: 'novel-promotion',
|
||||
}),
|
||||
})
|
||||
expect(apiFetch).toHaveBeenNthCalledWith(2, '/api/novel-promotion/project-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoRatio: '9:16',
|
||||
artStyle: 'american-comic',
|
||||
}),
|
||||
})
|
||||
expect(apiFetch).toHaveBeenNthCalledWith(3, '/api/novel-promotion/project-1/episodes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: '第 1 集',
|
||||
novelText: '第一章内容',
|
||||
}),
|
||||
})
|
||||
expect(result).toEqual({
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
target: {
|
||||
pathname: '/workspace/project-1',
|
||||
query: {
|
||||
episode: 'episode-1',
|
||||
autoRun: 'storyToScript',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('fails explicitly when first episode creation does not return an episode id', async () => {
|
||||
const apiFetch = vi
|
||||
.fn<(
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(buildJsonResponse({
|
||||
project: { id: 'project-1' },
|
||||
}, 201))
|
||||
.mockResolvedValueOnce(buildJsonResponse({ success: true }, 200))
|
||||
.mockResolvedValueOnce(buildJsonResponse({
|
||||
episode: {},
|
||||
}, 201))
|
||||
|
||||
await expect(createHomeProjectLaunch({
|
||||
apiFetch,
|
||||
projectName: '开场白',
|
||||
storyText: '第一章内容',
|
||||
videoRatio: '9:16',
|
||||
artStyle: 'american-comic',
|
||||
episodeName: '第 1 集',
|
||||
})).rejects.toThrow('Episode creation response missing episode id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildHomeWorkspaceLaunchTarget', () => {
|
||||
it('points workspace launch to the created episode and auto-runs story-to-script', () => {
|
||||
expect(buildHomeWorkspaceLaunchTarget('project-9', 'episode-4')).toEqual({
|
||||
pathname: '/workspace/project-9',
|
||||
query: {
|
||||
episode: 'episode-4',
|
||||
autoRun: 'storyToScript',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
14
tests/unit/home/default-route.test.ts
Normal file
14
tests/unit/home/default-route.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AUTHENTICATED_HOME_PATHNAME,
|
||||
buildAuthenticatedHomeTarget,
|
||||
} from '@/lib/home/default-route'
|
||||
|
||||
describe('authenticated home default route', () => {
|
||||
it('uses /home as the only authenticated default pathname', () => {
|
||||
expect(AUTHENTICATED_HOME_PATHNAME).toBe('/home')
|
||||
expect(buildAuthenticatedHomeTarget()).toEqual({
|
||||
pathname: '/home',
|
||||
})
|
||||
})
|
||||
})
|
||||
89
tests/unit/home/quick-start-textarea.test.ts
Normal file
89
tests/unit/home/quick-start-textarea.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import HomePage from '@/app/[locale]/home/page'
|
||||
import {
|
||||
HOME_QUICK_START_MIN_ROWS,
|
||||
resolveTextareaTargetHeight,
|
||||
} from '@/lib/home/quick-start-textarea'
|
||||
|
||||
vi.mock('next-auth/react', () => ({
|
||||
useSession: () => ({
|
||||
data: { user: { name: 'Earth' } },
|
||||
status: 'authenticated',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (namespace: string) => (key: string) => `${namespace}.${key}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/Navbar', () => ({
|
||||
default: () => createElement('nav', null, 'Navbar'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: ({ name, ...props }: { name: string } & Record<string, unknown>) =>
|
||||
createElement('span', { ...props, 'data-icon': name }),
|
||||
IconGradientDefs: (props: Record<string, unknown>) => createElement('span', props),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/selectors/RatioStyleSelectors', () => ({
|
||||
RatioSelector: (props: Record<string, unknown>) => createElement('div', props, 'RatioSelector'),
|
||||
StyleSelector: (props: Record<string, unknown>) => createElement('div', props, 'StyleSelector'),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
href: string | { pathname: string }
|
||||
children: React.ReactNode
|
||||
} & Record<string, unknown>) => {
|
||||
const resolvedHref = typeof href === 'string' ? href : href.pathname
|
||||
return createElement('a', { href: resolvedHref, ...props }, children)
|
||||
},
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-fetch', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/home/create-project-launch', () => ({
|
||||
createHomeProjectLaunch: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('resolveTextareaTargetHeight', () => {
|
||||
it('keeps the home quick-start input at least three rows tall', () => {
|
||||
expect(resolveTextareaTargetHeight({
|
||||
minHeight: 96,
|
||||
maxHeight: 320,
|
||||
scrollHeight: 54,
|
||||
})).toBe(96)
|
||||
})
|
||||
|
||||
it('caps the auto-resized height to the viewport ceiling', () => {
|
||||
expect(resolveTextareaTargetHeight({
|
||||
minHeight: 96,
|
||||
maxHeight: 180,
|
||||
scrollHeight: 240,
|
||||
})).toBe(180)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HomePage quick-start input', () => {
|
||||
it('renders the homepage textarea with a default three-row height baseline', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderToStaticMarkup(createElement(HomePage))
|
||||
|
||||
expect(HOME_QUICK_START_MIN_ROWS).toBe(3)
|
||||
expect(html).toContain('rows="3"')
|
||||
})
|
||||
})
|
||||
60
tests/unit/novel-promotion/assets-global-actions.test.ts
Normal file
60
tests/unit/novel-promotion/assets-global-actions.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isGlobalAnalyzeTaskRunning,
|
||||
resolveGlobalAnalyzeCompletion,
|
||||
} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useAssetsGlobalActions'
|
||||
|
||||
describe('assets global actions task state helpers', () => {
|
||||
it('treats queued and processing analyze task as running', () => {
|
||||
expect(isGlobalAnalyzeTaskRunning({
|
||||
phase: 'queued',
|
||||
runningTaskId: 'task-1',
|
||||
lastError: null,
|
||||
})).toBe(true)
|
||||
|
||||
expect(isGlobalAnalyzeTaskRunning({
|
||||
phase: 'processing',
|
||||
runningTaskId: 'task-1',
|
||||
lastError: null,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps completion idle when there is no previously running task', () => {
|
||||
expect(resolveGlobalAnalyzeCompletion(null, {
|
||||
phase: 'completed',
|
||||
runningTaskId: null,
|
||||
lastError: null,
|
||||
})).toEqual({
|
||||
status: 'idle',
|
||||
finishedTaskId: null,
|
||||
errorMessage: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('marks previously running task as succeeded once runtime state stops running', () => {
|
||||
expect(resolveGlobalAnalyzeCompletion('task-2', {
|
||||
phase: 'completed',
|
||||
runningTaskId: null,
|
||||
lastError: null,
|
||||
})).toEqual({
|
||||
status: 'succeeded',
|
||||
finishedTaskId: 'task-2',
|
||||
errorMessage: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces failed completion message from task state', () => {
|
||||
expect(resolveGlobalAnalyzeCompletion('task-3', {
|
||||
phase: 'failed',
|
||||
runningTaskId: null,
|
||||
lastError: {
|
||||
code: 'MODEL_NOT_CONFIGURED',
|
||||
message: 'No model configured',
|
||||
},
|
||||
})).toEqual({
|
||||
status: 'failed',
|
||||
finishedTaskId: 'task-3',
|
||||
errorMessage: 'No model configured',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
useQueryClientMock,
|
||||
useMutationMock,
|
||||
requestTaskResponseWithErrorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
useQueryClientMock: vi.fn(() => ({ invalidateQueries: vi.fn() })),
|
||||
useMutationMock: vi.fn((options: unknown) => options),
|
||||
requestTaskResponseWithErrorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => useQueryClientMock(),
|
||||
useMutation: (options: unknown) => useMutationMock(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/mutations/mutation-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(
|
||||
'@/lib/query/mutations/mutation-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
requestTaskResponseWithError: requestTaskResponseWithErrorMock,
|
||||
}
|
||||
})
|
||||
|
||||
import { useAnalyzeProjectGlobalAssets } from '@/lib/query/mutations/useProjectConfigMutations'
|
||||
|
||||
interface AnalyzeGlobalMutation {
|
||||
mutationFn: () => Promise<unknown>
|
||||
}
|
||||
|
||||
describe('project global analyze mutation', () => {
|
||||
beforeEach(() => {
|
||||
useQueryClientMock.mockClear()
|
||||
useMutationMock.mockClear()
|
||||
requestTaskResponseWithErrorMock.mockReset()
|
||||
})
|
||||
|
||||
it('returns async task submission instead of waiting for final task result', async () => {
|
||||
requestTaskResponseWithErrorMock.mockResolvedValue({
|
||||
json: async () => ({
|
||||
async: true,
|
||||
taskId: 'task-global-1',
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
}),
|
||||
} as Response)
|
||||
|
||||
const mutation = useAnalyzeProjectGlobalAssets('project-1') as unknown as AnalyzeGlobalMutation
|
||||
const result = await mutation.mutationFn() as { taskId: string; async: boolean }
|
||||
|
||||
expect(requestTaskResponseWithErrorMock).toHaveBeenCalledWith(
|
||||
'/api/novel-promotion/project-1/analyze-global',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ async: true }),
|
||||
},
|
||||
'Failed to analyze global assets',
|
||||
)
|
||||
expect(result).toEqual({
|
||||
async: true,
|
||||
taskId: 'task-global-1',
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('fails explicitly when route does not return an async task submission payload', async () => {
|
||||
requestTaskResponseWithErrorMock.mockResolvedValue({
|
||||
json: async () => ({ success: true }),
|
||||
} as Response)
|
||||
|
||||
const mutation = useAnalyzeProjectGlobalAssets('project-1') as unknown as AnalyzeGlobalMutation
|
||||
|
||||
await expect(mutation.mutationFn()).rejects.toThrow('Failed to submit global asset analysis task')
|
||||
})
|
||||
})
|
||||
81
tests/unit/novel-promotion/workspace-auto-run.test.ts
Normal file
81
tests/unit/novel-promotion/workspace-auto-run.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { useEffectMock, useRefMock } = vi.hoisted(() => ({
|
||||
useEffectMock: vi.fn(),
|
||||
useRefMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: useEffectMock,
|
||||
useRef: useRefMock,
|
||||
}
|
||||
})
|
||||
|
||||
import { useWorkspaceAutoRun } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceAutoRun'
|
||||
|
||||
describe('useWorkspaceAutoRun', () => {
|
||||
beforeEach(() => {
|
||||
useEffectMock.mockReset()
|
||||
useRefMock.mockReset()
|
||||
useRefMock.mockImplementation((initialValue: unknown) => ({
|
||||
current: initialValue,
|
||||
}))
|
||||
})
|
||||
|
||||
it('consumes autoRun=storyToScript and starts the story-to-script flow once', async () => {
|
||||
const effectCallbacks: Array<() => void | (() => void)> = []
|
||||
const router = { replace: vi.fn() }
|
||||
const runWithRebuildConfirm = vi.fn(async () => undefined)
|
||||
const runStoryToScriptFlow = vi.fn(async () => undefined)
|
||||
|
||||
useEffectMock.mockImplementation((callback: () => void | (() => void)) => {
|
||||
effectCallbacks.push(callback)
|
||||
})
|
||||
|
||||
useWorkspaceAutoRun({
|
||||
searchParams: new URLSearchParams('episode=episode-1&autoRun=storyToScript'),
|
||||
router,
|
||||
episodeId: 'episode-1',
|
||||
novelText: '第一章内容',
|
||||
isTransitioning: false,
|
||||
isStoryToScriptRunning: false,
|
||||
runWithRebuildConfirm,
|
||||
runStoryToScriptFlow,
|
||||
})
|
||||
|
||||
effectCallbacks[0]?.()
|
||||
|
||||
expect(router.replace).toHaveBeenCalledWith('?episode=episode-1', { scroll: false })
|
||||
expect(runWithRebuildConfirm).toHaveBeenCalledWith('storyToScript', runStoryToScriptFlow)
|
||||
})
|
||||
|
||||
it('does not auto-run when the episode text is still empty', () => {
|
||||
const effectCallbacks: Array<() => void | (() => void)> = []
|
||||
const router = { replace: vi.fn() }
|
||||
const runWithRebuildConfirm = vi.fn(async () => undefined)
|
||||
const runStoryToScriptFlow = vi.fn(async () => undefined)
|
||||
|
||||
useEffectMock.mockImplementation((callback: () => void | (() => void)) => {
|
||||
effectCallbacks.push(callback)
|
||||
})
|
||||
|
||||
useWorkspaceAutoRun({
|
||||
searchParams: new URLSearchParams('episode=episode-1&autoRun=storyToScript'),
|
||||
router,
|
||||
episodeId: 'episode-1',
|
||||
novelText: ' ',
|
||||
isTransitioning: false,
|
||||
isStoryToScriptRunning: false,
|
||||
runWithRebuildConfirm,
|
||||
runStoryToScriptFlow,
|
||||
})
|
||||
|
||||
effectCallbacks[0]?.()
|
||||
|
||||
expect(router.replace).not.toHaveBeenCalled()
|
||||
expect(runWithRebuildConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
107
tests/unit/optimistic/asset-actions-generate.test.ts
Normal file
107
tests/unit/optimistic/asset-actions-generate.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { queryKeys } from '@/lib/query/keys'
|
||||
import type { TaskTargetOverlayMap } from '@/lib/query/task-target-overlay'
|
||||
|
||||
const {
|
||||
apiFetchMock,
|
||||
useQueryClientMock,
|
||||
} = vi.hoisted(() => ({
|
||||
apiFetchMock: vi.fn(),
|
||||
useQueryClientMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async () => {
|
||||
const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
|
||||
return {
|
||||
...actual,
|
||||
useQueryClient: () => useQueryClientMock(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/api-fetch', () => ({
|
||||
apiFetch: apiFetchMock,
|
||||
}))
|
||||
|
||||
import { useAssetActions } from '@/lib/query/hooks/useAssets'
|
||||
|
||||
function getOverlay(
|
||||
queryClient: QueryClient,
|
||||
projectId: string,
|
||||
key: string,
|
||||
) {
|
||||
const map = queryClient.getQueryData<TaskTargetOverlayMap>(
|
||||
queryKeys.tasks.targetStateOverlay(projectId),
|
||||
) || {}
|
||||
return map[key] || null
|
||||
}
|
||||
|
||||
function createOkResponse() {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('useAssetActions.generate optimistic overlay', () => {
|
||||
beforeEach(() => {
|
||||
useQueryClientMock.mockReset()
|
||||
apiFetchMock.mockReset()
|
||||
apiFetchMock.mockResolvedValue(createOkResponse())
|
||||
})
|
||||
|
||||
it('keeps global prop in generating state immediately after submit', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
useQueryClientMock.mockReturnValue(queryClient)
|
||||
|
||||
const actions = useAssetActions({ scope: 'global', kind: 'prop' })
|
||||
await actions.generate({ id: 'prop-1' })
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledWith('/api/assets/prop-1/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scope: 'global',
|
||||
kind: 'prop',
|
||||
projectId: undefined,
|
||||
id: 'prop-1',
|
||||
}),
|
||||
})
|
||||
|
||||
const overlay = getOverlay(queryClient, 'global-asset-hub', 'GlobalLocation:prop-1')
|
||||
expect(overlay?.phase).toBe('queued')
|
||||
expect(overlay?.intent).toBe('generate')
|
||||
})
|
||||
|
||||
it('targets project prop generation overlay at the shared location-image task target', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
useQueryClientMock.mockReturnValue(queryClient)
|
||||
|
||||
const actions = useAssetActions({
|
||||
scope: 'project',
|
||||
projectId: 'project-1',
|
||||
kind: 'prop',
|
||||
})
|
||||
await actions.generate({ id: 'prop-2' })
|
||||
|
||||
const overlay = getOverlay(queryClient, 'project-1', 'LocationImage:prop-2')
|
||||
expect(overlay?.phase).toBe('queued')
|
||||
expect(overlay?.intent).toBe('generate')
|
||||
})
|
||||
|
||||
it('clears the overlay when prop generation submission fails', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
useQueryClientMock.mockReturnValue(queryClient)
|
||||
apiFetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
} as Response)
|
||||
|
||||
const actions = useAssetActions({ scope: 'global', kind: 'prop' })
|
||||
|
||||
await expect(actions.generate({ id: 'prop-3' })).rejects.toThrow('Failed to generate asset render')
|
||||
|
||||
const overlay = getOverlay(queryClient, 'global-asset-hub', 'GlobalLocation:prop-3')
|
||||
expect(overlay).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ describe('select prop prompt template', () => {
|
||||
|
||||
expect(template).toContain('关键剧情道具资产分析师')
|
||||
expect(template).toContain('宁缺毋滥')
|
||||
expect(template).toContain('必须在剧情中承担明确功能')
|
||||
expect(template).toContain('必须有明确剧情作用')
|
||||
expect(template).toContain('如果不确定它是否值得进入资产库,直接不输出')
|
||||
expect(template).toContain('仅因外观具体、名词明确,不足以成为关键道具')
|
||||
})
|
||||
@@ -17,7 +17,7 @@ describe('select prop prompt template', () => {
|
||||
|
||||
expect(template).toContain('key story prop extractor')
|
||||
expect(template).toContain('Be conservative')
|
||||
expect(template).toContain('clear story function')
|
||||
expect(template).toContain('explicit story function')
|
||||
expect(template).toContain('If you are unsure whether it deserves an asset entry, do not output it')
|
||||
expect(template).toContain('A specific-looking noun is not enough')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user