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

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