feat(frontend): 构建前端 MVP,实现完整 UI 框架与 mock 数据层

- 安装 zustand + react-router-dom 依赖
- 修复 Vite 代理配置,添加 /auth 路由转发
- 实现 API 封装层(client.ts 统一 fetch、auth.ts 真实调用)
- 实现 mock 数据层(mock.ts 模拟 WebSocket 管线进度)
- 实现 4 个 zustand stores(auth/project/task/generation)
- 实现 5 个组件(StyleSelector/PromptEditor/GenerateForm/ProgressBar/AssetPreview)
- 实现 5 个页面(Login/Register/Project/Generate/Result)
- 实现路由含 ProtectedRoute 守卫
- 添加暗色主题全局样式
- 更新前端文档与 API 文档
This commit is contained in:
2026-05-24 14:18:39 +08:00
parent 302b9ba88e
commit a2736f722c
33 changed files with 2272 additions and 20 deletions
+77
View File
@@ -0,0 +1,77 @@
import type { Asset } from '../api/types'
interface AssetPreviewProps {
assets: Asset[]
}
export default function AssetPreview({ assets }: AssetPreviewProps) {
if (assets.length === 0) {
return (
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>
暂无素材
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{assets.map(asset => (
<div
key={asset.id}
className="card"
style={{ padding: 16 }}
>
{/* 图片预览 */}
<div
style={{
position: 'relative',
display: 'inline-block',
marginBottom: 12,
}}
>
<img
src={asset.url}
alt={asset.id}
style={{
maxWidth: '100%',
maxHeight: 400,
borderRadius: 'var(--radius)',
border: '1px solid var(--border)',
}}
/>
{/* spritesheet 网格叠加 */}
{asset.metadata.frameWidth && asset.metadata.frameHeight && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: `
linear-gradient(to right, rgba(233,69,96,0.3) 1px, transparent 1px),
linear-gradient(to bottom, rgba(233,69,96,0.3) 1px, transparent 1px)
`,
backgroundSize: `${asset.metadata.frameWidth}px ${asset.metadata.frameHeight}px`,
borderRadius: 'var(--radius)',
pointerEvents: 'none',
}}
/>
)}
</div>
{/* 元数据 */}
<div style={{ display: 'flex', gap: 24, fontSize: 13, color: 'var(--text-secondary)' }}>
<span>尺寸: {asset.width} × {asset.height}</span>
{asset.metadata.frameWidth && (
<span>帧大小: {asset.metadata.frameWidth} × {asset.metadata.frameHeight}</span>
)}
{asset.metadata.frameCount && <span>帧数: {asset.metadata.frameCount}</span>}
{asset.metadata.directions && <span>方向: {asset.metadata.directions}</span>}
<span>格式: {asset.format}</span>
</div>
</div>
))}
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
import type { AssetType } from '../api/types'
import { useProjectStore } from '../stores/project'
import { useTaskStore } from '../stores/task'
import { mergeStyles } from '../utils/style'
import StyleSelector from './StyleSelector'
import PromptEditor from './PromptEditor'
const ASSET_TYPES: { value: AssetType; label: string }[] = [
{ value: 'sprite', label: '精灵' },
{ value: 'background', label: '背景' },
{ value: 'ui', label: 'UI' },
{ value: 'animation', label: '动画' },
]
interface GenerateFormProps {
onSubmit: () => void
submitting: boolean
}
export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps) {
const { style: projectStyle } = useProjectStore()
const {
prompt,
assetType,
taskStyle,
params,
setPrompt,
setAssetType,
toggleTaskStyle,
setParams,
} = useTaskStore()
const mergedStyle = mergeStyles(projectStyle, taskStyle)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* 素材类型 */}
<div>
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
素材类型
</h3>
<div style={{ display: 'flex', gap: 8 }}>
{ASSET_TYPES.map(t => (
<button
key={t.value}
type="button"
className={assetType === t.value ? 'btn-primary' : 'btn-secondary'}
onClick={() => setAssetType(t.value)}
style={{ padding: '8px 20px' }}
>
{t.label}
</button>
))}
</div>
</div>
{/* 任务风格覆盖 */}
<div>
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
风格覆盖(可选)
</h3>
<StyleSelector value={taskStyle} onChange={toggleTaskStyle} compact />
</div>
{/* 提示词 */}
<div>
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
提示词
</h3>
<PromptEditor
prompt={prompt}
onPromptChange={setPrompt}
style={mergedStyle}
params={{ resolution: params.resolution, format: params.format }}
/>
</div>
{/* 参数 */}
<div>
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
参数
</h3>
<div style={{ display: 'flex', gap: 24, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>分辨率</span>
<input
type="number"
value={params.resolution}
onChange={e => setParams({ resolution: Number(e.target.value) })}
min={32}
max={1024}
step={32}
style={{ width: 100 }}
/>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>px</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>格式</span>
<select
value={params.format}
onChange={e =>
setParams({ format: e.target.value as 'spritesheet' | 'individual' })
}
>
<option value="spritesheet">Spritesheet</option>
<option value="individual">单帧</option>
</select>
</label>
</div>
</div>
{/* 提交 */}
<button
type="button"
className="btn-primary"
onClick={onSubmit}
disabled={submitting || !prompt.trim()}
style={{ alignSelf: 'flex-start', padding: '12px 32px', fontSize: 16 }}
>
{submitting ? '提交中...' : '开始生成'}
</button>
</div>
)
}
+135
View File
@@ -0,0 +1,135 @@
import type { PipelineStage } from '../api/types'
import { STAGE_LABELS } from '../api/mock'
const STAGES: PipelineStage[] = [
'prompt_builder',
'asset_generator',
'quality_supervisor',
'format_adapter',
]
interface ProgressBarProps {
stage: PipelineStage | null
progress: number
status: string
retryCount: number
rejectReason: string | null
}
export default function ProgressBar({
stage,
progress,
status,
retryCount,
rejectReason,
}: ProgressBarProps) {
const currentIndex = stage ? STAGES.indexOf(stage) : -1
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: 20,
background: 'var(--bg-secondary)',
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--border)',
}}
>
{/* 阶段步进器 */}
<div style={{ display: 'flex', gap: 4 }}>
{STAGES.map((s, i) => {
const isActive = i === currentIndex
const isCompleted =
i < currentIndex || (i === currentIndex && progress === 100 && status !== 'failed')
const isFailed = isActive && status === 'failed'
return (
<div key={s} style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* 步骤头 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: isActive
? 'var(--accent)'
: isCompleted
? 'var(--success)'
: 'var(--text-muted)',
fontWeight: isActive ? 600 : 400,
}}
>
<span
style={{
width: 24,
height: 24,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
fontWeight: 700,
background: isActive
? 'var(--accent)'
: isCompleted
? 'var(--success)'
: 'var(--border)',
color: isActive || isCompleted ? '#fff' : 'var(--text-muted)',
flexShrink: 0,
}}
>
{isCompleted && !isActive ? '✓' : isFailed ? '✗' : i + 1}
</span>
<span>{STAGE_LABELS[s]}</span>
</div>
{/* 进度条 */}
<div
style={{
height: 4,
borderRadius: 2,
background: 'var(--border)',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
borderRadius: 2,
background: isFailed
? 'var(--error)'
: isActive
? 'var(--accent)'
: isCompleted
? 'var(--success)'
: 'transparent',
width: isCompleted && !isActive ? '100%' : isActive ? `${progress}%` : '0%',
transition: 'width 0.3s ease',
}}
/>
</div>
</div>
)
})}
</div>
{/* 重试信息 */}
{retryCount > 0 && (
<div style={{ fontSize: 13, color: 'var(--warning)' }}>
⚠ 质检重试中(第 {retryCount} 次)
{rejectReason && <span> — {rejectReason}</span>}
</div>
)}
{/* 错误信息 */}
{status === 'failed' && (
<div style={{ fontSize: 13, color: 'var(--error)' }}>
✗ 生成失败
</div>
)}
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { STYLE_CATEGORIES } from '../utils/style'
interface PromptEditorProps {
prompt: string
onPromptChange: (text: string) => void
style: Record<string, string>
params: {
resolution: number
format: string
}
}
function styleToDescription(kvPairs: Record<string, string>): string {
return STYLE_CATEGORIES.map(cat => {
const value = kvPairs[cat.key]
if (!value) return null
const option = cat.options.find(o => o.value === value)
return option ? `${cat.label}: ${option.label}` : null
})
.filter(Boolean)
.join(', ')
}
export default function PromptEditor({
prompt,
onPromptChange,
style,
params,
}: PromptEditorProps) {
const styleDesc = styleToDescription(style)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<textarea
value={prompt}
onChange={e => onPromptChange(e.target.value)}
placeholder="描述你想要生成的游戏素材..."
rows={4}
style={{ resize: 'vertical', width: '100%' }}
/>
<div
style={{
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 12,
fontSize: 13,
color: 'var(--text-secondary)',
lineHeight: 1.8,
}}
>
<div>
<strong style={{ color: 'var(--accent)' }}>[风格]</strong>{' '}
{styleDesc || '未设置'}
</div>
<div>
<strong style={{ color: 'var(--accent)' }}>[主体]</strong>{' '}
{prompt || '未输入'}
</div>
<div>
<strong style={{ color: 'var(--accent)' }}>[参数]</strong> 分辨率=
{params.resolution}px, 格式={params.format}
</div>
</div>
</div>
)
}
@@ -0,0 +1,63 @@
.container {
display: flex;
flex-direction: column;
gap: 20px;
}
.category {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
}
.options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pill {
padding: 6px 16px;
border-radius: 20px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.pill:hover {
border-color: var(--accent);
color: var(--accent);
}
.pillActive {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.pillActive:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: #fff;
}
/* compact 模式 */
.compact .category {
flex-direction: row;
align-items: center;
gap: 12px;
}
.compact .label {
min-width: 60px;
flex-shrink: 0;
}
+38
View File
@@ -0,0 +1,38 @@
import { STYLE_CATEGORIES } from '../utils/style'
import styles from './StyleSelector.module.css'
interface StyleSelectorProps {
value: Record<string, string>
onChange: (key: string, value: string) => void
compact?: boolean
}
export default function StyleSelector({
value,
onChange,
compact,
}: StyleSelectorProps) {
return (
<div className={`${styles.container} ${compact ? styles.compact : ''}`}>
{STYLE_CATEGORIES.map(cat => (
<div key={cat.key} className={styles.category}>
<span className={styles.label}>{cat.label}</span>
<div className={styles.options}>
{cat.options.map(opt => (
<button
key={opt.value}
type="button"
className={`${styles.pill} ${
value[cat.key] === opt.value ? styles.pillActive : ''
}`}
onClick={() => onChange(cat.key, opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
))}
</div>
)
}