style: polish UI and improve UX
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
"downloadSuccess": "Download Complete",
|
||||
"downloadFailed": "Download Failed",
|
||||
"downloadEmpty": "No image assets to download",
|
||||
"filteredEmptyHint": "Click \"New Asset\" to add assets",
|
||||
"newFolder": "New Folder",
|
||||
"editFolder": "Edit Folder",
|
||||
"deleteFolder": "Delete Folder",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Quick Start",
|
||||
"title": "From Inspiration to Screen",
|
||||
"subtitle": "Describe your story and let AI generate cinematic short dramas",
|
||||
"inputPlaceholder": "Enter your story idea, novel excerpt, or script outline...",
|
||||
"startCreation": "Start Creating",
|
||||
|
||||
@@ -173,4 +173,4 @@
|
||||
"confirm": "Continue and Clear",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"downloadSuccess": "下载完成",
|
||||
"downloadFailed": "下载失败",
|
||||
"downloadEmpty": "当前没有可下载的图片资产",
|
||||
"filteredEmptyHint": "点击新建资产添加资产",
|
||||
"newFolder": "新建文件夹",
|
||||
"editFolder": "编辑文件夹",
|
||||
"deleteFolder": "删除文件夹",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "快速开始",
|
||||
"title": "从灵感到银幕",
|
||||
"subtitle": "描述你想要创作的故事,AI 为你智能生成影视短剧",
|
||||
"inputPlaceholder": "输入你的故事创意、小说片段或剧本大纲...",
|
||||
"startCreation": "开始创作",
|
||||
|
||||
@@ -173,4 +173,4 @@
|
||||
"confirm": "继续并清空",
|
||||
"cancel": "取消"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,20 @@
|
||||
* 首页 - 创作中心
|
||||
* 用户登录后的主入口页面:快速创作 + 最近项目
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Navbar from '@/components/Navbar'
|
||||
import { AppIcon, IconGradientDefs } from '@/components/ui/icons'
|
||||
import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
|
||||
import TypewriterHero from '@/components/home/TypewriterHero'
|
||||
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
||||
import { DEFAULT_STYLE_PRESET_VALUE, STYLE_PRESETS } from '@/lib/style-presets'
|
||||
import { Link, useRouter } from '@/i18n/navigation'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
import { expandHomeStory } from '@/lib/home/ai-story-expand'
|
||||
import { createHomeProjectLaunch } from '@/lib/home/create-project-launch'
|
||||
import {
|
||||
HOME_QUICK_START_MIN_ROWS,
|
||||
resolveTextareaTargetHeight,
|
||||
} from '@/lib/home/quick-start-textarea'
|
||||
import { HOME_QUICK_START_MIN_ROWS } from '@/lib/ui/textarea-height'
|
||||
import AiWriteModal from '@/components/home/AiWriteModal'
|
||||
|
||||
interface ProjectStats {
|
||||
@@ -51,48 +50,10 @@ export default function HomePage() {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [videoRatio, setVideoRatio] = useState('9:16')
|
||||
const [artStyle, setArtStyle] = useState('american-comic')
|
||||
const [stylePresetValue, setStylePresetValue] = useState<string>(DEFAULT_STYLE_PRESET_VALUE)
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [aiWriteOpen, setAiWriteOpen] = useState(false)
|
||||
const [aiWriteLoading, setAiWriteLoading] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const textareaMinHeightRef = useRef<number | null>(null)
|
||||
|
||||
// textarea 自适应高度(rAF 分帧动画)
|
||||
const autoResizeTextarea = useCallback(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
const maxH = window.innerHeight * 0.5
|
||||
const oldH = el.offsetHeight
|
||||
const oldScrollTop = el.scrollTop
|
||||
if (textareaMinHeightRef.current === null && oldH > 0) {
|
||||
textareaMinHeightRef.current = oldH
|
||||
}
|
||||
const minH = textareaMinHeightRef.current ?? oldH
|
||||
|
||||
// 同步:测量真实高度(不改 overflow,避免 scrollTop 被重置)
|
||||
el.style.transition = 'none'
|
||||
el.style.height = 'auto'
|
||||
const scrollH = el.scrollHeight
|
||||
const targetH = resolveTextareaTargetHeight({
|
||||
minHeight: minH,
|
||||
maxHeight: maxH,
|
||||
scrollHeight: scrollH,
|
||||
})
|
||||
el.style.height = `${oldH}px`
|
||||
el.scrollTop = oldScrollTop
|
||||
|
||||
// 下一帧:开启 transition → 动画到目标高度
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = oldScrollTop
|
||||
el.style.transition = 'height 200ms ease-out'
|
||||
el.style.height = `${targetH}px`
|
||||
el.style.overflowY = scrollH > maxH ? 'auto' : 'hidden'
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
autoResizeTextarea()
|
||||
}, [inputValue, autoResizeTextarea])
|
||||
|
||||
// 鉴权
|
||||
useEffect(() => {
|
||||
@@ -183,7 +144,6 @@ export default function HomePage() {
|
||||
() => ART_STYLES.map((s) => ({ ...s, recommended: s.value === 'realistic' })),
|
||||
[]
|
||||
)
|
||||
|
||||
// 时间格式化
|
||||
const formatTimeAgo = (dateString: string): string => {
|
||||
const diffMs = Date.now() - new Date(dateString).getTime()
|
||||
@@ -228,97 +188,105 @@ export default function HomePage() {
|
||||
45% { transform: translate(-15px, -20px) scale(1.15); opacity: 0.7; }
|
||||
70% { transform: translate(10px, -10px) scale(1); opacity: 0.35; }
|
||||
}
|
||||
@keyframes bracket-breathe {
|
||||
0%, 70%, 100% { opacity: 0.2; }
|
||||
75%, 90% { opacity: 0.6; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<main className="flex flex-col items-center pt-[16vh] pb-12 px-4 max-w-3xl mx-auto w-full">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-3xl font-bold text-[var(--glass-text-primary)] mb-2">
|
||||
✨ {t('title')}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('subtitle')}</p>
|
||||
</div>
|
||||
<main className="flex flex-col items-center pt-[13vh] pb-12 px-4 max-w-5xl mx-auto w-full">
|
||||
|
||||
{/* 呼吸光晕 + 输入区域 */}
|
||||
<div className="w-full relative group">
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 80% 60% at 30% 40%, rgba(6, 182, 212, 0.4), transparent 70%)',
|
||||
animation: 'breathe-drift-1 8s ease-in-out infinite',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 70% 80% at 70% 60%, rgba(139, 92, 246, 0.35), transparent 70%)',
|
||||
animation: 'breathe-drift-2 10s ease-in-out infinite',
|
||||
filter: 'blur(35px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-[56px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(59, 130, 246, 0.3), transparent 70%)',
|
||||
animation: 'breathe-drift-3 12s ease-in-out infinite',
|
||||
filter: 'blur(40px)',
|
||||
}}
|
||||
/>
|
||||
{/* ─── 取景器整体包裹:标题 + 输入框 ─── */}
|
||||
<div className="w-full relative p-5">
|
||||
{/* 四角校准线 */}
|
||||
<span className="absolute top-0 left-0 w-5 h-5 border-t border-l border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||
<span className="absolute top-0 right-0 w-5 h-5 border-t border-r border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||
<span className="absolute bottom-0 left-0 w-5 h-5 border-b border-l border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||
<span className="absolute bottom-0 right-0 w-5 h-5 border-b border-r border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||
|
||||
<div className="relative w-full glass-surface-elevated rounded-2xl">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
rows={HOME_QUICK_START_MIN_ROWS}
|
||||
className="w-full bg-transparent border-none outline-none text-[var(--glass-text-primary)] placeholder:text-[var(--glass-text-tertiary)] text-base resize-none p-5 pb-3 custom-scrollbar"
|
||||
{/* REC 录制指示灯 */}
|
||||
<span
|
||||
className="absolute top-2 right-7 flex items-center gap-1 z-10"
|
||||
style={{ animation: 'bracket-breathe 2s ease-in-out infinite' }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_4px_rgba(239,68,68,0.7)]" />
|
||||
<span className="text-[8px] font-mono font-bold tracking-widest text-red-500/70">REC</span>
|
||||
</span>
|
||||
|
||||
{/* 标题区 */}
|
||||
<TypewriterHero title={t('title')} subtitle={t('subtitle')} />
|
||||
|
||||
{/* 呼吸光晕 + 输入区域 */}
|
||||
<div className="w-full relative group">
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 80% 60% at 30% 40%, rgba(6, 182, 212, 0.4), transparent 70%)',
|
||||
animation: 'breathe-drift-1 8s ease-in-out infinite',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 70% 80% at 70% 60%, rgba(139, 92, 246, 0.35), transparent 70%)',
|
||||
animation: 'breathe-drift-2 10s ease-in-out infinite',
|
||||
filter: 'blur(35px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-[56px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(59, 130, 246, 0.3), transparent 70%)',
|
||||
animation: 'breathe-drift-3 12s ease-in-out infinite',
|
||||
filter: 'blur(40px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 底部工具栏:比例 + 风格 + AI帮我写 + 创建按钮 */}
|
||||
<div className="flex items-end gap-3 px-5 pb-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={setVideoRatio}
|
||||
options={ratioOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={setArtStyle}
|
||||
options={styleOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAiWriteOpen(true)}
|
||||
disabled={createLoading}
|
||||
className="glass-btn-base px-4 py-2.5 text-sm flex-shrink-0 border border-[var(--glass-stroke-strong)] hover:border-[var(--glass-tone-info-fg)]/40 transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<AppIcon name="sparkles" className="w-4 h-4 text-[#7c3aed]" />
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
<StoryInputComposer
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
minRows={HOME_QUICK_START_MIN_ROWS}
|
||||
videoRatio={videoRatio}
|
||||
onVideoRatioChange={setVideoRatio}
|
||||
ratioOptions={ratioOptions}
|
||||
artStyle={artStyle}
|
||||
onArtStyleChange={setArtStyle}
|
||||
styleOptions={styleOptions}
|
||||
stylePresetValue={stylePresetValue}
|
||||
onStylePresetChange={setStylePresetValue}
|
||||
stylePresetOptions={STYLE_PRESETS}
|
||||
primaryAction={(
|
||||
<button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!inputValue.trim() || createLoading}
|
||||
className="glass-btn-base glass-btn-primary h-10 flex-shrink-0 px-5 text-sm disabled:opacity-50"
|
||||
>
|
||||
{t('aiWrite.trigger')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!inputValue.trim() || createLoading}
|
||||
className="glass-btn-base glass-btn-primary px-5 py-2.5 text-sm flex-shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{createLoading ? tc('loading') : t('startCreation')}
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{createLoading ? tc('loading') : t('startCreation')}
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
secondaryActions={(
|
||||
<button
|
||||
onClick={() => setAiWriteOpen(true)}
|
||||
disabled={createLoading}
|
||||
className="glass-btn-base flex h-10 flex-shrink-0 items-center gap-1.5 border border-[var(--glass-stroke-strong)] px-3 text-sm transition-all hover:border-[var(--glass-tone-info-fg)]/40"
|
||||
>
|
||||
<AppIcon name="sparkles" className="w-4 h-4 text-[#7c3aed]" />
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{t('aiWrite.trigger')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* AI 帮我写模态框 */}
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import '@/styles/animations.css'
|
||||
import AiWriteModal from '@/components/home/AiWriteModal'
|
||||
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
|
||||
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||
import { DEFAULT_STYLE_PRESET_VALUE, STYLE_PRESETS } from '@/lib/style-presets'
|
||||
import { PROJECT_STORY_INPUT_MIN_ROWS } from '@/lib/ui/textarea-height'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
import { expandHomeStory } from '@/lib/home/ai-story-expand'
|
||||
|
||||
/** 触发智能分集建议的字数阈值 */
|
||||
const LONG_TEXT_THRESHOLD = 1000
|
||||
@@ -58,6 +63,7 @@ export default function NovelInputStage({
|
||||
onArtStyleChange
|
||||
}: NovelInputStageProps) {
|
||||
const t = useTranslations('novelPromotion')
|
||||
const homeT = useTranslations('home')
|
||||
|
||||
// ── IME 组合输入处理 ──
|
||||
// 中文/日文/韩文输入法在组合(composing)期间会持续触发 onChange,
|
||||
@@ -66,6 +72,9 @@ export default function NovelInputStage({
|
||||
// 解决方案:组合期间仅更新本地 state,组合结束后再同步到父组件。
|
||||
const isComposingRef = useRef(false)
|
||||
const [localText, setLocalText] = useState(novelText)
|
||||
const [stylePresetValue, setStylePresetValue] = useState<string>(DEFAULT_STYLE_PRESET_VALUE)
|
||||
const [aiWriteOpen, setAiWriteOpen] = useState(false)
|
||||
const [aiWriteLoading, setAiWriteLoading] = useState(false)
|
||||
|
||||
// 当父组件的 novelText 变化(非本地编辑触发)时,同步到本地 state
|
||||
useEffect(() => {
|
||||
@@ -74,15 +83,6 @@ export default function NovelInputStage({
|
||||
}
|
||||
}, [novelText])
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
setLocalText(newValue)
|
||||
// 仅在非 IME 组合状态下才同步到父组件
|
||||
if (!isComposingRef.current) {
|
||||
onNovelTextChange(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
isComposingRef.current = true
|
||||
}
|
||||
@@ -106,23 +106,25 @@ export default function NovelInputStage({
|
||||
}
|
||||
}, [localText, onNext, onSmartSplit])
|
||||
|
||||
// 当前配置展示文案
|
||||
const ratioDisplayLabel = (VIDEO_RATIOS.find((option) => option.value === videoRatio) ?? VIDEO_RATIOS[0])?.label
|
||||
const artStyleDisplayLabel = (ART_STYLES.find((option) => option.value === artStyle) ?? ART_STYLES[0])?.label
|
||||
const handleAiWriteStart = useCallback(async (prompt: string) => {
|
||||
if (aiWriteLoading) return
|
||||
setAiWriteLoading(true)
|
||||
try {
|
||||
const result = await expandHomeStory({
|
||||
apiFetch,
|
||||
prompt,
|
||||
})
|
||||
|
||||
// 不同比例适合的素材类型文案映射(完整句子,用于 info 悬浮层)
|
||||
const ratioUsageTextMap: Record<string, string> = {
|
||||
'1:1': t('storyInput.ratioUsage.1_1'),
|
||||
'9:16': t('storyInput.ratioUsage.9_16'),
|
||||
'16:9': t('storyInput.ratioUsage.16_9'),
|
||||
'4:3': t('storyInput.ratioUsage.4_3'),
|
||||
'3:4': t('storyInput.ratioUsage.3_4'),
|
||||
'2:3': t('storyInput.ratioUsage.2_3'),
|
||||
'3:2': t('storyInput.ratioUsage.3_2'),
|
||||
'4:5': t('storyInput.ratioUsage.4_5'),
|
||||
'5:4': t('storyInput.ratioUsage.5_4'),
|
||||
'21:9': t('storyInput.ratioUsage.21_9'),
|
||||
}
|
||||
setLocalText(result.expandedText)
|
||||
onNovelTextChange(result.expandedText)
|
||||
setAiWriteOpen(false)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed'
|
||||
window.alert(message)
|
||||
} finally {
|
||||
setAiWriteLoading(false)
|
||||
}
|
||||
}, [aiWriteLoading, onNovelTextChange])
|
||||
|
||||
// 下拉中使用的简短标签(低信息密度)
|
||||
const ratioUsageTagMap: Record<string, string> = {
|
||||
@@ -138,13 +140,9 @@ export default function NovelInputStage({
|
||||
'21:9': t('storyInput.ratioUsageTag.21_9'),
|
||||
}
|
||||
|
||||
const getRatioUsageText = (ratio: string): string =>
|
||||
ratioUsageTextMap[ratio] ?? t('storyInput.videoRatioHint')
|
||||
|
||||
const getRatioUsageTag = (ratio: string): string =>
|
||||
ratioUsageTagMap[ratio] ?? ''
|
||||
|
||||
const ratioUsageText = getRatioUsageText(videoRatio)
|
||||
const stageSwitchingState = isSwitchingStage
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
@@ -168,81 +166,82 @@ export default function NovelInputStage({
|
||||
)}
|
||||
|
||||
{/* 主输入区域(含底部工具栏) */}
|
||||
<div className="glass-surface-elevated overflow-hidden relative z-10">
|
||||
<div className="p-6 pb-0">
|
||||
{/* 字数统计 */}
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
<span className="glass-chip glass-chip-neutral text-xs">
|
||||
{t("storyInput.wordCount")} {localText.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 剧本输入框 */}
|
||||
<textarea
|
||||
value={localText}
|
||||
onChange={handleTextChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
placeholder={`请输入您的剧本或小说内容...\n\nAI 将根据您的文本智能分析:\n• 自动识别场景切换\n• 提取角色对话和动作\n• 生成分镜脚本\n\n例如:\n清晨,阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起,看了一眼床头的闹钟——已经八点了!他猛地跳下床,手忙脚乱地开始穿衣服...`}
|
||||
className="glass-textarea-base custom-scrollbar h-80 px-4 py-3 text-base resize-none placeholder:text-[var(--glass-text-tertiary)]"
|
||||
disabled={isSubmittingTask || isSwitchingStage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部工具栏:比例 + 风格 + 开始创作(内嵌在输入框卡片内) */}
|
||||
<div className="flex items-end gap-3 px-6 py-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={(value) => onVideoRatioChange?.(value)}
|
||||
options={VIDEO_RATIOS.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === '9:16'
|
||||
}))}
|
||||
getUsage={getRatioUsageTag}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={(value) => onArtStyleChange?.(value)}
|
||||
options={ART_STYLES.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === 'realistic'
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStartClick}
|
||||
disabled={!hasContent || isSubmittingTask || isSwitchingStage}
|
||||
className="glass-btn-base glass-btn-primary px-5 py-2.5 text-sm flex-shrink-0 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSwitchingStage ? (
|
||||
<TaskStatusInline state={stageSwitchingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<span>{t("smartImport.manualCreate.button")}</span>
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 配置提示 */}
|
||||
<div className="px-6 pb-4 space-y-1 text-center">
|
||||
<p className="text-xs text-[var(--glass-text-secondary)]">
|
||||
{t("storyInput.currentConfigSummary", {
|
||||
ratio: ratioDisplayLabel,
|
||||
style: artStyleDisplayLabel
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{t("storyInput.moreConfig")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<StoryInputComposer
|
||||
value={localText}
|
||||
onValueChange={(value) => {
|
||||
setLocalText(value)
|
||||
if (!isComposingRef.current) {
|
||||
onNovelTextChange(value)
|
||||
}
|
||||
}}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
placeholder={`请输入您的剧本或小说内容...\n\nAI 将根据您的文本智能分析:\n• 自动识别场景切换\n• 提取角色对话和动作\n• 生成分镜脚本\n\n例如:\n清晨,阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起,看了一眼床头的闹钟——已经八点了!他猛地跳下床,手忙脚乱地开始穿衣服...`}
|
||||
minRows={PROJECT_STORY_INPUT_MIN_ROWS}
|
||||
maxHeightViewportRatio={0.5}
|
||||
disabled={isSubmittingTask || isSwitchingStage}
|
||||
videoRatio={videoRatio}
|
||||
onVideoRatioChange={(value) => onVideoRatioChange?.(value)}
|
||||
ratioOptions={VIDEO_RATIOS.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === '9:16'
|
||||
}))}
|
||||
getRatioUsage={getRatioUsageTag}
|
||||
artStyle={artStyle}
|
||||
onArtStyleChange={(value) => onArtStyleChange?.(value)}
|
||||
styleOptions={ART_STYLES.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === 'realistic'
|
||||
}))}
|
||||
stylePresetValue={stylePresetValue}
|
||||
onStylePresetChange={setStylePresetValue}
|
||||
stylePresetOptions={STYLE_PRESETS}
|
||||
textareaClassName="text-base p-5 pb-3"
|
||||
primaryAction={(
|
||||
<button
|
||||
onClick={handleStartClick}
|
||||
disabled={!hasContent || isSubmittingTask || isSwitchingStage}
|
||||
className="glass-btn-base glass-btn-primary h-10 flex-shrink-0 px-5 text-sm disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSwitchingStage ? (
|
||||
<TaskStatusInline state={stageSwitchingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<span>{t("smartImport.manualCreate.button")}</span>
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
secondaryActions={(
|
||||
<button
|
||||
onClick={() => setAiWriteOpen(true)}
|
||||
disabled={isSubmittingTask || isSwitchingStage}
|
||||
className="glass-btn-base flex h-10 flex-shrink-0 items-center gap-1.5 border border-[var(--glass-stroke-strong)] px-3 text-sm transition-all hover:border-[var(--glass-tone-info-fg)]/40"
|
||||
>
|
||||
<AppIcon name="sparkles" className="w-4 h-4 text-[#7c3aed]" />
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{homeT('aiWrite.trigger')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<AiWriteModal
|
||||
open={aiWriteOpen}
|
||||
loading={aiWriteLoading}
|
||||
onClose={() => setAiWriteOpen(false)}
|
||||
onStart={(prompt) => void handleAiWriteStart(prompt)}
|
||||
t={(key: string) => homeT(`aiWrite.${key}`)}
|
||||
/>
|
||||
|
||||
{/* 资产库引导提示 */}
|
||||
<div className="glass-surface p-4">
|
||||
|
||||
@@ -282,6 +282,21 @@ export function AssetGrid({
|
||||
}
|
||||
|
||||
const isEmpty = characters.length === 0 && locations.length === 0 && props.length === 0 && voices.length === 0
|
||||
const visibleAssetCount = (() => {
|
||||
switch (filter) {
|
||||
case 'character':
|
||||
return characters.length
|
||||
case 'location':
|
||||
return locations.length
|
||||
case 'prop':
|
||||
return props.length
|
||||
case 'voice':
|
||||
return voices.length
|
||||
case 'all':
|
||||
default:
|
||||
return characters.length + locations.length + props.length + voices.length
|
||||
}
|
||||
})()
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: t('allAssets') },
|
||||
@@ -300,6 +315,8 @@ export function AssetGrid({
|
||||
options={tabs.map(tab => ({ value: tab.id, label: tab.label }))}
|
||||
value={filter}
|
||||
onChange={(val) => setFilter(val as 'all' | 'character' | 'location' | 'prop' | 'voice')}
|
||||
layout="compact"
|
||||
className="min-w-max"
|
||||
/>
|
||||
|
||||
{/* 右侧操作按钮 */}
|
||||
@@ -332,6 +349,20 @@ export function AssetGrid({
|
||||
</div>
|
||||
<p className="text-[var(--glass-text-secondary)] mb-2">{t('emptyState')}</p>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('emptyStateHint')}</p>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<AddAssetDropdown
|
||||
onAddCharacter={onAddCharacter}
|
||||
onAddLocation={onAddLocation}
|
||||
onAddProp={onAddProp}
|
||||
onAddVoice={onAddVoice}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : visibleAssetCount === 0 ? (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)]">
|
||||
{t('filteredEmptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
|
||||
106
src/components/home/TypewriterHero.tsx
Normal file
106
src/components/home/TypewriterHero.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* TypewriterHero — 标题 + 终端打字机副标题
|
||||
* 对焦动画保留,取景器四角线移至页面级包裹
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
const TYPE_SPEED = 55
|
||||
const DELETE_SPEED = 20
|
||||
const PAUSE_AFTER_TYPE = 3200
|
||||
const PAUSE_AFTER_DELETE = 500
|
||||
|
||||
interface TypewriterHeroProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
export default function TypewriterHero({ title, subtitle }: TypewriterHeroProps) {
|
||||
const [text, setText] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const prevLenRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
prevLenRef.current = text.length
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout
|
||||
if (!isDeleting && text.length === subtitle.length) {
|
||||
timeout = setTimeout(() => setIsDeleting(true), PAUSE_AFTER_TYPE)
|
||||
} else if (isDeleting && text.length === 0) {
|
||||
timeout = setTimeout(() => setIsDeleting(false), PAUSE_AFTER_DELETE)
|
||||
} else {
|
||||
timeout = setTimeout(
|
||||
() => setText(subtitle.slice(0, text.length + (isDeleting ? -1 : 1))),
|
||||
isDeleting ? DELETE_SPEED : TYPE_SPEED
|
||||
)
|
||||
}
|
||||
return () => clearTimeout(timeout)
|
||||
}, [text, isDeleting, subtitle])
|
||||
|
||||
const isNewChar = (i: number) =>
|
||||
!isDeleting && i === text.length - 1 && text.length > prevLenRef.current
|
||||
|
||||
return (
|
||||
<div className="text-center mb-4">
|
||||
<style>{`
|
||||
@keyframes twh-focus-pull {
|
||||
0%, 70%, 100% { filter: blur(0px); opacity: 1; }
|
||||
75% { filter: blur(3px); opacity: 0.85; }
|
||||
80% { filter: blur(1.5px); opacity: 0.9; }
|
||||
85% { filter: blur(0.5px); opacity: 0.95; }
|
||||
88% { filter: blur(1px); opacity: 0.92; }
|
||||
92% { filter: blur(0px); opacity: 1; }
|
||||
}
|
||||
@keyframes twh-charIn {
|
||||
0% { opacity: 0; transform: translateY(6px) scale(0.8); }
|
||||
60% { opacity: 1; transform: translateY(-1px) scale(1.05); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes twh-hover {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-1.5px); }
|
||||
}
|
||||
@keyframes twh-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 标题 — 带对焦动画 */}
|
||||
<h1
|
||||
className="text-3xl font-bold text-[var(--glass-text-primary)] tracking-[0.08em] mb-2"
|
||||
style={{ animation: 'twh-focus-pull 8s ease-in-out infinite' }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* 终端打字机副标题 */}
|
||||
<p className="font-mono text-sm h-6 flex items-center justify-center" style={{ color: 'var(--glass-text-tertiary)' }}>
|
||||
<span className="mr-1.5 opacity-50">>_</span>
|
||||
{text.split('').map((char, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
animationName: isNewChar(i) ? 'twh-charIn' : 'twh-hover',
|
||||
animationDuration: isNewChar(i) ? '0.25s' : '3s',
|
||||
animationTimingFunction: isNewChar(i) ? 'ease-out' : 'ease-in-out',
|
||||
animationIterationCount: isNewChar(i) ? 1 : 'infinite',
|
||||
animationFillMode: isNewChar(i) ? 'forwards' : 'none',
|
||||
animationDelay: isNewChar(i) ? '0s' : `${i * 0.08}s`,
|
||||
}}
|
||||
>
|
||||
{char === ' ' ? '\u00A0' : char}
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className="inline-block w-2 h-4 ml-0.5 bg-[var(--glass-text-tertiary)] align-middle rounded-[1px]"
|
||||
style={{ animation: 'twh-blink 1s step-end infinite' }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,9 +6,80 @@
|
||||
*
|
||||
* 使用场景:首页、项目故事输入页
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useCallback, type CSSProperties } from 'react'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
const TRIGGER_CLASSNAME = 'glass-input-base flex h-10 w-full items-center justify-between gap-2 px-2.5 transition-colors'
|
||||
const TRIGGER_TEXT_CLASSNAME = 'text-[13px] font-medium text-[var(--glass-text-primary)]'
|
||||
|
||||
const VIEWPORT_EDGE_GAP = 8
|
||||
const DEFAULT_MAX_HEIGHT = 280
|
||||
|
||||
function useFloatingDropdown(isOpen: boolean, minWidth: number) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [panelStyle, setPanelStyle] = useState<CSSProperties>({})
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current || typeof window === 'undefined') return
|
||||
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_EDGE_GAP
|
||||
const spaceAbove = rect.top - VIEWPORT_EDGE_GAP
|
||||
const openUpward = spaceBelow < 220 && spaceAbove > spaceBelow
|
||||
const availableSpace = openUpward ? spaceAbove : spaceBelow
|
||||
const width = Math.min(
|
||||
Math.max(rect.width, minWidth),
|
||||
viewportWidth - VIEWPORT_EDGE_GAP * 2,
|
||||
)
|
||||
const left = Math.min(
|
||||
Math.max(VIEWPORT_EDGE_GAP, rect.left),
|
||||
viewportWidth - width - VIEWPORT_EDGE_GAP,
|
||||
)
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width,
|
||||
maxHeight: Math.max(120, Math.min(DEFAULT_MAX_HEIGHT, availableSpace)),
|
||||
...(openUpward
|
||||
? { bottom: viewportHeight - rect.top + 4 }
|
||||
: { top: rect.bottom + 4 }),
|
||||
})
|
||||
}, [minWidth])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
if (!isOpen) return
|
||||
setPanelStyle({})
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [isOpen, updatePosition])
|
||||
|
||||
return { triggerRef, panelRef, panelStyle }
|
||||
}
|
||||
|
||||
/** 线框比例预览块 */
|
||||
function RatioShape({ ratio, selected, size = 26 }: { ratio: string; selected: boolean; size?: number }) {
|
||||
const [w, h] = ratio.split(':').map(Number)
|
||||
@@ -38,38 +109,43 @@ export function RatioSelector({
|
||||
getUsage?: (ratio: string) => string
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 300)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
const target = event.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
}, [isOpen, panelRef, triggerRef])
|
||||
|
||||
const selectedOption = options.find((o) => o.value === value)
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
className={`${TRIGGER_CLASSNAME} cursor-pointer`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<RatioShape ratio={value} size={18} selected />
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption?.label || value}</span>
|
||||
<span className={`${TRIGGER_TEXT_CLASSNAME} truncate`}>{selectedOption?.label || value}</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
|
||||
style={{ minWidth: '300px' }}
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] p-3 overflow-y-auto custom-scrollbar"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{options.map((option) => {
|
||||
@@ -98,9 +174,10 @@ export function RatioSelector({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,33 +191,44 @@ export function StyleSelector({
|
||||
options: { value: string; label: string; recommended?: boolean }[]
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 320)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
const target = event.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
}, [isOpen, panelRef, triggerRef])
|
||||
|
||||
const selectedOption = options.find((o) => o.value === value) || options[0]
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="glass-input-base h-11 px-3 flex w-full items-center justify-between gap-2 cursor-pointer transition-colors"
|
||||
className={`${TRIGGER_CLASSNAME} cursor-pointer`}
|
||||
>
|
||||
<span className="text-sm text-[var(--glass-text-primary)] font-medium">{selectedOption.label}</span>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AppIcon name="sparklesAlt" className="h-4 w-4 text-[var(--glass-accent-from)]" />
|
||||
<span className={`${TRIGGER_TEXT_CLASSNAME} truncate`}>{selectedOption.label}</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute z-50 mt-1 left-0 p-3" style={{ minWidth: '320px' }}>
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] p-3"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value
|
||||
@@ -165,8 +253,125 @@ export function StyleSelector({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StylePresetSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: readonly { value: string; label: string; description: string }[]
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { triggerRef, panelRef, panelStyle } = useFloatingDropdown(isOpen, 260)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Node
|
||||
if (triggerRef.current?.contains(target)) return
|
||||
if (panelRef.current?.contains(target)) return
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen, panelRef, triggerRef])
|
||||
|
||||
const selectedOption = options.find((option) => option.value === value) ?? options[0]
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`${TRIGGER_CLASSNAME} cursor-pointer`}
|
||||
title={selectedOption.label}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AppIcon name="clapperboard" className="h-4 w-4 shrink-0 text-[var(--glass-accent-from)]" />
|
||||
<span className={`${TRIGGER_TEXT_CLASSNAME} min-w-0 flex-1 truncate`}>
|
||||
{selectedOption.label}
|
||||
</span>
|
||||
</div>
|
||||
<AppIcon name="chevronDown" className={`h-4 w-4 text-[var(--glass-text-tertiary)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] p-2.5"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-[var(--glass-accent-from)] bg-[var(--glass-accent-from)]/5 shadow-sm'
|
||||
: 'border-[var(--glass-stroke-soft)] hover:border-[var(--glass-stroke-strong)]'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className={`text-sm ${isSelected ? 'font-semibold text-[var(--glass-accent-from)]' : 'font-medium text-[var(--glass-text-primary)]'}`}>
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<AppIcon name="check" className="h-4 w-4 shrink-0 text-[var(--glass-accent-from)]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StylePresetBadge({
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-input-base relative flex h-10 w-full items-center gap-2 overflow-hidden px-2.5">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(99,102,241,0.1))',
|
||||
}}
|
||||
/>
|
||||
<AppIcon name="clapperboard" className="relative h-4 w-4 shrink-0 text-[var(--glass-accent-from)]" />
|
||||
<span className="relative min-w-0 flex-1 truncate text-[13px] font-semibold text-[var(--glass-text-primary)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="relative shrink-0 rounded-full bg-[var(--glass-tone-info-bg)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--glass-tone-info-fg)]">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
170
src/components/story-input/StoryInputComposer.tsx
Normal file
170
src/components/story-input/StoryInputComposer.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, type CompositionEvent, type ReactNode } from 'react'
|
||||
import { RatioSelector, StylePresetSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||
import { resolveTextareaTargetHeight } from '@/lib/ui/textarea-height'
|
||||
|
||||
interface StoryInputComposerOption {
|
||||
value: string
|
||||
label: string
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
interface StoryInputComposerStylePresetOption {
|
||||
value: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface StoryInputComposerProps {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
placeholder: string
|
||||
minRows: number
|
||||
disabled?: boolean
|
||||
maxHeightViewportRatio?: number
|
||||
topRight?: ReactNode
|
||||
footer?: ReactNode
|
||||
secondaryActions?: ReactNode
|
||||
primaryAction: ReactNode
|
||||
videoRatio: string
|
||||
onVideoRatioChange: (value: string) => void
|
||||
ratioOptions: StoryInputComposerOption[]
|
||||
getRatioUsage?: (ratio: string) => string
|
||||
artStyle: string
|
||||
onArtStyleChange: (value: string) => void
|
||||
styleOptions: StoryInputComposerOption[]
|
||||
stylePresetValue: string
|
||||
onStylePresetChange: (value: string) => void
|
||||
stylePresetOptions: readonly StoryInputComposerStylePresetOption[]
|
||||
onCompositionStart?: () => void
|
||||
onCompositionEnd?: (event: CompositionEvent<HTMLTextAreaElement>) => void
|
||||
textareaClassName?: string
|
||||
}
|
||||
|
||||
export default function StoryInputComposer({
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
minRows,
|
||||
disabled = false,
|
||||
maxHeightViewportRatio = 0.5,
|
||||
topRight,
|
||||
footer,
|
||||
secondaryActions,
|
||||
primaryAction,
|
||||
videoRatio,
|
||||
onVideoRatioChange,
|
||||
ratioOptions,
|
||||
getRatioUsage,
|
||||
artStyle,
|
||||
onArtStyleChange,
|
||||
styleOptions,
|
||||
stylePresetValue,
|
||||
onStylePresetChange,
|
||||
stylePresetOptions,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
textareaClassName,
|
||||
}: StoryInputComposerProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const textareaMinHeightRef = useRef<number | null>(null)
|
||||
|
||||
const autoResizeTextarea = useCallback(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el || typeof window === 'undefined') return
|
||||
|
||||
const maxHeight = window.innerHeight * maxHeightViewportRatio
|
||||
const oldHeight = el.offsetHeight
|
||||
const oldScrollTop = el.scrollTop
|
||||
|
||||
if (textareaMinHeightRef.current === null && oldHeight > 0) {
|
||||
textareaMinHeightRef.current = oldHeight
|
||||
}
|
||||
|
||||
const minHeight = textareaMinHeightRef.current ?? oldHeight
|
||||
|
||||
el.style.transition = 'none'
|
||||
el.style.height = 'auto'
|
||||
const scrollHeight = el.scrollHeight
|
||||
const targetHeight = resolveTextareaTargetHeight({
|
||||
minHeight,
|
||||
maxHeight,
|
||||
scrollHeight,
|
||||
})
|
||||
el.style.height = `${oldHeight}px`
|
||||
el.scrollTop = oldScrollTop
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = oldScrollTop
|
||||
el.style.transition = 'height 200ms ease-out'
|
||||
el.style.height = `${targetHeight}px`
|
||||
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
|
||||
})
|
||||
}, [maxHeightViewportRatio])
|
||||
|
||||
useEffect(() => {
|
||||
autoResizeTextarea()
|
||||
}, [value, autoResizeTextarea])
|
||||
|
||||
return (
|
||||
<div className="relative w-full glass-surface-elevated rounded-2xl">
|
||||
<div className="p-6 pb-0">
|
||||
{topRight && (
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
{topRight}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
placeholder={placeholder}
|
||||
rows={minRows}
|
||||
disabled={disabled}
|
||||
className={`w-full resize-none border-none bg-transparent text-base text-[var(--glass-text-primary)] outline-none placeholder:text-[var(--glass-text-tertiary)] custom-scrollbar ${textareaClassName ?? 'p-5 pb-3'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 overflow-x-auto px-5 pb-4">
|
||||
<div className="flex min-w-max flex-1 items-center gap-2">
|
||||
<div className="w-[118px] flex-shrink-0">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={onVideoRatioChange}
|
||||
options={ratioOptions}
|
||||
getUsage={getRatioUsage}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[132px] flex-shrink-0">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={onArtStyleChange}
|
||||
options={styleOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[152px] flex-shrink-0">
|
||||
<StylePresetSelector
|
||||
value={stylePresetValue}
|
||||
onChange={onStylePresetChange}
|
||||
options={stylePresetOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-max items-center gap-2">
|
||||
{secondaryActions}
|
||||
{primaryAction}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-6 pb-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
src/lib/style-presets.ts
Normal file
16
src/lib/style-presets.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const STYLE_PRESETS = [
|
||||
{
|
||||
value: 'horror-suspense',
|
||||
label: '恐怖悬疑',
|
||||
description: '压迫氛围',
|
||||
},
|
||||
] as const
|
||||
|
||||
export type StylePresetOption = (typeof STYLE_PRESETS)[number]
|
||||
export type StylePresetValue = StylePresetOption['value']
|
||||
|
||||
export const DEFAULT_STYLE_PRESET_VALUE: StylePresetValue = 'horror-suspense'
|
||||
|
||||
export function getStylePresetOption(value: string): StylePresetOption {
|
||||
return STYLE_PRESETS.find((preset) => preset.value === value) ?? STYLE_PRESETS[0]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export const HOME_QUICK_START_MIN_ROWS = 3
|
||||
export const PROJECT_STORY_INPUT_MIN_ROWS = 8
|
||||
|
||||
interface ResolveTextareaTargetHeightInput {
|
||||
minHeight: number
|
||||
158
tests/unit/components/asset-grid.test.ts
Normal file
158
tests/unit/components/asset-grid.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
import { AssetGrid } from '@/app/[locale]/workspace/asset-hub/components/AssetGrid'
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useState: <T,>(initialState: T | (() => T)) => {
|
||||
const resolvedInitialState = typeof initialState === 'function'
|
||||
? (initialState as () => T)()
|
||||
: initialState
|
||||
|
||||
if (resolvedInitialState === 'all') {
|
||||
return actual.useState('location' as T)
|
||||
}
|
||||
|
||||
return actual.useState(resolvedInitialState)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/asset-hub/components/CharacterCard', () => ({
|
||||
CharacterCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/asset-hub/components/LocationCard', () => ({
|
||||
LocationCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/[locale]/workspace/asset-hub/components/VoiceCard', () => ({
|
||||
VoiceCard: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assetHub: {
|
||||
allAssets: '所有资产',
|
||||
characters: '角色',
|
||||
locations: '场景',
|
||||
props: '道具',
|
||||
voices: '音色',
|
||||
addAsset: '新建资产',
|
||||
addCharacter: '新建角色',
|
||||
addLocation: '新建场景',
|
||||
addProp: '新建道具',
|
||||
addVoice: '新建音色',
|
||||
downloadAll: '打包下载',
|
||||
downloadAllTitle: '下载全部图片资产',
|
||||
downloading: '打包中...',
|
||||
emptyState: '暂无资产',
|
||||
emptyStateHint: '点击上方按钮添加角色或场景',
|
||||
filteredEmptyHint: '点击新建资产添加资产',
|
||||
pagination: {
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const renderWithIntl = (node: ReactElement) => {
|
||||
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
children: node,
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(
|
||||
createElement(NextIntlClientProvider, providerProps),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AssetGrid', () => {
|
||||
it('空状态下使用与资产库一致的 compact 分段控件,并在中间显示新建资产按钮', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderWithIntl(
|
||||
createElement(AssetGrid, {
|
||||
assets: [],
|
||||
loading: false,
|
||||
onAddCharacter: () => undefined,
|
||||
onAddLocation: () => undefined,
|
||||
onAddProp: () => undefined,
|
||||
onAddVoice: () => undefined,
|
||||
onDownloadAll: () => undefined,
|
||||
isDownloading: false,
|
||||
selectedFolderId: null,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('inline-block max-w-full min-w-max')
|
||||
expect(html).toContain('inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]')
|
||||
expect(html).toContain('justify-center')
|
||||
expect(html).toContain('>新建资产<')
|
||||
})
|
||||
|
||||
it('当前筛选分类没有资产时显示添加提示文案', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderWithIntl(
|
||||
createElement(AssetGrid, {
|
||||
assets: [
|
||||
{
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
family: 'visual',
|
||||
scope: 'project',
|
||||
name: '角色A',
|
||||
folderId: null,
|
||||
capabilities: {
|
||||
canGenerate: true,
|
||||
canSelectRender: false,
|
||||
canRevertRender: false,
|
||||
canModifyRender: false,
|
||||
canUploadRender: false,
|
||||
canBindVoice: false,
|
||||
canCopyFromGlobal: false,
|
||||
},
|
||||
taskRefs: [],
|
||||
taskState: { isRunning: false, lastError: null },
|
||||
variants: [],
|
||||
introduction: null,
|
||||
profileData: null,
|
||||
profileConfirmed: null,
|
||||
profileTaskRefs: [],
|
||||
profileTaskState: { isRunning: false, lastError: null },
|
||||
voice: {
|
||||
voiceType: null,
|
||||
voiceId: null,
|
||||
customVoiceUrl: null,
|
||||
media: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
onAddCharacter: () => undefined,
|
||||
onAddLocation: () => undefined,
|
||||
onAddProp: () => undefined,
|
||||
onAddVoice: () => undefined,
|
||||
onDownloadAll: () => undefined,
|
||||
isDownloading: false,
|
||||
selectedFolderId: null,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('点击新建资产添加资产')
|
||||
})
|
||||
})
|
||||
107
tests/unit/components/ratio-style-selectors.test.ts
Normal file
107
tests/unit/components/ratio-style-selectors.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RatioSelector, StylePresetSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||
|
||||
const portalMocks = vi.hoisted(() => {
|
||||
return {
|
||||
currentPortalTarget: null as unknown,
|
||||
createPortalMock: vi.fn((node: React.ReactNode, target: unknown) => {
|
||||
const targetLabel = target === portalMocks.currentPortalTarget ? 'body' : 'unknown'
|
||||
return createElement('div', { 'data-portal-target': targetLabel }, node)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useState: <T,>(initialState: T | (() => T)) => {
|
||||
const resolvedInitialState = typeof initialState === 'function'
|
||||
? (initialState as () => T)()
|
||||
: initialState
|
||||
|
||||
if (resolvedInitialState === false) {
|
||||
return actual.useState(true as T)
|
||||
}
|
||||
|
||||
return actual.useState(resolvedInitialState)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
|
||||
return {
|
||||
...actual,
|
||||
createPortal: portalMocks.createPortalMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: ({ name, className }: { name: string; className?: string }) =>
|
||||
createElement('span', { 'data-icon': name, className }),
|
||||
}))
|
||||
|
||||
describe('RatioStyleSelectors', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
portalMocks.currentPortalTarget = null
|
||||
Reflect.deleteProperty(globalThis, 'React')
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
})
|
||||
|
||||
it('renders ratio, style, and style preset dropdown panels through a portal to document.body', () => {
|
||||
const fakeDocument = {
|
||||
body: { nodeName: 'BODY' },
|
||||
}
|
||||
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
portalMocks.currentPortalTarget = fakeDocument.body
|
||||
Reflect.set(globalThis, 'document', fakeDocument)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement('div', null,
|
||||
createElement(RatioSelector, {
|
||||
value: '9:16',
|
||||
onChange: () => undefined,
|
||||
options: [
|
||||
{ value: '9:16', label: '9:16' },
|
||||
{ value: '16:9', label: '16:9' },
|
||||
],
|
||||
}),
|
||||
createElement(StyleSelector, {
|
||||
value: 'realistic',
|
||||
onChange: () => undefined,
|
||||
options: [
|
||||
{ value: 'realistic', label: '真人风格' },
|
||||
{ value: 'american-comic', label: '美漫风格' },
|
||||
],
|
||||
}),
|
||||
createElement(StylePresetSelector, {
|
||||
value: 'horror-suspense',
|
||||
onChange: () => undefined,
|
||||
options: [
|
||||
{ value: 'horror-suspense', label: '恐怖悬疑', description: '压迫氛围' },
|
||||
{ value: 'dark-noir', label: '暗黑黑色', description: '冷峻低照' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(3)
|
||||
expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)
|
||||
expect(portalMocks.createPortalMock.mock.calls[1]?.[1]).toBe(fakeDocument.body)
|
||||
expect(portalMocks.createPortalMock.mock.calls[2]?.[1]).toBe(fakeDocument.body)
|
||||
expect(html).toContain('data-portal-target="body"')
|
||||
expect(html).toContain('data-icon="sparklesAlt"')
|
||||
expect(html).toContain('data-icon="clapperboard"')
|
||||
expect(html).toContain('真人风格')
|
||||
expect(html).toContain('16:9')
|
||||
expect(html).toContain('恐怖悬疑')
|
||||
expect(html).toContain('压迫氛围')
|
||||
})
|
||||
})
|
||||
51
tests/unit/components/story-input-composer.test.ts
Normal file
51
tests/unit/components/story-input-composer.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
|
||||
|
||||
vi.mock('@/components/selectors/RatioStyleSelectors', () => ({
|
||||
RatioSelector: ({
|
||||
getUsage: _getUsage,
|
||||
...props
|
||||
}: Record<string, unknown> & { getUsage?: unknown }) => createElement('div', props, 'RatioSelector'),
|
||||
StyleSelector: (props: Record<string, unknown>) => createElement('div', props, 'StyleSelector'),
|
||||
StylePresetSelector: (props: Record<string, unknown>) => createElement('div', props, 'StylePresetSelector'),
|
||||
}))
|
||||
|
||||
describe('StoryInputComposer', () => {
|
||||
it('renders a shared composer shell with configurable textarea rows and shared controls', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(StoryInputComposer, {
|
||||
value: '测试内容',
|
||||
onValueChange: () => undefined,
|
||||
placeholder: '请输入内容',
|
||||
minRows: 8,
|
||||
videoRatio: '9:16',
|
||||
onVideoRatioChange: () => undefined,
|
||||
ratioOptions: [{ value: '9:16', label: '9:16' }],
|
||||
artStyle: 'realistic',
|
||||
onArtStyleChange: () => undefined,
|
||||
styleOptions: [{ value: 'realistic', label: '真人风格' }],
|
||||
stylePresetValue: 'horror-suspense',
|
||||
onStylePresetChange: () => undefined,
|
||||
stylePresetOptions: [{ value: 'horror-suspense', label: '恐怖悬疑', description: '压迫氛围' }],
|
||||
topRight: createElement('span', null, '字数:4'),
|
||||
footer: createElement('p', null, '当前配置'),
|
||||
secondaryActions: createElement('button', { type: 'button' }, 'AI 帮我写'),
|
||||
primaryAction: createElement('button', { type: 'button' }, '开始创作'),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('rows="8"')
|
||||
expect(html).toContain('RatioSelector')
|
||||
expect(html).toContain('StyleSelector')
|
||||
expect(html).toContain('StylePresetSelector')
|
||||
expect(html).toContain('字数:4')
|
||||
expect(html).toContain('当前配置')
|
||||
expect(html).toContain('AI 帮我写')
|
||||
expect(html).toContain('开始创作')
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import HomePage from '@/app/[locale]/home/page'
|
||||
import {
|
||||
HOME_QUICK_START_MIN_ROWS,
|
||||
resolveTextareaTargetHeight,
|
||||
} from '@/lib/home/quick-start-textarea'
|
||||
} from '@/lib/ui/textarea-height'
|
||||
|
||||
vi.mock('next-auth/react', () => ({
|
||||
useSession: () => ({
|
||||
@@ -23,17 +23,30 @@ vi.mock('@/components/Navbar', () => ({
|
||||
default: () => createElement('nav', null, 'Navbar'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/story-input/StoryInputComposer', () => ({
|
||||
default: ({
|
||||
minRows,
|
||||
primaryAction,
|
||||
secondaryActions,
|
||||
}: {
|
||||
minRows: number
|
||||
primaryAction: React.ReactNode
|
||||
secondaryActions?: React.ReactNode
|
||||
}) => createElement(
|
||||
'section',
|
||||
{ 'data-min-rows': String(minRows) },
|
||||
secondaryActions,
|
||||
primaryAction,
|
||||
'StoryInputComposer',
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: ({ name, ...props }: { name: string } & Record<string, unknown>) =>
|
||||
createElement('span', { ...props, 'data-icon': name }),
|
||||
IconGradientDefs: (props: Record<string, unknown>) => createElement('span', props),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/selectors/RatioStyleSelectors', () => ({
|
||||
RatioSelector: (props: Record<string, unknown>) => createElement('div', props, 'RatioSelector'),
|
||||
StyleSelector: (props: Record<string, unknown>) => createElement('div', props, 'StyleSelector'),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({
|
||||
href,
|
||||
@@ -88,6 +101,7 @@ describe('HomePage quick-start input', () => {
|
||||
const html = renderToStaticMarkup(createElement(HomePage))
|
||||
|
||||
expect(HOME_QUICK_START_MIN_ROWS).toBe(3)
|
||||
expect(html).toContain('rows="3"')
|
||||
expect(html).toContain('StoryInputComposer')
|
||||
expect(html).toContain('data-min-rows="3"')
|
||||
})
|
||||
})
|
||||
|
||||
87
tests/unit/novel-promotion/novel-input-stage.test.ts
Normal file
87
tests/unit/novel-promotion/novel-input-stage.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import NovelInputStage from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/NovelInputStage'
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, string | number>) => {
|
||||
if (values && 'name' in values) {
|
||||
return `${key}:${String(values.name)}`
|
||||
}
|
||||
return key
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/story-input/StoryInputComposer', () => ({
|
||||
default: ({
|
||||
minRows,
|
||||
maxHeightViewportRatio,
|
||||
topRight,
|
||||
footer,
|
||||
secondaryActions,
|
||||
primaryAction,
|
||||
}: {
|
||||
minRows: number
|
||||
maxHeightViewportRatio: number
|
||||
topRight?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
secondaryActions?: React.ReactNode
|
||||
primaryAction: React.ReactNode
|
||||
}) => createElement(
|
||||
'section',
|
||||
{
|
||||
'data-min-rows': String(minRows),
|
||||
'data-max-height-ratio': String(maxHeightViewportRatio),
|
||||
},
|
||||
topRight,
|
||||
footer,
|
||||
secondaryActions,
|
||||
primaryAction,
|
||||
'StoryInputComposer',
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/task/TaskStatusInline', () => ({
|
||||
default: () => createElement('span', null, 'TaskStatusInline'),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/home/AiWriteModal', () => ({
|
||||
default: () => createElement('div', null, 'AiWriteModal'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-fetch', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/home/ai-story-expand', () => ({
|
||||
expandHomeStory: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/icons', () => ({
|
||||
AppIcon: ({ name, ...props }: { name: string } & Record<string, unknown>) =>
|
||||
createElement('span', { ...props, 'data-icon': name }),
|
||||
}))
|
||||
|
||||
describe('NovelInputStage', () => {
|
||||
it('uses the shared composer with a taller adaptive baseline in story mode', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(NovelInputStage, {
|
||||
novelText: '',
|
||||
episodeName: '剧集 1',
|
||||
onNovelTextChange: () => undefined,
|
||||
onNext: () => undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('StoryInputComposer')
|
||||
expect(html).toContain('data-min-rows="8"')
|
||||
expect(html).toContain('data-max-height-ratio="0.5"')
|
||||
expect(html).toContain('aiWrite.trigger')
|
||||
expect(html).toContain('AiWriteModal')
|
||||
expect(html).not.toContain('storyInput.wordCount 0')
|
||||
expect(html).not.toContain('storyInput.currentConfigSummary')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user