feat: add home page and refactor workspace entry UI
This commit is contained in:
@@ -1,12 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import NovelInputStage from './NovelInputStage'
|
||||
import SmartImportWizard from './SmartImportWizard'
|
||||
import { useWorkspaceStageRuntime } from '../WorkspaceStageRuntimeContext'
|
||||
import { useWorkspaceEpisodeStageData } from '../hooks/useWorkspaceEpisodeStageData'
|
||||
import type { SplitEpisode } from './smart-import/types'
|
||||
|
||||
/**
|
||||
* 配置阶段 — 整合 NovelInputStage + 长文本智能分集
|
||||
*
|
||||
* 当用户输入长文本(>1000字)并点击"开始创作"时,
|
||||
* 弹出引导卡片建议使用智能分集。
|
||||
* 选择"智能分集"后,直接进入 SmartImportWizard 的分析流程。
|
||||
*/
|
||||
export default function ConfigStage() {
|
||||
const runtime = useWorkspaceStageRuntime()
|
||||
const { episodeName, novelText } = useWorkspaceEpisodeStageData()
|
||||
const params = useParams<{ projectId: string }>()
|
||||
const projectId = params?.projectId ?? ''
|
||||
|
||||
// 智能分集模式
|
||||
const [smartSplitMode, setSmartSplitMode] = useState(false)
|
||||
const [smartSplitText, setSmartSplitText] = useState('')
|
||||
|
||||
const handleSmartSplit = useCallback((text: string) => {
|
||||
setSmartSplitText(text)
|
||||
setSmartSplitMode(true)
|
||||
}, [])
|
||||
|
||||
const handleSmartSplitComplete = useCallback((episodes: SplitEpisode[], triggerGlobalAnalysis?: boolean) => {
|
||||
// 分集完成后,刷新页面以加载新的剧集数据
|
||||
// 通过 window.location.reload 简单处理,因为分集会重新创建所有剧集
|
||||
void episodes
|
||||
void triggerGlobalAnalysis
|
||||
window.location.reload()
|
||||
}, [])
|
||||
|
||||
// 如果已进入智能分集模式,显示 SmartImportWizard
|
||||
if (smartSplitMode) {
|
||||
return (
|
||||
<SmartImportWizard
|
||||
projectId={projectId}
|
||||
onManualCreate={() => setSmartSplitMode(false)}
|
||||
onImportComplete={handleSmartSplitComplete}
|
||||
initialRawContent={smartSplitText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NovelInputStage
|
||||
@@ -20,6 +62,7 @@ export default function ConfigStage() {
|
||||
onVideoRatioChange={runtime.onVideoRatioChange}
|
||||
onArtStyleChange={runtime.onArtStyleChange}
|
||||
onNext={runtime.onRunStoryToScript}
|
||||
onSmartSplit={handleSmartSplit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,184 +6,18 @@
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import '@/styles/animations.css'
|
||||
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon, RatioPreviewIcon } from '@/components/ui/icons'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||
|
||||
/**
|
||||
* RatioIcon - 比例预览图标组件
|
||||
* 需求:所有比例选项的图标永远保持蓝色,帮助用户建立比例视觉记忆
|
||||
*/
|
||||
function RatioIcon({ ratio, size = 24, selected = false }: { ratio: string; size?: number; selected?: boolean }) {
|
||||
// 始终以选中态渲染图标,但仍保留 selected 参数以满足类型与未来扩展
|
||||
return <RatioPreviewIcon ratio={ratio} size={size} selected={selected || true} />
|
||||
}
|
||||
/** 触发智能分集建议的字数阈值 */
|
||||
const LONG_TEXT_THRESHOLD = 1000
|
||||
|
||||
/**
|
||||
* RatioSelector - 比例选择下拉组件
|
||||
*/
|
||||
function RatioSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
getUsage
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: { value: string; label: string; recommended?: boolean }[]
|
||||
getUsage?: (ratio: string) => string
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const t = useTranslations('novelPromotion')
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find(o => o.value === value)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* 触发按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<RatioIcon ratio={value} size={20} selected />
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption?.label || value}</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* 下拉面板 - 横向网格布局 */}
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar" style={{ minWidth: '280px' }}>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-[var(--glass-bg-muted)]/70 transition-colors ${value === option.value
|
||||
? 'bg-[var(--glass-tone-info-bg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<RatioIcon ratio={option.value} size={28} selected={value === option.value} />
|
||||
<span className={`flex flex-col items-center gap-1 text-xs ${value === option.value ? 'text-[var(--glass-tone-info-fg)] font-medium' : 'text-[var(--glass-text-secondary)]'}`}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{option.label}</span>
|
||||
{option.recommended && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-[var(--glass-tone-info-bg)] text-[10px] text-[var(--glass-tone-info-fg)] font-semibold">
|
||||
{t('smartImport.smartImport.recommended')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{getUsage && (
|
||||
<span className="text-[10px] font-normal text-[var(--glass-text-tertiary)] leading-snug text-center">
|
||||
{getUsage(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* StyleSelector - 视觉风格选择抽屉组件
|
||||
*/
|
||||
function StyleSelector({
|
||||
value,
|
||||
onChange,
|
||||
options
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: { value: string; label: string; recommended?: boolean }[]
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const t = useTranslations('novelPromotion')
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find(o => o.value === value) || options[0]
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* 触发按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* 下拉面板 */}
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center p-3 rounded-lg text-left transition-all ${value === option.value
|
||||
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-[0_0_0_1px_rgba(79,128,255,0.35)]'
|
||||
: 'hover:bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 font-medium text-sm">
|
||||
<span>{option.label}</span>
|
||||
{option.recommended && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-[var(--glass-tone-info-bg)] text-[10px] text-[var(--glass-tone-info-fg)] font-semibold">
|
||||
{t('smartImport.smartImport.recommended')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NovelInputStageProps {
|
||||
// 核心数据
|
||||
@@ -193,6 +27,8 @@ interface NovelInputStageProps {
|
||||
// 回调函数
|
||||
onNovelTextChange: (value: string) => void
|
||||
onNext: () => void
|
||||
/** 触发智能分集流程(携带当前文本) */
|
||||
onSmartSplit?: (text: string) => void
|
||||
// 状态
|
||||
isSubmittingTask?: boolean
|
||||
isSwitchingStage?: boolean
|
||||
@@ -211,6 +47,7 @@ export default function NovelInputStage({
|
||||
episodeName,
|
||||
onNovelTextChange,
|
||||
onNext,
|
||||
onSmartSplit,
|
||||
isSubmittingTask = false,
|
||||
isSwitchingStage = false,
|
||||
enableNarration = false,
|
||||
@@ -257,6 +94,17 @@ export default function NovelInputStage({
|
||||
}
|
||||
|
||||
const hasContent = localText.trim().length > 0
|
||||
const [showLongTextPrompt, setShowLongTextPrompt] = useState(false)
|
||||
|
||||
/** 点击"开始创作"时,先检测文本长度 */
|
||||
const handleStartClick = useCallback(() => {
|
||||
const textLength = localText.trim().length
|
||||
if (textLength > LONG_TEXT_THRESHOLD && onSmartSplit) {
|
||||
setShowLongTextPrompt(true)
|
||||
} else {
|
||||
onNext()
|
||||
}
|
||||
}, [localText, onNext, onSmartSplit])
|
||||
|
||||
// 当前配置展示文案
|
||||
const ratioDisplayLabel = (VIDEO_RATIOS.find((option) => option.value === videoRatio) ?? VIDEO_RATIOS[0])?.label
|
||||
@@ -319,9 +167,9 @@ export default function NovelInputStage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主输入区域 */}
|
||||
<div className="glass-surface-elevated overflow-hidden">
|
||||
<div className="p-6">
|
||||
{/* 主输入区域(含底部工具栏) */}
|
||||
<div className="glass-surface-elevated overflow-hidden relative z-10">
|
||||
<div className="p-6 pb-0">
|
||||
{/* 字数统计 */}
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
<span className="glass-chip glass-chip-neutral text-xs">
|
||||
@@ -335,108 +183,86 @@ export default function NovelInputStage({
|
||||
onChange={handleTextChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
placeholder={`请输入您的剧本或小说内容...
|
||||
|
||||
AI 将根据您的文本智能分析:
|
||||
• 自动识别场景切换
|
||||
• 提取角色对话和动作
|
||||
• 生成分镜脚本
|
||||
|
||||
例如:
|
||||
清晨,阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起,看了一眼床头的闹钟——已经八点了!他猛地跳下床,手忙脚乱地开始穿衣服...`}
|
||||
placeholder={`请输入您的剧本或小说内容...\n\nAI 将根据您的文本智能分析:\n• 自动识别场景切换\n• 提取角色对话和动作\n• 生成分镜脚本\n\n例如:\n清晨,阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起,看了一眼床头的闹钟——已经八点了!他猛地跳下床,手忙脚乱地开始穿衣服...`}
|
||||
className="glass-textarea-base custom-scrollbar h-80 px-4 py-3 text-base resize-none placeholder:text-[var(--glass-text-tertiary)]"
|
||||
disabled={isSubmittingTask || isSwitchingStage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 资产库引导提示 */}
|
||||
<div className="mt-5 p-4 glass-surface-soft">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 glass-surface-soft rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<AppIcon name="folderCards" className="w-5 h-5 text-[var(--glass-text-secondary)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[var(--glass-text-secondary)] mb-1">{t("storyInput.assetLibraryTip.title")}</div>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)] leading-relaxed">
|
||||
{t("storyInput.assetLibraryTip.description")}
|
||||
</p>
|
||||
</div>
|
||||
{/* 底部工具栏:比例 + 风格 + 开始创作(内嵌在输入框卡片内) */}
|
||||
<div className="flex items-end gap-3 px-6 py-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={(value) => onVideoRatioChange?.(value)}
|
||||
options={VIDEO_RATIOS.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === '9:16'
|
||||
}))}
|
||||
getUsage={getRatioUsageTag}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={(value) => onArtStyleChange?.(value)}
|
||||
options={ART_STYLES.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === 'realistic'
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStartClick}
|
||||
disabled={!hasContent || isSubmittingTask || isSwitchingStage}
|
||||
className="glass-btn-base glass-btn-primary px-5 py-2.5 text-sm flex-shrink-0 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSwitchingStage ? (
|
||||
<TaskStatusInline state={stageSwitchingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<span>{t("smartImport.manualCreate.button")}</span>
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 配置提示 */}
|
||||
<div className="px-6 pb-4 space-y-1 text-center">
|
||||
<p className="text-xs text-[var(--glass-text-secondary)]">
|
||||
{t("storyInput.currentConfigSummary", {
|
||||
ratio: ratioDisplayLabel,
|
||||
style: artStyleDisplayLabel
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{t("storyInput.moreConfig")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 资产库引导提示 */}
|
||||
<div className="glass-surface p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 glass-surface-soft rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<AppIcon name="folderCards" className="w-5 h-5 text-[var(--glass-text-secondary)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[var(--glass-text-secondary)] mb-1">{t("storyInput.assetLibraryTip.title")}</div>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)] leading-relaxed">
|
||||
{t("storyInput.assetLibraryTip.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画面比例与视觉风格配置 */}
|
||||
<div className="glass-surface p-6 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 画面比例 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-muted)] tracking-[0.01em]">
|
||||
{t("storyInput.videoRatio")}
|
||||
</h3>
|
||||
<div className="relative inline-flex items-center group">
|
||||
<div className="w-4 h-4 flex items-center justify-center rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] shadow-sm">
|
||||
<AppIcon name="info" className="w-3 h-3" />
|
||||
</div>
|
||||
<div className="pointer-events-none absolute left-1/2 top-full mt-2 -translate-x-1/2 opacity-0 translate-y-1 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-150 z-20">
|
||||
<div
|
||||
className="rounded-lg border bg-[var(--glass-bg-surface-strong)]/95 border-[var(--glass-tone-info-bg)] px-3.5 py-2.5 text-xs leading-relaxed text-[var(--glass-text-primary)] shadow-[0_18px_45px_rgba(15,23,42,0.55)] whitespace-pre-wrap"
|
||||
style={{ minWidth: 220 }}
|
||||
>
|
||||
{ratioUsageText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{t("storyInput.videoRatioHint")}
|
||||
</p>
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={(value) => onVideoRatioChange?.(value)}
|
||||
options={VIDEO_RATIOS.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === '9:16'
|
||||
}))}
|
||||
getUsage={getRatioUsageTag}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 视觉风格 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-muted)] tracking-[0.01em]">{t("storyInput.visualStyle")}</h3>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{t("storyInput.visualStyleHint")}
|
||||
</p>
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={(value) => onArtStyleChange?.(value)}
|
||||
options={ART_STYLES.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === 'realistic'
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--glass-text-secondary)] mt-4 text-center">
|
||||
{t("storyInput.currentConfigSummary", {
|
||||
ratio: ratioDisplayLabel,
|
||||
style: artStyleDisplayLabel
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)] mt-1 text-center">
|
||||
{t("storyInput.assetLibraryRatioNote")}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)] mt-1 text-center">
|
||||
{t("storyInput.moreConfig")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 旁白开关 + 操作按钮 */}
|
||||
<div className="glass-surface p-6">
|
||||
{/* 旁白开关 */}
|
||||
{onEnableNarrationChange && (
|
||||
<div className="glass-surface-soft flex items-center justify-between p-4 rounded-xl mb-6">
|
||||
{/* 旁白开关 */}
|
||||
{onEnableNarrationChange && (
|
||||
<div className="glass-surface p-6">
|
||||
<div className="glass-surface-soft flex items-center justify-between p-4 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] font-semibold text-sm">VO</span>
|
||||
<div>
|
||||
@@ -457,27 +283,91 @@ AI 将根据您的文本智能分析:
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 开始创作按钮 */}
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!hasContent || isSubmittingTask || isSwitchingStage}
|
||||
className="glass-btn-base glass-btn-primary w-full py-4 text-white font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSwitchingStage ? (
|
||||
<TaskStatusInline state={stageSwitchingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<span>{t("smartImport.manualCreate.button")}</span>
|
||||
<AppIcon name="arrowRight" className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<p className="text-center text-xs text-[var(--glass-text-tertiary)] mt-3">
|
||||
{hasContent ? t("storyInput.ready") : t("storyInput.pleaseInput")}
|
||||
</p>
|
||||
</div>
|
||||
{/* 长文本检测 — 智能分集强引导弹窗 */}
|
||||
{showLongTextPrompt && (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg mx-4 relative">
|
||||
{/* 渐变描边外壳 */}
|
||||
<div
|
||||
className="rounded-2xl p-[1.5px]"
|
||||
style={{ background: 'linear-gradient(135deg, #3b82f6, #8b5cf6, #06b6d4)' }}
|
||||
>
|
||||
<div className="glass-surface-modal rounded-2xl p-6 space-y-5">
|
||||
{/* 标题行 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15))' }}
|
||||
>
|
||||
<AppIcon name="sparkles" className="w-5 h-5 text-[#7c3aed]" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">
|
||||
{t('storyInput.longTextDetection.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<p className="text-sm text-[var(--glass-text-secondary)] leading-relaxed">
|
||||
{t('storyInput.longTextDetection.description', { count: localText.trim().length.toLocaleString() })}
|
||||
</p>
|
||||
|
||||
{/* 强烈推荐文案 */}
|
||||
<div
|
||||
className="p-4 rounded-xl text-sm leading-relaxed"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(139,92,246,0.08))' }}
|
||||
>
|
||||
<p
|
||||
className="font-semibold"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{t('storyInput.longTextDetection.strongRecommend')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 按钮区域 */}
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
{/* 智能分集 — 主按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLongTextPrompt(false)
|
||||
onSmartSplit?.(localText)
|
||||
}}
|
||||
className="w-full py-3.5 rounded-xl text-white font-semibold text-base flex items-center justify-center gap-2 transition-all hover:opacity-90 active:scale-[0.98]"
|
||||
style={{ background: 'linear-gradient(135deg, #3b82f6, #7c3aed)' }}
|
||||
>
|
||||
<AppIcon name="sparkles" className="w-5 h-5" />
|
||||
<span>{t('storyInput.longTextDetection.smartSplit')}</span>
|
||||
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
|
||||
{t('storyInput.longTextDetection.smartSplitRecommend')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 直接创作 — 弱化按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLongTextPrompt(false)
|
||||
onNext()
|
||||
}}
|
||||
className="w-full py-2.5 text-sm text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors"
|
||||
>
|
||||
{t('storyInput.longTextDetection.continueAnyway')}
|
||||
<span className="text-xs ml-1 opacity-60">
|
||||
— {t('storyInput.longTextDetection.singleEpisodeWarning')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ interface SmartImportWizardProps {
|
||||
onImportComplete: (episodes: SplitEpisode[], triggerGlobalAnalysis?: boolean) => void
|
||||
projectId: string
|
||||
importStatus?: string | null
|
||||
/** 预填文本:传入后自动跳过选择页,直接开始分析 */
|
||||
initialRawContent?: string
|
||||
}
|
||||
|
||||
export default function SmartImportWizard({
|
||||
@@ -23,9 +25,10 @@ export default function SmartImportWizard({
|
||||
onImportComplete,
|
||||
projectId,
|
||||
importStatus,
|
||||
initialRawContent,
|
||||
}: SmartImportWizardProps) {
|
||||
const t = useTranslations('smartImport')
|
||||
const wizard = useWizardState({ projectId, importStatus, onImportComplete, t })
|
||||
const wizard = useWizardState({ projectId, importStatus, onImportComplete, t, initialRawContent })
|
||||
|
||||
const savingTaskState = wizard.saving
|
||||
? resolveTaskPresentationState({
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { useAnalyzeProjectGlobalAssets } from '@/lib/query/hooks'
|
||||
import { useTaskTargetStateMap, type TaskTargetState } from '@/lib/query/hooks/useTaskTargetStateMap'
|
||||
import { clearTaskTargetOverlay, upsertTaskTargetOverlay } from '@/lib/query/task-target-overlay'
|
||||
import { waitForTaskResult } from '@/lib/task/client'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
type ToastType = 'success' | 'warning' | 'error'
|
||||
|
||||
@@ -22,6 +26,52 @@ interface UseAssetsGlobalActionsParams {
|
||||
|
||||
const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
|
||||
|
||||
type GlobalAnalyzeTaskSnapshot = Pick<TaskTargetState, 'phase' | 'runningTaskId' | 'lastError'> | null
|
||||
|
||||
function isRunningPhase(phase: TaskTargetState['phase'] | null | undefined): boolean {
|
||||
return phase === 'queued' || phase === 'processing'
|
||||
}
|
||||
|
||||
export function isGlobalAnalyzeTaskRunning(taskState: GlobalAnalyzeTaskSnapshot): boolean {
|
||||
return isRunningPhase(taskState?.phase)
|
||||
}
|
||||
|
||||
export function resolveGlobalAnalyzeCompletion(
|
||||
previousRunningTaskId: string | null,
|
||||
taskState: GlobalAnalyzeTaskSnapshot,
|
||||
) {
|
||||
const isRunning = isGlobalAnalyzeTaskRunning(taskState)
|
||||
if (isRunning) {
|
||||
return {
|
||||
status: 'running' as const,
|
||||
finishedTaskId: null,
|
||||
errorMessage: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!previousRunningTaskId) {
|
||||
return {
|
||||
status: 'idle' as const,
|
||||
finishedTaskId: null,
|
||||
errorMessage: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (taskState?.phase === 'failed' || taskState?.lastError) {
|
||||
return {
|
||||
status: 'failed' as const,
|
||||
finishedTaskId: previousRunningTaskId,
|
||||
errorMessage: taskState?.lastError?.message ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'succeeded' as const,
|
||||
finishedTaskId: previousRunningTaskId,
|
||||
errorMessage: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAssetsGlobalActions({
|
||||
projectId,
|
||||
triggerGlobalAnalyze = false,
|
||||
@@ -30,45 +80,114 @@ export function useAssetsGlobalActions({
|
||||
showToast,
|
||||
t,
|
||||
}: UseAssetsGlobalActionsParams) {
|
||||
const queryClient = useQueryClient()
|
||||
const analyzeGlobalAssets = useAnalyzeProjectGlobalAssets(projectId)
|
||||
const [isGlobalAnalyzing, setIsGlobalAnalyzing] = useState(false)
|
||||
const hasTriggeredGlobalAnalyze = useRef(false)
|
||||
const lastRunningTaskIdRef = useRef<string | null>(null)
|
||||
const lastHandledTaskIdRef = useRef<string | null>(null)
|
||||
const isSubmittingRef = useRef(false)
|
||||
const globalAnalyzeTaskStateQuery = useTaskTargetStateMap(
|
||||
projectId,
|
||||
[{
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: projectId,
|
||||
types: ['analyze_global'],
|
||||
}],
|
||||
{
|
||||
enabled: projectId.length > 0,
|
||||
staleTime: 2_000,
|
||||
},
|
||||
)
|
||||
const globalAnalyzeTaskState = globalAnalyzeTaskStateQuery.getState('NovelPromotionProject', projectId)
|
||||
const isGlobalAnalyzing = isGlobalAnalyzeTaskRunning(globalAnalyzeTaskState)
|
||||
|
||||
const globalAnalyzingState = useMemo(() => {
|
||||
if (!isGlobalAnalyzing) return null
|
||||
return resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'generate',
|
||||
phase: globalAnalyzeTaskState?.phase ?? 'processing',
|
||||
intent: globalAnalyzeTaskState?.intent ?? 'analyze',
|
||||
resource: 'text',
|
||||
hasOutput: false,
|
||||
})
|
||||
}, [isGlobalAnalyzing])
|
||||
}, [globalAnalyzeTaskState?.intent, globalAnalyzeTaskState?.phase, isGlobalAnalyzing])
|
||||
|
||||
const handleGlobalAnalyze = useCallback(async () => {
|
||||
if (isGlobalAnalyzing) return
|
||||
if (isGlobalAnalyzing || isSubmittingRef.current) return
|
||||
|
||||
try {
|
||||
setIsGlobalAnalyzing(true)
|
||||
isSubmittingRef.current = true
|
||||
upsertTaskTargetOverlay(queryClient, {
|
||||
projectId,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: projectId,
|
||||
runningTaskType: 'analyze_global',
|
||||
intent: 'analyze',
|
||||
})
|
||||
showToast(t('toolbar.globalAnalyzing'), 'warning', 60000)
|
||||
|
||||
const data = await analyzeGlobalAssets.mutateAsync()
|
||||
await Promise.resolve(onRefresh())
|
||||
|
||||
showToast(
|
||||
t('toolbar.globalAnalyzeSuccess', {
|
||||
characters: data.stats?.newCharacters || 0,
|
||||
locations: data.stats?.newLocations || 0,
|
||||
}),
|
||||
'success',
|
||||
5000,
|
||||
)
|
||||
const submission = await analyzeGlobalAssets.mutateAsync()
|
||||
lastRunningTaskIdRef.current = submission.taskId
|
||||
} catch (error: unknown) {
|
||||
clearTaskTargetOverlay(queryClient, {
|
||||
projectId,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: projectId,
|
||||
})
|
||||
_ulogError('Global analyze error:', error)
|
||||
showToast(`${t('toolbar.globalAnalyzeFailed')}: ${getErrorMessage(error)}`, 'error', 5000)
|
||||
} finally {
|
||||
setIsGlobalAnalyzing(false)
|
||||
isSubmittingRef.current = false
|
||||
}
|
||||
}, [analyzeGlobalAssets, isGlobalAnalyzing, onRefresh, showToast, t])
|
||||
}, [analyzeGlobalAssets, isGlobalAnalyzing, projectId, queryClient, showToast, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (isGlobalAnalyzing && globalAnalyzeTaskState?.runningTaskId) {
|
||||
lastRunningTaskIdRef.current = globalAnalyzeTaskState.runningTaskId
|
||||
}
|
||||
}, [globalAnalyzeTaskState?.runningTaskId, isGlobalAnalyzing])
|
||||
|
||||
useEffect(() => {
|
||||
const completion = resolveGlobalAnalyzeCompletion(lastRunningTaskIdRef.current, globalAnalyzeTaskState)
|
||||
if (completion.status === 'running' || completion.status === 'idle' || !completion.finishedTaskId) {
|
||||
return
|
||||
}
|
||||
if (lastHandledTaskIdRef.current === completion.finishedTaskId) {
|
||||
return
|
||||
}
|
||||
|
||||
lastHandledTaskIdRef.current = completion.finishedTaskId
|
||||
lastRunningTaskIdRef.current = null
|
||||
|
||||
void (async () => {
|
||||
if (completion.status === 'failed') {
|
||||
showToast(
|
||||
`${t('toolbar.globalAnalyzeFailed')}: ${completion.errorMessage || t('toolbar.globalAnalyzeFailed')}`,
|
||||
'error',
|
||||
5000,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await waitForTaskResult(completion.finishedTaskId, {
|
||||
intervalMs: 100,
|
||||
timeoutMs: 2_000,
|
||||
}) as { stats?: { newCharacters?: number; newLocations?: number } }
|
||||
await Promise.resolve(onRefresh())
|
||||
showToast(
|
||||
t('toolbar.globalAnalyzeSuccess', {
|
||||
characters: result.stats?.newCharacters || 0,
|
||||
locations: result.stats?.newLocations || 0,
|
||||
}),
|
||||
'success',
|
||||
5000,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
_ulogError('Global analyze finalize error:', error)
|
||||
showToast(`${t('toolbar.globalAnalyzeFailed')}: ${getErrorMessage(error)}`, 'error', 5000)
|
||||
}
|
||||
})()
|
||||
}, [globalAnalyzeTaskState, onRefresh, showToast, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerGlobalAnalyze || hasTriggeredGlobalAnalyze.current || isGlobalAnalyzing) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { logInfo as _ulogInfo, logWarn as _ulogWarn, logError as _ulogError } from '@/lib/logging/core'
|
||||
import { detectEpisodeMarkers, type EpisodeMarkerResult } from '@/lib/episode-marker-detector'
|
||||
import { countWords } from '@/lib/word-count'
|
||||
@@ -20,12 +20,14 @@ interface UseWizardStateParams {
|
||||
importStatus?: string | null
|
||||
onImportComplete: (episodes: SplitEpisode[], triggerGlobalAnalysis?: boolean) => void
|
||||
t: Translate
|
||||
/** 预填文本:传入后自动设置并触发分析 */
|
||||
initialRawContent?: string
|
||||
}
|
||||
|
||||
export function useWizardState({ projectId, importStatus, onImportComplete, t }: UseWizardStateParams) {
|
||||
export function useWizardState({ projectId, importStatus, onImportComplete, t, initialRawContent }: UseWizardStateParams) {
|
||||
const initialStage: WizardStage = importStatus === 'pending' ? 'preview' : 'select'
|
||||
const [stage, setStage] = useState<WizardStage>(initialStage)
|
||||
const [rawContent, setRawContent] = useState('')
|
||||
const [rawContent, setRawContent] = useState(initialRawContent || '')
|
||||
const [episodes, setEpisodes] = useState<SplitEpisode[]>([])
|
||||
const [selectedEpisode, setSelectedEpisode] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -64,6 +66,7 @@ export function useWizardState({ projectId, importStatus, onImportComplete, t }:
|
||||
}
|
||||
}, [episodes.length, importStatus, loadSavedEpisodes])
|
||||
|
||||
|
||||
const performAISplit = useCallback(async () => {
|
||||
setShowMarkerConfirm(false)
|
||||
setStage('analyzing')
|
||||
@@ -131,6 +134,16 @@ export function useWizardState({ projectId, importStatus, onImportComplete, t }:
|
||||
await performAISplit()
|
||||
}, [performAISplit, projectId, rawContent, t])
|
||||
|
||||
// 当预填文本存在时,自动触发分析(跳过选择页面)
|
||||
const autoAnalyzeTriggered = useRef(false)
|
||||
useEffect(() => {
|
||||
if (initialRawContent && !autoAnalyzeTriggered.current && stage === 'select') {
|
||||
autoAnalyzeTriggered.current = true
|
||||
void handleAnalyze()
|
||||
}
|
||||
}) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
const handleMarkerSplit = useCallback(async () => {
|
||||
if (!markerResult) return
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useWorkspaceProjectSnapshot } from './useWorkspaceProjectSnapshot'
|
||||
import { useWorkspaceModalEscape } from './useWorkspaceModalEscape'
|
||||
import { useWorkspaceStageRuntime } from './useWorkspaceStageRuntime'
|
||||
import { useWorkspaceConfigActions } from './useWorkspaceConfigActions'
|
||||
import { useWorkspaceAutoRun } from './useWorkspaceAutoRun'
|
||||
import { buildWorkspaceControllerViewModel } from './workspace-controller-view-model'
|
||||
import type { NovelPromotionWorkspaceProps } from '../types'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
@@ -111,6 +112,10 @@ export function useNovelPromotionWorkspaceController({
|
||||
|
||||
const isStartingStoryToScript = rebuildState.pendingActionType === 'storyToScript'
|
||||
const isStartingScriptToStoryboard = rebuildState.pendingActionType === 'scriptToStoryboard'
|
||||
const isStoryToScriptRunning =
|
||||
execution.storyToScriptStream.isRunning ||
|
||||
execution.storyToScriptStream.isRecoveredRunning ||
|
||||
execution.storyToScriptStream.status === 'running'
|
||||
|
||||
const isAnyOperationRunning =
|
||||
isStartingStoryToScript ||
|
||||
@@ -122,6 +127,17 @@ export function useNovelPromotionWorkspaceController({
|
||||
execution.storyToScriptStream.isRunning ||
|
||||
execution.scriptToStoryboardStream.isRunning
|
||||
|
||||
useWorkspaceAutoRun({
|
||||
searchParams,
|
||||
router,
|
||||
episodeId,
|
||||
novelText: projectSnapshot.novelText,
|
||||
isTransitioning: execution.isTransitioning,
|
||||
isStoryToScriptRunning,
|
||||
runWithRebuildConfirm: rebuildState.runWithRebuildConfirm,
|
||||
runStoryToScriptFlow: execution.runStoryToScriptFlow,
|
||||
})
|
||||
|
||||
const capsuleNavItems = useWorkspaceStageNavigation({
|
||||
isAnyOperationRunning,
|
||||
episode,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface SearchParamsLike {
|
||||
get: (name: string) => string | null
|
||||
toString: () => string
|
||||
}
|
||||
|
||||
interface RouterLike {
|
||||
replace: (href: string, options?: { scroll?: boolean }) => void
|
||||
}
|
||||
|
||||
interface UseWorkspaceAutoRunParams {
|
||||
searchParams: SearchParamsLike | null
|
||||
router: RouterLike
|
||||
episodeId?: string
|
||||
novelText: string
|
||||
isTransitioning: boolean
|
||||
isStoryToScriptRunning: boolean
|
||||
runWithRebuildConfirm: (
|
||||
action: 'storyToScript' | 'scriptToStoryboard',
|
||||
operation: () => Promise<void>,
|
||||
) => Promise<void>
|
||||
runStoryToScriptFlow: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useWorkspaceAutoRun({
|
||||
searchParams,
|
||||
router,
|
||||
episodeId,
|
||||
novelText,
|
||||
isTransitioning,
|
||||
isStoryToScriptRunning,
|
||||
runWithRebuildConfirm,
|
||||
runStoryToScriptFlow,
|
||||
}: UseWorkspaceAutoRunParams) {
|
||||
const handledAutoRunKeyRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams) return
|
||||
if (searchParams.get('autoRun') !== 'storyToScript') return
|
||||
if (!episodeId) return
|
||||
if (!novelText.trim()) return
|
||||
if (isTransitioning || isStoryToScriptRunning) return
|
||||
|
||||
const autoRunKey = `storyToScript:${episodeId}`
|
||||
if (handledAutoRunKeyRef.current === autoRunKey) {
|
||||
return
|
||||
}
|
||||
handledAutoRunKeyRef.current = autoRunKey
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('autoRun')
|
||||
router.replace(`?${params.toString()}`, { scroll: false })
|
||||
|
||||
void runWithRebuildConfirm('storyToScript', runStoryToScriptFlow)
|
||||
}, [
|
||||
episodeId,
|
||||
isStoryToScriptRunning,
|
||||
isTransitioning,
|
||||
novelText,
|
||||
router,
|
||||
runStoryToScriptFlow,
|
||||
runWithRebuildConfirm,
|
||||
searchParams,
|
||||
])
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import { useParams, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
@@ -135,9 +135,18 @@ export default function ProjectDetailPage() {
|
||||
// 获取导入状态
|
||||
const importStatus = novelPromotionData?.importStatus
|
||||
|
||||
// 检测是否需要显示导入向导:无剧集或导入中
|
||||
// 零状态:无剧集且非导入中 → 自动创建第一集
|
||||
const isZeroState = episodes.length === 0
|
||||
const shouldShowImportWizard = isZeroState || importStatus === 'pending'
|
||||
const shouldShowImportWizard = importStatus === 'pending' // 仅分集预览中才显示 wizard
|
||||
const shouldAutoCreateEpisode = isZeroState && importStatus !== 'pending'
|
||||
const autoCreateTriggered = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoCreateEpisode || autoCreateTriggered.current || loading) return
|
||||
autoCreateTriggered.current = true
|
||||
void handleCreateEpisode(`${t('episode')} 1`)
|
||||
}, [shouldAutoCreateEpisode, loading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const shouldGateImportWizardByModel = shouldShowImportWizard && !isGlobalAssetsView
|
||||
|
||||
useEffect(() => {
|
||||
@@ -489,7 +498,7 @@ export default function ProjectDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 零状态或导入中:显示智能导入向导
|
||||
// 导入中(pending):显示分集预览向导
|
||||
<SmartImportWizard
|
||||
projectId={projectId}
|
||||
onManualCreate={() => handleCreateEpisode(`${t('episode')} 1`)}
|
||||
|
||||
Reference in New Issue
Block a user