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

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