feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user