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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user