style: polish UI and improve UX

This commit is contained in:
saturn
2026-03-28 18:58:21 +08:00
parent ca5d8a58f7
commit c3e74c228a
19 changed files with 1182 additions and 267 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -173,4 +173,4 @@
"confirm": "Continue and Clear",
"cancel": "Cancel"
}
}
}

View File

@@ -22,6 +22,7 @@
"downloadSuccess": "下载完成",
"downloadFailed": "下载失败",
"downloadEmpty": "当前没有可下载的图片资产",
"filteredEmptyHint": "点击新建资产添加资产",
"newFolder": "新建文件夹",
"editFolder": "编辑文件夹",
"deleteFolder": "删除文件夹",

View File

@@ -1,5 +1,5 @@
{
"title": "快速开始",
"title": "从灵感到银幕",
"subtitle": "描述你想要创作的故事AI 为你智能生成影视短剧",
"inputPlaceholder": "输入你的故事创意、小说片段或剧本大纲...",
"startCreation": "开始创作",

View File

@@ -173,4 +173,4 @@
"confirm": "继续并清空",
"cancel": "取消"
}
}
}

View File

@@ -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 帮我写模态框 */}

View File

@@ -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">

View File

@@ -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">

View 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">&gt;_</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>
)
}

View File

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

View 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
View 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]
}

View File

@@ -1,4 +1,5 @@
export const HOME_QUICK_START_MIN_ROWS = 3
export const PROJECT_STORY_INPUT_MIN_ROWS = 8
interface ResolveTextareaTargetHeightInput {
minHeight: number

View 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('点击新建资产添加资产')
})
})

View 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('压迫氛围')
})
})

View 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('开始创作')
})
})

View File

@@ -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"')
})
})

View 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')
})
})