feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions

This commit is contained in:
saturn
2026-04-02 17:39:16 +08:00
parent c3e74c228a
commit 9703714b69
153 changed files with 4472 additions and 1088 deletions

View File

@@ -42,13 +42,11 @@ function NovelPromotionWorkspaceContent(props: NovelPromotionWorkspaceProps) {
const showStoryToScriptMinBadge =
storyToScriptStream.isVisible &&
storyToScriptStream.stages.length > 0 &&
storyToScriptActive &&
vm.execution.storyToScriptConsoleMinimized
const showScriptToStoryboardMinBadge =
scriptToStoryboardStream.isVisible &&
scriptToStoryboardStream.stages.length > 0 &&
scriptToStoryboardActive &&
vm.execution.scriptToStoryboardConsoleMinimized

View File

@@ -9,6 +9,7 @@ import { useTranslations } from 'next-intl'
import { useState, useRef, useEffect, useCallback } from 'react'
import '@/styles/animations.css'
import AiWriteModal from '@/components/home/AiWriteModal'
import LongTextDetectionPrompt from '@/components/story-input/LongTextDetectionPrompt'
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
import TaskStatusInline from '@/components/task/TaskStatusInline'
@@ -197,7 +198,7 @@ export default function NovelInputStage({
stylePresetValue={stylePresetValue}
onStylePresetChange={setStylePresetValue}
stylePresetOptions={STYLE_PRESETS}
textareaClassName="text-base p-5 pb-3"
textareaClassName="px-0 pt-0 pb-3 align-top"
primaryAction={(
<button
onClick={handleStartClick}
@@ -285,88 +286,29 @@ export default function NovelInputStage({
</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>
)}
<LongTextDetectionPrompt
open={showLongTextPrompt}
copy={{
title: t('storyInput.longTextDetection.title'),
description: t('storyInput.longTextDetection.description', {
count: localText.trim().length.toLocaleString(),
}),
strongRecommend: t('storyInput.longTextDetection.strongRecommend'),
smartSplitLabel: t('storyInput.longTextDetection.smartSplit'),
smartSplitBadge: t('storyInput.longTextDetection.smartSplitRecommend'),
continueLabel: t('storyInput.longTextDetection.continueAnyway'),
continueHint: t('storyInput.longTextDetection.singleEpisodeWarning'),
}}
onClose={() => setShowLongTextPrompt(false)}
onSmartSplit={() => {
setShowLongTextPrompt(false)
onSmartSplit?.(localText)
}}
onContinue={() => {
setShowLongTextPrompt(false)
onNext()
}}
/>
</div>
)
}

View File

@@ -21,7 +21,7 @@ export interface PanelEditData {
cameraMove: string | null
description: string | null
location: string | null
characters: { name: string; appearance: string }[]
characters: { name: string; appearance: string; slot?: string }[]
srtStart: number | null
srtEnd: number | null
duration: number | null
@@ -75,7 +75,7 @@ export default function PanelEditForm({
interface CharacterPickerModalProps {
projectId: string
currentCharacters: { name: string; appearance: string }[]
currentCharacters: { name: string; appearance: string; slot?: string }[]
onSelect: (charName: string, appearance: string) => void
onClose: () => void
}

View File

@@ -5,6 +5,7 @@ import { SettingsModal, WorldContextModal } from '@/components/ui/ConfigModals'
import WorkspaceTopActions from './WorkspaceTopActions'
import type { NovelPromotionPanel } from '@/types/project'
import type { CapabilitySelections, ModelCapabilities } from '@/lib/model-config-contract'
import { resolveEpisodeStageArtifacts } from '@/lib/novel-promotion/stage-readiness'
interface EpisodeSummary {
id: string
@@ -164,15 +165,23 @@ export default function WorkspaceHeaderShell({
return (
<EpisodeSelector
projectName={projectName}
episodes={sorted.map((ep) => ({
id: ep.id,
title: ep.name,
summary: ep.description ?? undefined,
status: {
script: ep.clips?.length ? 'ready' as const : 'empty' as const,
visual: ep.storyboards?.some((sb) => sb.panels?.some((panel) => panel.videoUrl)) ? 'ready' as const : 'empty' as const,
},
}))}
episodes={sorted.map((ep) => {
const stageArtifacts = resolveEpisodeStageArtifacts({
novelText: null,
clips: ep.clips || [],
storyboards: ep.storyboards || [],
voiceLines: [],
})
return {
id: ep.id,
title: ep.name,
summary: ep.description ?? undefined,
status: {
script: stageArtifacts.hasScript ? 'ready' as const : 'empty' as const,
visual: stageArtifacts.hasVideo ? 'ready' as const : 'empty' as const,
},
}
})}
currentId={currentEpisodeId}
onSelect={(id) => onEpisodeSelect?.(id)}
onAdd={onEpisodeCreate}

View File

@@ -65,7 +65,7 @@ export default function WorkspaceRunStreamConsoles({
const showStoryToScriptConsole =
storyToScriptStream.isVisible &&
(storyToScriptStream.stages.length > 0 || !!storyToScriptStream.errorMessage)
(storyToScriptStream.stages.length > 0 || !!storyToScriptStream.errorMessage || storyToScriptActive)
const storyFallbackStatus: LLMStageViewItem['status'] =
storyToScriptStream.status === 'failed' ? 'failed' : 'processing'
const storyToScriptStages = storyToScriptStream.stages.length > 0
@@ -94,7 +94,7 @@ export default function WorkspaceRunStreamConsoles({
storyToScriptSelectedStage?.status === 'processing'
const showScriptToStoryboardConsole =
scriptToStoryboardStream.isVisible &&
(scriptToStoryboardStream.stages.length > 0 || !!scriptToStoryboardStream.errorMessage)
(scriptToStoryboardStream.stages.length > 0 || !!scriptToStoryboardStream.errorMessage || scriptToStoryboardActive)
const storyboardFallbackStatus: LLMStageViewItem['status'] =
scriptToStoryboardStream.status === 'failed' ? 'failed' : 'processing'
const scriptToStoryboardStages = scriptToStoryboardStream.stages.length > 0

View File

@@ -10,6 +10,7 @@ import { useAiCreateProjectLocation, useCreateProjectLocation } from '@/lib/quer
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { AppIcon } from '@/components/ui/icons'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
interface AddLocationModalProps {
projectId: string
@@ -58,6 +59,7 @@ export default function AddLocationModal({
const [description, setDescription] = useState('')
const [aiInstruction, setAiInstruction] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [isAiDesigning, setIsAiDesigning] = useState(false)
const aiDesigningState = isAiDesigning
@@ -87,6 +89,7 @@ export default function AddLocationModal({
userInstruction: aiInstruction,
})
setDescription(data.prompt || '')
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
setAiInstruction('')
} catch (error: unknown) {
if (getErrorStatus(error) === 402) {
@@ -113,6 +116,7 @@ export default function AddLocationModal({
description: description.trim(),
artStyle,
count: locationGenerationCount,
availableSlots,
})
onSuccess()
onClose()

View File

@@ -140,6 +140,7 @@ export default function CharacterCard({
const imageUrlsWithIndex = rawImageUrls
.map((url, idx) => ({ url, originalIndex: idx }))
.filter((item) => !!item.url) as { url: string; originalIndex: number }[]
const generatedImageCount = imageUrlsWithIndex.length
const hasMultipleImages = imageUrlsWithIndex.length > 1
const selectedIndex = appearance.selectedIndex ?? null
@@ -218,22 +219,24 @@ export default function CharacterCard({
<>
<ImageGenerationInlineCountButton
prefix={isGroupTaskRunning ? (
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
) : (
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
)}
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
value={generationCount}
options={getImageGenerationCountOptions('character')}
onValueChange={setGenerationCount}
onClick={() => onRegenerate(generationCount)}
onClick={() => onRegenerate(generatedImageCount)}
disabled={isAppearanceTaskRunning || isAnyTaskRunning || uploadImage.isPending}
ariaLabel={t('image.regenCountAriaLabel')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={t('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{onUndo && (appearance.previousImageUrl || appearance.previousImageUrls.length > 0) && (
<button

View File

@@ -170,7 +170,7 @@ export default function LocationCard({
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
hasRunningTask: isTaskRunning,
requestedCount: generationCount,
requestedCount: generatedImageCount > 1 ? generatedImageCount : generationCount,
})
const displaySlotCount = displaySelectionImages.length
const hasMultipleImages = generatedImageCount > 1
@@ -192,22 +192,24 @@ export default function LocationCard({
<>
<ImageGenerationInlineCountButton
prefix={isGroupTaskRunning ? (
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
) : (
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
)}
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
value={generationCount}
options={getImageGenerationCountOptions('location')}
onValueChange={setGenerationCount}
onClick={() => onRegenerate(generationCount)}
onClick={() => onRegenerate(generatedImageCount)}
disabled={isTaskRunning || isAnyTaskRunning || uploadImage.isPending}
ariaLabel={t('image.regenCountAriaLabel')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={t('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{onUndo && hasPreviousVersion && (
<button

View File

@@ -1,11 +1,15 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import AIDataModalFormPane from './AIDataModalFormPane'
import AIDataModalPreviewPane from './AIDataModalPreviewPane'
import { createPortal } from 'react-dom'
import { AppIcon } from '@/components/ui/icons'
import GlassButton from '@/components/ui/primitives/GlassButton'
import type { AIDataModalProps } from './AIDataModal.types'
import { useAIDataModalState } from './hooks/useAIDataModalState'
import { AppIcon } from '@/components/ui/icons'
import AIDataModalFormPane from './AIDataModalFormPane'
import AIDataModalPreviewPane from './AIDataModalPreviewPane'
import { lockModalPageScroll } from './modal-scroll-lock'
export type {
AIDataModalProps,
@@ -13,6 +17,7 @@ export type {
PhotographyRules,
ActingCharacter,
ActingNotes,
AIDataCharacter,
} from './AIDataModal.types'
export default function AIDataModal({
@@ -32,6 +37,7 @@ export default function AIDataModal({
onSave,
}: AIDataModalProps) {
const t = useTranslations('storyboard')
const [activeCharIdx, setActiveCharIdx] = useState(0)
const {
shotType,
@@ -78,29 +84,49 @@ export default function AIDataModal({
...(actingNotes.length > 0 ? { acting_notes: actingNotes } : {}),
}
if (!isOpen) return null
useEffect(() => {
if (!isOpen || typeof document === 'undefined') return undefined
return lockModalPageScroll(document)
}, [isOpen])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-[var(--glass-overlay)] backdrop-blur-sm" onClick={onClose} />
if (!isOpen || typeof document === 'undefined') return null
<div className="relative bg-[var(--glass-bg-surface)] rounded-2xl shadow-2xl w-[90vw] max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]">
return createPortal(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div className="glass-overlay absolute inset-0" onClick={onClose} />
<div
className="relative z-10 glass-surface-modal w-full max-w-[920px] flex flex-col overflow-hidden"
style={{ maxHeight: '92vh' }}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--glass-stroke-base)] flex-shrink-0">
<div className="flex items-center gap-3">
<span className="text-2xl" />
<div className="flex h-8 w-8 items-center justify-center rounded-[var(--glass-radius-xs)] bg-[var(--glass-tone-info-bg)] flex-shrink-0">
<AppIcon name="clapperboard" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--glass-text-primary)]">{t('aiData.title')}</h2>
<p className="text-xs text-[var(--glass-text-tertiary)]">{t('aiData.subtitle', { number: panelNumber })}</p>
<h2 className="text-sm font-semibold text-[var(--glass-text-primary)] leading-none">
{t('aiData.title')}
</h2>
<p className="text-[11px] text-[var(--glass-text-tertiary)] mt-0.5">
{t('aiData.subtitle', { number: panelNumber })} · {videoRatio}
</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors">
<AppIcon name="close" className="w-5 h-5 text-[var(--glass-text-tertiary)]" />
<button
onClick={onClose}
className="glass-btn-base glass-btn-ghost h-7 w-7 flex-shrink-0"
aria-label={t('common.cancel')}
>
<AppIcon name="close" className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-hidden flex">
{/* Body */}
<div className="flex flex-1 min-h-0 overflow-hidden">
<AIDataModalFormPane
t={(key) => t(key as never)}
t={t}
shotType={shotType}
cameraMove={cameraMove}
description={description}
@@ -109,6 +135,8 @@ export default function AIDataModal({
videoPrompt={videoPrompt}
photographyRules={photographyRules}
actingNotes={actingNotes}
activeCharIdx={activeCharIdx}
onActiveCharIdxChange={setActiveCharIdx}
onShotTypeChange={setShotType}
onCameraMoveChange={setCameraMove}
onDescriptionChange={setDescription}
@@ -117,29 +145,34 @@ export default function AIDataModal({
onPhotographyCharacterChange={updatePhotographyCharacter}
onActingCharacterChange={updateActingCharacter}
/>
<AIDataModalPreviewPane
t={(key) => t(key as never)}
t={t}
previewJson={previewJson}
/>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors"
>
{t('candidate.cancel')}
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm text-white bg-[var(--glass-accent-from)] hover:bg-[var(--glass-accent-to)] rounded-lg transition-colors flex items-center gap-2"
>
<AppIcon name="check" className="w-4 h-4" />
{t('aiData.save')}
</button>
{/* Footer */}
<div className="flex items-center justify-between border-t border-[var(--glass-stroke-base)] px-5 py-3 flex-shrink-0">
<p className="text-[11px] text-[var(--glass-text-tertiary)]">
{characters.map(c => c.name).join('、')}
{location ? ` · ${location}` : ''}
</p>
<div className="flex gap-2">
<GlassButton variant="secondary" size="sm" onClick={onClose}>
{t('common.cancel')}
</GlassButton>
<GlassButton
variant="primary"
size="sm"
onClick={handleSave}
iconLeft={<AppIcon name="check" className="h-3.5 w-3.5" />}
>
{t('aiData.save')}
</GlassButton>
</div>
</div>
</div>
</div>
</div>,
document.body
)
}

View File

@@ -1,5 +1,11 @@
'use client'
export interface AIDataCharacter {
name: string
appearance: string
slot?: string
}
export interface PhotographyCharacter {
name: string
screen_position: string
@@ -47,7 +53,7 @@ export interface AIDataModalProps {
cameraMove: string | null
description: string | null
location: string | null
characters: string[]
characters: AIDataCharacter[]
videoPrompt: string | null
photographyRules: PhotographyRules | null
actingNotes: ActingNotes | ActingCharacter[] | null

View File

@@ -1,21 +1,29 @@
'use client'
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
import type { useTranslations } from 'next-intl'
import GlassInput from '@/components/ui/primitives/GlassInput'
import GlassTextarea from '@/components/ui/primitives/GlassTextarea'
import { AppIcon } from '@/components/ui/icons'
import type {
ActingCharacter,
AIDataCharacter,
PhotographyCharacter,
PhotographyRules,
} from './AIDataModal.types'
interface AIDataModalFormPaneProps {
t: (key: string) => string
t: ReturnType<typeof useTranslations<'storyboard'>>
shotType: string
cameraMove: string
description: string
location: string | null
characters: string[]
characters: AIDataCharacter[]
videoPrompt: string
photographyRules: PhotographyRules | null
actingNotes: ActingCharacter[]
activeCharIdx: number
onActiveCharIdxChange: (idx: number) => void
onShotTypeChange: (value: string) => void
onCameraMoveChange: (value: string) => void
onDescriptionChange: (value: string) => void
@@ -25,6 +33,101 @@ interface AIDataModalFormPaneProps {
onActingCharacterChange: (index: number, field: keyof ActingCharacter, value: string) => void
}
function FL({ children }: { children: string }) {
return <p className="mb-1 text-[10.5px] font-semibold text-[var(--glass-text-tertiary)]">{children}</p>
}
function AutoGrowTextarea({
value,
onChange,
rows,
placeholder,
density = 'default',
className,
}: {
value: string
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void
rows: number
placeholder?: string
density?: 'compact' | 'default'
className?: string
}) {
const ref = useRef<HTMLTextAreaElement | null>(null)
useEffect(() => {
const el = ref.current
if (!el) return
el.style.height = '0px'
el.style.height = `${el.scrollHeight}px`
}, [value])
return (
<GlassTextarea
ref={ref}
rows={rows}
value={value}
onChange={onChange}
onInput={(event) => {
const el = event.currentTarget
el.style.height = '0px'
el.style.height = `${el.scrollHeight}px`
}}
placeholder={placeholder}
density={density}
className={['overflow-hidden', className].filter(Boolean).join(' ')}
/>
)
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="flex items-center gap-2 mb-2.5">
<AppIcon name="sparkles" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span className="text-[11px] font-semibold text-[var(--glass-text-primary)]">{children}</span>
</div>
)
}
function CollapseSection({
label,
iconName,
children,
}: {
label: string
iconName?: 'video' | 'film'
children: React.ReactNode
}) {
const [open, setOpen] = useState(false)
return (
<div className="border border-[var(--glass-stroke-base)] rounded-[var(--glass-radius-xs)] overflow-hidden">
<button
type="button"
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between px-3.5 py-2.5 bg-[var(--glass-bg-muted)] hover:bg-[var(--glass-bg-surface)] transition-colors"
>
<div className="flex items-center gap-2">
{iconName ? (
<AppIcon
name={iconName}
className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0"
/>
) : null}
<span className="text-[11px] font-semibold text-[var(--glass-text-secondary)]">{label}</span>
</div>
<AppIcon
name={open ? 'chevronUp' : 'chevronDown'}
className="h-3.5 w-3.5 text-[var(--glass-text-tertiary)] flex-shrink-0"
/>
</button>
{open && (
<div className="px-3.5 py-3 space-y-3 bg-[var(--glass-bg-surface)]">
{children}
</div>
)}
</div>
)
}
export default function AIDataModalFormPane({
t,
shotType,
@@ -35,6 +138,8 @@ export default function AIDataModalFormPane({
videoPrompt,
photographyRules,
actingNotes,
activeCharIdx,
onActiveCharIdxChange,
onShotTypeChange,
onCameraMoveChange,
onDescriptionChange,
@@ -43,194 +148,257 @@ export default function AIDataModalFormPane({
onPhotographyCharacterChange,
onActingCharacterChange,
}: AIDataModalFormPaneProps) {
const activeChar = characters[activeCharIdx]
const photoChar = photographyRules?.characters.find(c => c.name === activeChar?.name)
const actingCharIdx = actingNotes.findIndex(n => n.name === activeChar?.name)
const actingChar = actingCharIdx >= 0 ? actingNotes[actingCharIdx] : null
return (
<div className="w-1/2 border-r border-[var(--glass-stroke-base)] overflow-y-auto p-6 space-y-5">
<div className="text-sm font-medium text-[var(--glass-text-secondary)] mb-3">{t('aiData.basicData')}</div>
<div className="w-[55%] border-r border-[var(--glass-stroke-base)] overflow-y-auto p-5 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.shotType')}</label>
<input
type="text"
value={shotType}
onChange={(event) => onShotTypeChange(event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
placeholder={t('aiData.shotTypePlaceholder')}
/>
{/* ① 视觉描述 — 最高优先 */}
<section>
<div className="flex items-center gap-2 mb-2.5">
<AppIcon name="fileText" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span className="text-[11px] font-semibold text-[var(--glass-text-primary)]">
{t('aiData.visualDescription')}
</span>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.cameraMove')}</label>
<input
type="text"
value={cameraMove}
onChange={(event) => onCameraMoveChange(event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
placeholder={t('aiData.cameraMovePlaceholder')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.scene')}</label>
<div className="px-3 py-2 bg-[var(--glass-bg-muted)] border border-[var(--glass-stroke-base)] rounded-lg text-sm text-[var(--glass-text-secondary)]">
{location || t('aiData.notSelected')}
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.characters')}</label>
<div className="px-3 py-2 bg-[var(--glass-bg-muted)] border border-[var(--glass-stroke-base)] rounded-lg text-sm text-[var(--glass-text-secondary)]">
{characters.length > 0 ? characters.join('、') : t('common.none')}
</div>
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.visualDescription')}</label>
<textarea
value={description}
onChange={(event) => onDescriptionChange(event.target.value)}
<AutoGrowTextarea
rows={3}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
value={description}
onChange={e => onDescriptionChange(e.target.value)}
placeholder={t('insert.placeholder.description')}
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.videoPrompt')}</label>
<textarea
value={videoPrompt}
onChange={(event) => onVideoPromptChange(event.target.value)}
rows={2}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-warning-bg)]"
placeholder={t('panel.videoPromptPlaceholder')}
/>
</div>
{photographyRules && (
<>
<div className="border-t border-[var(--glass-stroke-base)] pt-4 mt-4">
<div className="text-sm font-medium text-[var(--glass-text-secondary)] mb-3">{t('aiData.photographyRules')}</div>
</div>
</section>
{/* ② 镜头设置 */}
<section>
<SectionLabel>{t('aiData.shotAndScene')}</SectionLabel>
<div className="grid grid-cols-2 gap-3 mb-2">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.summary')}</label>
<input
type="text"
value={photographyRules.scene_summary || ''}
onChange={(event) => onPhotographyFieldChange('scene_summary', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.lightingDirection')}</label>
<input
type="text"
value={photographyRules.lighting?.direction || ''}
onChange={(event) => onPhotographyFieldChange('lighting.direction', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.lightingQuality')}</label>
<input
type="text"
value={photographyRules.lighting?.quality || ''}
onChange={(event) => onPhotographyFieldChange('lighting.quality', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
<FL>{t('aiData.shotType')}</FL>
<div className="relative">
<AppIcon name="clapperboard" className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--glass-text-tertiary)]" />
<GlassInput
density="compact"
value={shotType}
onChange={e => onShotTypeChange(e.target.value)}
placeholder={t('aiData.shotTypePlaceholder')}
className="pl-9"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.depthOfField')}</label>
<input
type="text"
value={photographyRules.depth_of_field || ''}
onChange={(event) => onPhotographyFieldChange('depth_of_field', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
/>
<FL>{t('aiData.cameraMove')}</FL>
<div className="relative">
<AppIcon name="video" className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--glass-text-tertiary)]" />
<GlassInput
density="compact"
value={cameraMove}
onChange={e => onCameraMoveChange(e.target.value)}
placeholder={t('aiData.cameraMovePlaceholder')}
className="pl-9"
/>
</div>
</div>
</div>
{/* 场景 + 比例 — 只读文字,不用 input 避免视觉干扰 */}
{location && (
<div className="flex items-center gap-2 text-[11.5px] text-[var(--glass-text-tertiary)]">
<AppIcon name="imageAlt" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span>
{t('aiData.scene').replace('(只读)', '')}<span className="text-[var(--glass-text-secondary)] font-medium">{location}</span>
</span>
</div>
)}
</section>
{/* ③ 角色详情 — tab 切换 */}
{characters.length > 0 && (
<section>
<SectionLabel>{t('aiData.characterDetails')}</SectionLabel>
{/* Tab 按钮 */}
<div className="flex gap-2 mb-3 flex-wrap">
{characters.map((char, i) => (
<button
key={i}
type="button"
onClick={() => onActiveCharIdxChange(i)}
className={[
'flex items-center gap-2 px-3 py-1.5 rounded-[var(--glass-radius-xs)] border text-xs font-semibold transition-all',
activeCharIdx === i
? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]'
: 'border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]',
].join(' ')}
>
<div className={[
'h-5 w-5 rounded-full flex items-center justify-center flex-shrink-0',
activeCharIdx === i ? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]' : 'bg-[var(--glass-bg-surface)] text-[var(--glass-text-tertiary)]',
].join(' ')}>
<AppIcon name="user" className="h-3 w-3" />
</div>
{char.name}
{char.slot && (
<span className="glass-chip glass-chip-neutral text-[9.5px] inline-flex items-center gap-1">
<AppIcon name="badgeCheck" className="h-3 w-3" />
{char.slot}
</span>
)}
</button>
))}
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.colorTone')}</label>
<input
type="text"
value={photographyRules.color_tone || ''}
onChange={(event) => onPhotographyFieldChange('color_tone', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
/>
</div>
{/* 当前角色详情卡 */}
{activeChar && (
<div className="rounded-[var(--glass-radius-sm)] border border-[var(--glass-stroke-focus)] overflow-hidden">
{/* slot 行 */}
<div className="flex items-center gap-2 px-3.5 py-2 bg-[var(--glass-bg-muted)] border-b border-[var(--glass-stroke-base)] flex-wrap">
<AppIcon name="badgeCheck" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span className="text-[10.5px] font-semibold text-[var(--glass-text-tertiary)]">
{t('aiData.slot')}
</span>
<span className="glass-chip glass-chip-info text-[10.5px]">
{activeChar.slot ?? t('aiData.slotUnset')}
</span>
</div>
{photographyRules.characters && photographyRules.characters.length > 0 && (
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-2">{t('aiData.characterPosition')}</label>
<div className="space-y-3">
{photographyRules.characters.map((character, index) => (
<div key={index} className="p-3 bg-[var(--glass-bg-muted)] rounded-lg border border-[var(--glass-stroke-base)]">
<div className="text-xs font-medium text-[var(--glass-tone-info-fg)] mb-2">{character.name}</div>
<div className="grid grid-cols-3 gap-2">
<div className="px-3.5 py-3 space-y-3 bg-[var(--glass-bg-surface)]">
{/* 外貌 — 只读 */}
{activeChar.appearance && (
<div>
<FL>{t('aiData.appearanceReadonly')}</FL>
<div className="flex items-start gap-2 rounded-[var(--glass-radius-xs)] bg-[var(--glass-bg-muted)] px-3 py-2">
<AppIcon name="sparkles" className="mt-0.5 h-3.5 w-3.5 text-[var(--glass-tone-warning-fg)] flex-shrink-0" />
<p className="text-[12px] text-[var(--glass-text-secondary)] leading-relaxed">
{activeChar.appearance}
</p>
</div>
</div>
)}
{/* 画面站位 */}
{photoChar && (
<div>
<FL>{t('aiData.framePosition')}</FL>
<div className="space-y-2">
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.position')}</label>
<input
type="text"
value={character.screen_position || ''}
onChange={(event) => onPhotographyCharacterChange(index, 'screen_position', event.target.value)}
className="w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs"
<p className="text-[10px] text-[var(--glass-text-tertiary)] mb-1">{t('aiData.screenPosition')}</p>
<GlassInput
density="compact"
value={photoChar.screen_position}
onChange={e => {
const idx = photographyRules!.characters.findIndex(c => c.name === activeChar.name)
if (idx >= 0) onPhotographyCharacterChange(idx, 'screen_position', e.target.value)
}}
/>
</div>
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.posture')}</label>
<input
type="text"
value={character.posture || ''}
onChange={(event) => onPhotographyCharacterChange(index, 'posture', event.target.value)}
className="w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs"
/>
</div>
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.facing')}</label>
<input
type="text"
value={character.facing || ''}
onChange={(event) => onPhotographyCharacterChange(index, 'facing', event.target.value)}
className="w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs"
/>
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-[10px] text-[var(--glass-text-tertiary)] mb-1">{t('aiData.posture')}</p>
<GlassInput
density="compact"
value={photoChar.posture}
onChange={e => {
const idx = photographyRules!.characters.findIndex(c => c.name === activeChar.name)
if (idx >= 0) onPhotographyCharacterChange(idx, 'posture', e.target.value)
}}
/>
</div>
<div>
<p className="text-[10px] text-[var(--glass-text-tertiary)] mb-1">{t('aiData.facing')}</p>
<GlassInput
density="compact"
value={photoChar.facing}
onChange={e => {
const idx = photographyRules!.characters.findIndex(c => c.name === activeChar.name)
if (idx >= 0) onPhotographyCharacterChange(idx, 'facing', e.target.value)
}}
/>
</div>
</div>
</div>
</div>
))}
)}
{/* 表演指导 */}
{actingChar && (
<div>
<FL>{t('aiData.actingGuide')}</FL>
<AutoGrowTextarea
density="compact"
rows={2}
value={actingChar.acting}
onChange={e => onActingCharacterChange(actingCharIdx, 'acting', e.target.value)}
/>
</div>
)}
</div>
</div>
)}
</>
</section>
)}
{actingNotes.length > 0 && (
<>
<div className="border-t border-[var(--glass-stroke-base)] pt-4 mt-4">
<div className="text-sm font-medium text-[var(--glass-text-secondary)] mb-3">{t('aiData.actingNotes')}</div>
</div>
{/* ④ 视频提示词 — 折叠 */}
<CollapseSection label={t('aiData.videoPrompt')} iconName="video">
<AutoGrowTextarea
rows={4}
value={videoPrompt}
onChange={e => onVideoPromptChange(e.target.value)}
placeholder={t('panel.videoPromptPlaceholder')}
className="bg-[var(--glass-tone-warning-bg)]"
/>
</CollapseSection>
<div className="space-y-3">
{actingNotes.map((character, index) => (
<div key={index} className="p-3 bg-[var(--glass-tone-info-bg)] rounded-lg border border-[var(--glass-stroke-focus)]">
<div className="text-xs font-medium text-[var(--glass-tone-info-fg)] mb-2">{character.name}</div>
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.actingDescription')}</label>
<textarea
value={character.acting || ''}
onChange={(event) => onActingCharacterChange(index, 'acting', event.target.value)}
rows={2}
className="w-full px-2 py-1 border border-[var(--glass-stroke-focus)] rounded text-xs resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
/>
</div>
</div>
))}
{/* ⑤ 摄影环境 — 折叠 */}
{photographyRules && (
<CollapseSection label={t('aiData.photoEnv')} iconName="film">
<div>
<FL>{t('aiData.summary')}</FL>
<GlassInput
density="compact"
value={photographyRules.scene_summary}
onChange={e => onPhotographyFieldChange('scene_summary', e.target.value)}
/>
</div>
</>
<div className="grid grid-cols-2 gap-3">
<div>
<FL>{t('aiData.lightingDirection')}</FL>
<GlassInput
density="compact"
value={photographyRules.lighting?.direction ?? ''}
onChange={e => onPhotographyFieldChange('lighting.direction', e.target.value)}
/>
</div>
<div>
<FL>{t('aiData.lightingQuality')}</FL>
<GlassInput
density="compact"
value={photographyRules.lighting?.quality ?? ''}
onChange={e => onPhotographyFieldChange('lighting.quality', e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<FL>{t('aiData.depthOfField')}</FL>
<GlassInput
density="compact"
value={photographyRules.depth_of_field}
onChange={e => onPhotographyFieldChange('depth_of_field', e.target.value)}
/>
</div>
<div>
<FL>{t('aiData.colorTone')}</FL>
<GlassInput
density="compact"
value={photographyRules.color_tone}
onChange={e => onPhotographyFieldChange('color_tone', e.target.value)}
/>
</div>
</div>
</CollapseSection>
)}
</div>
)

View File

@@ -1,46 +1,89 @@
'use client'
import { useState } from 'react'
import type { useTranslations } from 'next-intl'
import { AppIcon } from '@/components/ui/icons'
import GlassButton from '@/components/ui/primitives/GlassButton'
interface AIDataModalPreviewPaneProps {
t: (key: string) => string
t: ReturnType<typeof useTranslations<'storyboard'>>
previewJson: Record<string, unknown>
}
export async function copyPreviewJsonText(text: string): Promise<void> {
const clipboardApi = globalThis.navigator?.clipboard
if (clipboardApi && typeof clipboardApi.writeText === 'function') {
try {
await clipboardApi.writeText(text)
return
} catch {
// Fall through to manual copy fallback.
}
}
if (typeof document === 'undefined') {
throw new Error('Clipboard unavailable')
}
const el = document.createElement('textarea')
el.value = text
el.style.position = 'fixed'
el.style.opacity = '0'
document.body.appendChild(el)
el.select()
const copied = typeof document.execCommand === 'function' && document.execCommand('copy')
document.body.removeChild(el)
if (!copied) {
throw new Error('Clipboard fallback failed')
}
}
export default function AIDataModalPreviewPane({
t,
previewJson,
}: AIDataModalPreviewPaneProps) {
const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle')
const handleCopy = async () => {
const text = JSON.stringify(previewJson, null, 2)
try {
await copyPreviewJsonText(text)
setCopyState('success')
} catch {
setCopyState('error')
}
window.setTimeout(() => setCopyState('idle'), 1600)
}
const copyLabel = t('common.copy')
const copyIconName = copyState === 'success' ? 'clipboardCheck' : copyState === 'error' ? 'alert' : 'copy'
return (
<div className="w-1/2 bg-[var(--glass-text-primary)] overflow-y-auto p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-[var(--glass-text-tertiary)]">{t('aiData.jsonPreview')}</span>
<button
onClick={() => {
const text = JSON.stringify(previewJson, null, 2)
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(text).catch(() => { })
} else {
// HTTP 环境 fallback
const el = document.createElement('textarea')
el.value = text
el.style.position = 'fixed'
el.style.opacity = '0'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
}}
className="text-xs text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-text-primary)] flex items-center gap-1"
<div className="w-[45%] flex flex-col overflow-hidden bg-[var(--glass-bg-muted)]">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] flex-shrink-0">
<div className="flex items-center gap-2">
<AppIcon name="fileText" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)]" />
<span className="text-xs font-medium text-[var(--glass-text-tertiary)]">
{t('aiData.jsonCheck')}
</span>
</div>
<GlassButton
size="sm"
variant="ghost"
onClick={handleCopy}
iconLeft={<AppIcon name={copyIconName} className="h-3 w-3" />}
>
<AppIcon name="copy" className="w-3.5 h-3.5" />
{t('common.copy')}
</button>
{copyLabel}
</GlassButton>
</div>
<div className="flex-1 overflow-y-auto p-4">
<pre className="text-[11px] font-mono leading-relaxed text-[var(--glass-text-secondary)] whitespace-pre-wrap break-all">
{JSON.stringify(previewJson, null, 2)}
</pre>
</div>
<pre className="text-xs text-[var(--glass-tone-success-fg)] font-mono whitespace-pre-wrap break-all">
{JSON.stringify(previewJson, null, 2)}
</pre>
</div>
)
}

View File

@@ -64,13 +64,13 @@ export function useStoryboardAiDataRuntime({
const panelData = getPanelEditData(panel)
const photographyRules = parseJsonSafely(panel.photographyRules, 'photographyRules')
const actingNotes = parseJsonSafely(panel.actingNotes, 'actingNotes')
const characterNames = panelData.characters.map((character) => character.name)
const characters = panelData.characters.map((character) => ({ ...character }))
return {
panelData,
panel,
storyboardId: storyboard.id,
characterNames,
characters,
photographyRules,
actingNotes,
}

View File

@@ -20,7 +20,7 @@ export interface StoryboardPanel {
shot_type: string
camera_move: string | null
description: string
characters: { name: string; appearance: string }[]
characters: { name: string; appearance: string; slot?: string }[]
location?: string
srt_range?: string
duration?: number
@@ -112,12 +112,22 @@ export function useStoryboardState({
return sortedPanels.map((p) => {
const parsedChars = p.characters ? JSON.parse(p.characters) : []
const characters = Array.isArray(parsedChars)
? parsedChars.filter((item): item is { name: string; appearance: string } => (
typeof item === 'object'
&& item !== null
&& typeof (item as { name?: unknown }).name === 'string'
&& typeof (item as { appearance?: unknown }).appearance === 'string'
))
? parsedChars.flatMap((item): Array<{ name: string; appearance: string; slot?: string }> => {
if (
typeof item !== 'object'
|| item === null
|| typeof (item as { name?: unknown }).name !== 'string'
|| typeof (item as { appearance?: unknown }).appearance !== 'string'
) {
return []
}
const candidate = item as { name: string; appearance: string; slot?: unknown }
return [{
name: candidate.name,
appearance: candidate.appearance,
slot: typeof candidate.slot === 'string' ? candidate.slot : undefined,
}]
})
: []
return {
id: p.id,

View File

@@ -237,7 +237,7 @@ export default function StoryboardStage({
cameraMove={modalRuntime.aiDataRuntime.panelData.cameraMove}
description={modalRuntime.aiDataRuntime.panelData.description}
location={modalRuntime.aiDataRuntime.panelData.location}
characters={modalRuntime.aiDataRuntime.characterNames}
characters={modalRuntime.aiDataRuntime.characters}
videoPrompt={modalRuntime.aiDataRuntime.panelData.videoPrompt}
photographyRules={modalRuntime.aiDataRuntime.photographyRules}
actingNotes={modalRuntime.aiDataRuntime.actingNotes}

View File

@@ -0,0 +1,23 @@
interface ScrollLockTarget {
style: {
overflow: string
}
}
interface ScrollLockDocumentLike {
body: ScrollLockTarget
documentElement: ScrollLockTarget
}
export function lockModalPageScroll(doc: ScrollLockDocumentLike): () => void {
const previousBodyOverflow = doc.body.style.overflow
const previousHtmlOverflow = doc.documentElement.style.overflow
doc.body.style.overflow = 'hidden'
doc.documentElement.style.overflow = 'hidden'
return () => {
doc.body.style.overflow = previousBodyOverflow
doc.documentElement.style.overflow = previousHtmlOverflow
}
}

View File

@@ -20,6 +20,7 @@ import { useWorkspaceAutoRun } from './useWorkspaceAutoRun'
import { buildWorkspaceControllerViewModel } from './workspace-controller-view-model'
import type { NovelPromotionWorkspaceProps } from '../types'
import { useRouter } from '@/i18n/navigation'
import { resolveEpisodeStageArtifacts } from '@/lib/novel-promotion/stage-readiness'
export function useNovelPromotionWorkspaceController({
project,
@@ -38,7 +39,7 @@ export function useNovelPromotionWorkspaceController({
const { onRefresh } = useWorkspaceProvider()
const projectSnapshot = useWorkspaceProjectSnapshot({ project, episode, urlStage })
const { currentStage, episodeStoryboards, ...projectSection } = projectSnapshot
const { currentStage, ...projectSection } = projectSnapshot
const assetsLoading = false
const assetsLoadingState = assetsLoading
@@ -116,6 +117,11 @@ export function useNovelPromotionWorkspaceController({
execution.storyToScriptStream.isRunning ||
execution.storyToScriptStream.isRecoveredRunning ||
execution.storyToScriptStream.status === 'running'
const isScriptToStoryboardRunning =
execution.scriptToStoryboardStream.isRunning ||
execution.scriptToStoryboardStream.isRecoveredRunning ||
execution.scriptToStoryboardStream.status === 'running'
const stageArtifacts = resolveEpisodeStageArtifacts(episode)
const isAnyOperationRunning =
isStartingStoryToScript ||
@@ -124,8 +130,8 @@ export function useNovelPromotionWorkspaceController({
execution.isAssetAnalysisRunning ||
execution.isConfirmingAssets ||
execution.isTransitioning ||
execution.storyToScriptStream.isRunning ||
execution.scriptToStoryboardStream.isRunning
isStoryToScriptRunning ||
isScriptToStoryboardRunning
useWorkspaceAutoRun({
searchParams,
@@ -140,9 +146,7 @@ export function useNovelPromotionWorkspaceController({
const capsuleNavItems = useWorkspaceStageNavigation({
isAnyOperationRunning,
episode,
projectCharacterCount: projectSnapshot.projectCharacters.length,
episodeStoryboards,
stageArtifacts,
t,
})

View File

@@ -1,15 +1,6 @@
'use client'
import type { NovelPromotionPanel } from '@/types/project'
interface EpisodeLike {
novelText?: string | null
voiceLines?: unknown[] | null
}
interface StoryboardLike {
panels?: NovelPromotionPanel[] | null
}
import type { StageArtifactReadiness } from '@/lib/novel-promotion/stage-readiness'
interface CapsuleNavItem {
id: string
@@ -22,17 +13,13 @@ interface CapsuleNavItem {
interface UseWorkspaceStageNavigationParams {
isAnyOperationRunning: boolean
episode?: EpisodeLike | null
projectCharacterCount: number
episodeStoryboards: StoryboardLike[]
stageArtifacts: StageArtifactReadiness
t: (key: string) => string
}
export function useWorkspaceStageNavigation({
isAnyOperationRunning,
episode,
projectCharacterCount,
episodeStoryboards,
stageArtifacts,
t,
}: UseWorkspaceStageNavigationParams): CapsuleNavItem[] {
const getStageStatus = (stageId: string): 'empty' | 'active' | 'processing' | 'ready' => {
@@ -40,16 +27,16 @@ export function useWorkspaceStageNavigation({
switch (stageId) {
case 'config':
return episode?.novelText ? 'ready' : 'active'
return stageArtifacts.hasStory ? 'ready' : 'active'
case 'assets':
return projectCharacterCount > 0 ? 'ready' : 'empty'
return stageArtifacts.hasScript ? 'ready' : 'empty'
case 'storyboard':
return episodeStoryboards.some((sb) => sb.panels?.length) ? 'ready' : 'empty'
return stageArtifacts.hasStoryboard ? 'ready' : 'empty'
case 'videos':
case 'editor':
return episodeStoryboards.some((sb) => sb.panels?.some((panel) => panel.videoUrl)) ? 'ready' : 'empty'
return stageArtifacts.hasVideo ? 'ready' : 'empty'
case 'voice':
return (episode?.voiceLines?.length || 0) > 0 ? 'ready' : 'empty'
return stageArtifacts.hasVoice ? 'ready' : 'empty'
default:
return 'empty'
}

View File

@@ -9,6 +9,7 @@ import { useImageGenerationCount } from '@/lib/image-generation/use-image-genera
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { AppIcon } from '@/components/ui/icons'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
interface AddLocationModalProps {
folderId: string | null
@@ -33,6 +34,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
const [summary, setSummary] = useState('')
const [aiInstruction, setAiInstruction] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const aiDesignMutation = useAiDesignLocation()
const createLocationMutation = useCreateAssetHubLocation()
@@ -63,6 +65,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
try {
const data = await aiDesignMutation.mutateAsync(aiInstruction.trim())
setSummary(data.prompt || '')
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
setAiInstruction('')
} catch (error) {
_ulogError('AI设计失败:', error)
@@ -80,6 +83,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
folderId,
artStyle,
count: locationGenerationCount,
availableSlots,
})
onSuccess()
} catch (error) {

View File

@@ -91,7 +91,8 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
}
const imageUrls = appearance?.imageUrls || []
const hasMultipleImages = imageUrls.filter(u => isValidUrl(u)).length > 1
const generatedImageCount = imageUrls.filter(u => isValidUrl(u)).length
const hasMultipleImages = generatedImageCount > 1
const effectiveSelectedIndex: number | null = appearance?.selectedIndex ?? null
const currentImageUrl = appearance?.imageUrl || (effectiveSelectedIndex !== null ? imageUrls[effectiveSelectedIndex] : null) || imageUrls.find(u => u) || null
const hasPreviousVersion = !!(appearance?.previousImageUrl || (appearance?.previousImageUrls && appearance.previousImageUrls.length > 0))
@@ -250,22 +251,27 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
<div className="flex items-center gap-1">
<ImageGenerationInlineCountButton
prefix={isAppearanceTaskRunning ? (
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
) : (
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
)}
suffix={null}
value={generationCount}
options={getImageGenerationCountOptions('character')}
onValueChange={setGenerationCount}
onClick={() => {
_ulogInfo('[CharacterCard] 多图模式 - 重新生成按钮点击, characterId:', character.id, 'appearanceCount:', appearanceCount)
handleGenerate(generationCount)
handleGenerate(generatedImageCount)
}}
disabled={isAppearanceTaskRunning}
ariaLabel={tAssets('image.selectCount')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={tAssets('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center gap-1 rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{hasPreviousVersion && (
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>

View File

@@ -94,7 +94,7 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
const isTaskRunning = serverTaskRunning || transientSubmitting
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
hasRunningTask: isTaskRunning,
requestedCount: generationCount,
requestedCount: generatedImageCount > 1 ? generatedImageCount : generationCount,
})
const displaySlotCount = displaySelectionImages.length
const hasMultipleImages = generatedImageCount > 1
@@ -226,19 +226,24 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
<div className="flex items-center gap-1 ml-2">
<ImageGenerationInlineCountButton
prefix={isTaskRunning ? (
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
) : (
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
)}
suffix={null}
value={generationCount}
options={getImageGenerationCountOptions('location')}
onValueChange={setGenerationCount}
onClick={() => handleGenerate(generationCount)}
onClick={() => handleGenerate(generatedImageCount)}
disabled={isTaskRunning}
ariaLabel={tAssets('image.selectCount')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={tAssets('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center gap-1 rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{hasPreviousVersion && (
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>

View File

@@ -11,6 +11,8 @@ import { AppIcon, IconGradientDefs } from '@/components/ui/icons'
import { shouldGuideToModelSetup } from '@/lib/workspace/model-setup'
import { Link, useRouter } from '@/i18n/navigation'
import { apiFetch } from '@/lib/api-fetch'
import { readApiErrorMessage } from '@/lib/api/read-error-message'
import { validateProjectDraft } from '@/lib/projects/validation'
interface ProjectStats {
episodes: number
@@ -45,6 +47,24 @@ function formatProjectCost(amount: number, currency = DEFAULT_BILLING_CURRENCY):
return `¥${amount.toFixed(2)}`
}
function toProjectValidationMessage(
issue: ReturnType<typeof validateProjectDraft>,
t: ReturnType<typeof useTranslations>,
): string | null {
if (!issue) return null
switch (issue.code) {
case 'PROJECT_NAME_REQUIRED':
return t('validation.nameRequired')
case 'PROJECT_NAME_TOO_LONG':
return t('validation.nameTooLong')
case 'PROJECT_DESCRIPTION_TOO_LONG':
return t('validation.descriptionTooLong')
}
return null
}
export default function WorkspacePage() {
const { data: session, status } = useSession()
const router = useRouter()
@@ -52,12 +72,14 @@ export default function WorkspacePage() {
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [formData, setFormData] = useState({
name: '',
description: ''
})
const [editingProject, setEditingProject] = useState<Project | null>(null)
const [showEditModal, setShowEditModal] = useState(false)
const [editError, setEditError] = useState<string | null>(null)
const [editFormData, setEditFormData] = useState({
name: '',
description: ''
@@ -124,6 +146,7 @@ export default function WorkspacePage() {
// 打开新建项目弹窗并检测模型配置
const openCreateModal = useCallback(() => {
setCreateError(null)
setShowCreateModal(true)
// 异步检测模型配置状态
void (async () => {
@@ -146,8 +169,13 @@ export default function WorkspacePage() {
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name.trim()) return
const validationMessage = toProjectValidationMessage(validateProjectDraft(formData), t)
if (validationMessage) {
setCreateError(validationMessage)
return
}
setCreateError(null)
setCreateLoading(true)
try {
const response = await apiFetch('/api/projects', {
@@ -181,11 +209,11 @@ export default function WorkspacePage() {
router.push({ pathname: '/profile' })
}
} else {
alert(t('createFailed'))
setCreateError(await readApiErrorMessage(response, t('createFailed')))
}
} catch (error) {
_ulogError('创建项目失败:', error)
alert(t('createFailed'))
setCreateError(error instanceof Error ? error.message : t('createFailed'))
} finally {
setCreateLoading(false)
}
@@ -207,8 +235,15 @@ export default function WorkspacePage() {
const handleEditProject = async (e: React.FormEvent) => {
e.preventDefault()
if (!editingProject || !editFormData.name.trim()) return
if (!editingProject) return
const validationMessage = toProjectValidationMessage(validateProjectDraft(editFormData), t)
if (validationMessage) {
setEditError(validationMessage)
return
}
setEditError(null)
setCreateLoading(true)
try {
const response = await apiFetch(`/api/projects/${editingProject.id}`, {
@@ -226,10 +261,10 @@ export default function WorkspacePage() {
setEditingProject(null)
setEditFormData({ name: '', description: '' })
} else {
alert(t('updateFailed'))
setEditError(await readApiErrorMessage(response, t('updateFailed')))
}
} catch {
alert(t('updateFailed'))
} catch (error) {
setEditError(error instanceof Error ? error.message : t('updateFailed'))
} finally {
setCreateLoading(false)
}
@@ -276,6 +311,7 @@ export default function WorkspacePage() {
e.preventDefault() // 阻止 Link 导航
e.stopPropagation()
setEditingProject(project)
setEditError(null)
setEditFormData({
name: project.name,
description: project.description || ''
@@ -574,7 +610,12 @@ export default function WorkspacePage() {
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value })
if (createError) {
setCreateError(null)
}
}}
className="glass-input-base w-full px-3 py-2"
placeholder={t('projectNamePlaceholder')}
maxLength={100}
@@ -589,18 +630,29 @@ export default function WorkspacePage() {
<textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
onChange={(e) => {
setFormData({ ...formData, description: e.target.value })
if (createError) {
setCreateError(null)
}
}}
className="glass-textarea-base w-full px-3 py-2"
placeholder={t('projectDescriptionPlaceholder')}
rows={3}
maxLength={500}
/>
</div>
{createError && (
<p className="mb-4 rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600">
{createError}
</p>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => {
setShowCreateModal(false)
setCreateError(null)
setFormData({ name: '', description: '' })
}}
className="glass-btn-base glass-btn-secondary px-4 py-2"
@@ -635,7 +687,12 @@ export default function WorkspacePage() {
id="edit-name"
type="text"
value={editFormData.name}
onChange={(e) => setEditFormData({ ...editFormData, name: e.target.value })}
onChange={(e) => {
setEditFormData({ ...editFormData, name: e.target.value })
if (editError) {
setEditError(null)
}
}}
className="glass-input-base w-full px-3 py-2"
placeholder={t('projectNamePlaceholder')}
maxLength={100}
@@ -649,19 +706,30 @@ export default function WorkspacePage() {
<textarea
id="edit-description"
value={editFormData.description}
onChange={(e) => setEditFormData({ ...editFormData, description: e.target.value })}
onChange={(e) => {
setEditFormData({ ...editFormData, description: e.target.value })
if (editError) {
setEditError(null)
}
}}
className="glass-textarea-base w-full px-3 py-2"
placeholder={t('projectDescriptionPlaceholder')}
rows={3}
maxLength={500}
/>
</div>
{editError && (
<p className="mb-4 rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600">
{editError}
</p>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => {
setShowEditModal(false)
setEditingProject(null)
setEditError(null)
setEditFormData({ name: '', description: '' })
}}
className="glass-btn-base glass-btn-secondary px-4 py-2"