style: polish UI and improve UX
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user