feat: add home page and refactor workspace entry UI

This commit is contained in:
saturn
2026-03-23 17:45:17 +08:00
parent a6ad11b9c4
commit 4e469074e0
48 changed files with 2970 additions and 453 deletions

View File

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

View File

@@ -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 字符串值中的引号统一替换为「」。
输入文本:

View File

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

View File

@@ -123,8 +123,8 @@
"5_4": "Horizontal · Banner",
"21_9": "Ultrawide · Cinema feel"
},
"visualStyle": "Visual Style",
"visualStyleHint": "Pick a style that matches your audience — e.g. Realistic for liveaction, 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",

View 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"
}
}

View File

@@ -3,7 +3,8 @@
"subtitle": "默认沿用设置中心的全局配置,也可为当前项目单独自定义,修改仅对本项目生效。",
"saved": "已保存",
"autoSave": "自动保存",
"visualStyle": "视觉风格",
"visualSettings": "画面设置",
"visualStyle": "画面风格",
"modelParams": "模型参数",
"aspectRatio": "画面比例",
"ttsSettings": "旁白配置",

15
messages/zh/home.json Normal file
View File

@@ -0,0 +1,15 @@
{
"title": "快速开始",
"subtitle": "描述你想要创作的故事AI 为你智能生成影视短剧",
"inputPlaceholder": "输入你的故事创意、小说片段或剧本大纲...",
"startCreation": "开始创作",
"recentProjects": "最近项目",
"viewAll": "查看全部项目",
"noProjects": "还没有项目,从上方开始你的第一个创作吧",
"ago": {
"justNow": "刚刚",
"minutesAgo": "{n}分钟前",
"hoursAgo": "{n}小时前",
"daysAgo": "{n}天前"
}
}

View File

@@ -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": "请先选择剧集",

View 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}天前"
}
}

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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),
}
}

View 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,
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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=""')
})

View 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',
},
})
})
})

View 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',
})
})
})

View 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"')
})
})

View 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',
})
})
})

View File

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

View 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()
})
})

View 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()
})
})

View File

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