!23 docs(api): 同步 api.md 与后端实际行为

Merge pull request !23 from 何朝晖/feat/frontend
This commit is contained in:
GYhde
2026-05-24 13:29:12 +00:00
committed by Gitee
24 changed files with 894 additions and 168 deletions
+22 -34
View File
@@ -8,14 +8,7 @@
## 认证
除健康检查和注册登录外,所有接口需通过 httpOnly Cookie 携带 JWT。登录/注册成功后,服务端通过 `Set-Cookie` 响应头写入 Token,后续请求自动携带。
Cookie 属性:
- `Name`: `token`
- `HttpOnly`: true
- `SameSite`: Lax
- `Path`: `/`
- `MaxAge`: 由 `GEN2D_JWT_EXPIRE` 控制(默认 7200 秒)
除健康检查和注册登录外,所有接口需认证。登录成功后,Token 在响应体 `data.token` 字段中返回,客户端需自行存储(如 localStorage)并通过 `Authorization: Bearer <token>` 请求头携带。
Token 过期或无效时返回:
@@ -66,7 +59,7 @@ Token 过期或无效时返回:
| [ ] | GET | `/api/v1/auth/me` | 获取当前用户信息 |
| [ ] | PUT | `/api/v1/auth/password` | 修改密码 |
### POST /api/v1/auth/register
### POST /auth/register
```json
{
@@ -78,20 +71,19 @@ Token 过期或无效时返回:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `username` | string | 是 | 3-64 字符,字母数字下划线 |
| `email` | string | 是 | 邮箱地址 |
| `password` | string | 是 | 8-128 字符 |
| `username` | string | 是 | 3-32 字符 |
| `email` | string | 否 | 邮箱地址 |
| `password` | string | 是 | 6-64 字符 |
响应(Token 通过 `Set-Cookie` 响应头写入,不在 body 中返回):
响应(HTTP 状态码 201):
```json
{
"code": 0,
"message": "ok",
"data": {
"id": "user_a1B2c3D4",
"username": "player1",
"email": "player1@example.com"
"id": 1,
"username": "player1"
}
}
```
@@ -100,13 +92,12 @@ Token 过期或无效时返回:
| 场景 | code | message |
|------|------|---------|
| 用户名格式不合法(非 3-64 字符或含特殊字符) | 400 | 用户名格式不合法 |
| 密码长度不足 | 400 | 密码长度不能少于 8 位 |
| 邮箱格式不合法 | 400 | 邮箱格式不正确 |
| 用户名格式不合法(非 3-32 字符) | 400 | 参数错误 |
| 密码长度不足 | 400 | 参数错误 |
| 邮箱格式不合法 | 400 | 参数错误 |
| 用户名已存在 | 409 | 用户名已被注册 |
| 邮箱已存在 | 409 | 邮箱已被注册 |
### POST /api/v1/auth/login
### POST /auth/login
```json
{
@@ -115,25 +106,22 @@ Token 过期或无效时返回:
}
```
支持 `username` 或 `email` 登录:
```json
{
"email": "player1@example.com",
"password": "s3cretP@ss"
}
```
响应(Token 通过 `Set-Cookie` 响应头写入,不在 body 中返回):
响应(Token 在 body 中返回):
```json
{
"code": 0,
"message": "ok",
"data": {
"id": "user_a1B2c3D4",
"username": "player1",
"email": "player1@example.com"
"token": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 7200,
"user": {
"id": 1,
"username": "player1",
"email": "player1@example.com",
"createdAt": "2026-05-24T10:00:00Z",
"updatedAt": "2026-05-24T10:00:00Z"
}
}
}
```
+6
View File
@@ -2,14 +2,20 @@ import { useEffect } from 'react'
import { RouterProvider } from 'react-router-dom'
import { router } from './router'
import { useAuthStore } from './stores/auth'
import { useThemeStore } from './stores/theme'
import './styles/global.css'
export default function App() {
const loadFromStorage = useAuthStore(s => s.loadFromStorage)
const theme = useThemeStore(s => s.theme)
useEffect(() => {
loadFromStorage()
}, [loadFromStorage])
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
}, [theme])
return <RouterProvider router={router} />
}
+35
View File
@@ -0,0 +1,35 @@
import { post } from './client'
import { STYLE_CATEGORIES } from '../utils/style'
interface OptimizePromptParams {
tags: string[]
assetType: string
prompt: string
userNote?: string
}
interface OptimizePromptResponse {
prompt: string
rawText: string
}
/**
* 从风格键值对中提取标签的中文名称作为 tags
*/
export function extractTags(style: Record<string, string>): string[] {
return STYLE_CATEGORIES.flatMap(cat => {
const value = style[cat.key]
if (!value) return []
const option = cat.options.find(o => o.value === value)
return option ? [option.label] : []
})
}
/**
* 调用后端提示词优化接口
*/
export async function optimizePrompt(
params: OptimizePromptParams,
): Promise<OptimizePromptResponse> {
return post<OptimizePromptResponse>('/api/v1/prompt/optimize', params)
}
+2 -5
View File
@@ -1,4 +1,5 @@
import type { Asset } from '../api/types'
import EmptyState from './EmptyState'
interface AssetPreviewProps {
assets: Asset[]
@@ -6,11 +7,7 @@ interface AssetPreviewProps {
export default function AssetPreview({ assets }: AssetPreviewProps) {
if (assets.length === 0) {
return (
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>
暂无素材
</div>
)
return <EmptyState title="暂无素材" description="生成完成后,素材将在这里展示" />
}
return (
+43
View File
@@ -0,0 +1,43 @@
interface EmptyStateProps {
icon?: string
title: string
description?: string
action?: {
label: string
onClick: () => void
}
}
export default function EmptyState({ icon = '📭', title, description, action }: EmptyStateProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 24px',
textAlign: 'center',
}}
>
<span style={{ fontSize: 48, marginBottom: 16, opacity: 0.6 }}>{icon}</span>
<h3 style={{ fontSize: 16, color: 'var(--text-secondary)', marginBottom: 8 }}>
{title}
</h3>
{description && (
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: action ? 20 : 0 }}>
{description}
</p>
)}
{action && (
<button
className="btn-primary"
onClick={action.onClick}
style={{ padding: '8px 24px', fontSize: 13 }}
>
{action.label}
</button>
)}
</div>
)
}
@@ -0,0 +1,98 @@
.promptPanels {
display: flex;
gap: 16px;
}
.panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.panelSingle {
composes: panel;
flex: 1;
}
.panelLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.optimizedBadge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--accent-dim);
color: var(--accent);
font-weight: 600;
}
.textarea {
resize: vertical;
width: 100%;
min-height: 120px;
}
.toggleRow {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.toggle {
position: relative;
width: 40px;
height: 22px;
border-radius: 11px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
padding: 0;
flex-shrink: 0;
}
.toggleOn {
background: var(--accent);
}
.toggleOff {
background: var(--border);
}
.toggleKnob {
position: absolute;
top: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
transition: left 0.2s;
}
.toggleKnobOn {
left: 20px;
}
.toggleKnobOff {
left: 2px;
}
.toggleLabel {
font-size: 13px;
color: var(--text-secondary);
}
.optimizeBtn {
padding: 6px 16px;
font-size: 13px;
}
@media (max-width: 640px) {
.promptPanels {
flex-direction: column;
}
}
+105 -19
View File
@@ -5,6 +5,29 @@ import { mergeStyles } from '../utils/style'
import StyleSelector from './StyleSelector'
import PromptEditor from './PromptEditor'
const ASSET_TYPE_ICONS: Record<AssetType, JSX.Element> = {
sprite: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2a3 3 0 0 1 3 3c0 1.1-.6 2.1-1.5 2.6L17 12h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-1.2l-1.4 5.5a1 1 0 0 1-1 .5H10.6a1 1 0 0 1-1-.5L8.2 16H7a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1h2l3.5-4.4A3 3 0 0 1 12 2z" />
</svg>
),
background: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm0 2v8.6l4.3-4.3a1.5 1.5 0 0 1 2.1 0L14 13.9l2.4-2.4a1.5 1.5 0 0 1 2.1 0L20 13V6H4zm0 12h16v-1.4l-3.5-3.5-2.4 2.4a1.5 1.5 0 0 1-2.1 0L6.4 12 4 14.4V18z" />
</svg>
),
ui: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 4h18a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm0 2v12h18V6H3zm2 2h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zm1 1v4h2V9H6zm7-1h5a1 1 0 0 1 0 2h-5a1 1 0 0 1 0-2zm0 4h3a1 1 0 0 1 0 2h-3a1 1 0 0 1 0-2z" />
</svg>
),
animation: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7L8 5zm-2 1.5L16 12 6 17.5V6.5z" />
</svg>
),
}
const ASSET_TYPES: { value: AssetType; label: string }[] = [
{ value: 'sprite', label: '精灵' },
{ value: 'background', label: '背景' },
@@ -13,7 +36,7 @@ const ASSET_TYPES: { value: AssetType; label: string }[] = [
]
interface GenerateFormProps {
onSubmit: () => void
onSubmit: (prompt: string) => void
submitting: boolean
}
@@ -24,13 +47,32 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
assetType,
taskStyle,
params,
enableAI,
optimizedPrompt,
isOptimizing,
setPrompt,
setAssetType,
toggleTaskStyle,
setParams,
setEnableAI,
setOptimizedPrompt,
runOptimize,
} = useTaskStore()
const mergedStyle = mergeStyles(projectStyle, taskStyle)
const finalPrompt = enableAI && optimizedPrompt ? optimizedPrompt : prompt
const handleOptimize = async () => {
try {
await runOptimize(projectStyle)
} catch {
// error handled in store
}
}
const handleSubmit = () => {
onSubmit(finalPrompt)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
@@ -39,18 +81,37 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
<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 style={{ display: 'flex', gap: 12 }}>
{ASSET_TYPES.map(t => {
const isSelected = assetType === t.value
return (
<button
key={t.value}
type="button"
onClick={() => setAssetType(t.value)}
style={{
width: 80,
height: 72,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
borderRadius: 'var(--radius-lg)',
border: `2px solid ${isSelected ? 'var(--accent)' : 'var(--border)'}`,
background: isSelected ? 'var(--accent-dim)' : 'transparent',
color: isSelected ? 'var(--accent)' : 'var(--text-secondary)',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: 13,
fontWeight: isSelected ? 600 : 400,
}}
>
{ASSET_TYPE_ICONS[t.value]}
{t.label}
</button>
)
})}
</div>
</div>
@@ -62,16 +123,41 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
<StyleSelector value={taskStyle} onChange={toggleTaskStyle} compact />
</div>
{/* 提示词 */}
{/* 提示词(含 AI 优化) */}
<div>
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
提示词
</h3>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}
>
<h3 style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
提示词
</h3>
{enableAI && (
<button
type="button"
className="btn-secondary"
onClick={handleOptimize}
disabled={isOptimizing || !prompt.trim()}
style={{ padding: '6px 16px', fontSize: 13 }}
>
{isOptimizing ? '优化中...' : '优化提示词'}
</button>
)}
</div>
<PromptEditor
prompt={prompt}
onPromptChange={setPrompt}
style={mergedStyle}
params={{ resolution: params.resolution, format: params.format }}
enableAI={enableAI}
onEnableAIToggle={setEnableAI}
optimizedPrompt={optimizedPrompt}
isOptimizing={isOptimizing}
onOptimizedPromptChange={setOptimizedPrompt}
/>
</div>
@@ -113,8 +199,8 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
<button
type="button"
className="btn-primary"
onClick={onSubmit}
disabled={submitting || !prompt.trim()}
onClick={handleSubmit}
disabled={submitting || !finalPrompt.trim()}
style={{ alignSelf: 'flex-start', padding: '12px 32px', fontSize: 16 }}
>
{submitting ? '提交中...' : '开始生成'}
+66
View File
@@ -0,0 +1,66 @@
import { Link, Outlet } from 'react-router-dom'
import { useAuthStore } from '../stores/auth'
import { useThemeStore } from '../stores/theme'
import ToastContainer from './Toast'
export default function Layout() {
const { user, logout } = useAuthStore()
const { theme, toggleTheme } = useThemeStore()
return (
<>
<nav
style={{
position: 'sticky',
top: 0,
zIndex: 100,
background: 'var(--nav-bg)',
borderBottom: '1px solid var(--nav-border)',
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
transition: 'background-color 0.3s ease, border-color 0.3s ease',
}}
>
<Link
to="/"
style={{
fontSize: 20,
fontWeight: 700,
color: 'var(--accent)',
textDecoration: 'none',
}}
>
gen2d
</Link>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<button
onClick={toggleTheme}
className="btn-secondary"
style={{ padding: '6px 10px', fontSize: 16, lineHeight: 1 }}
title={theme === 'light' ? '切换到暗色模式' : '切换到浅色模式'}
>
{theme === 'light' ? '☾' : '☀'}
</button>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{user?.username}
</span>
<button
className="btn-secondary"
onClick={logout}
style={{ padding: '6px 16px', fontSize: 13 }}
>
退出
</button>
</div>
</nav>
<main>
<Outlet />
</main>
<ToastContainer />
</>
)
}
+71 -8
View File
@@ -1,4 +1,5 @@
import { STYLE_CATEGORIES } from '../utils/style'
import styles from './GenerateForm.module.css'
interface PromptEditorProps {
prompt: string
@@ -8,6 +9,11 @@ interface PromptEditorProps {
resolution: number
format: string
}
enableAI: boolean
onEnableAIToggle: (enable: boolean) => void
optimizedPrompt: string | null
isOptimizing: boolean
onOptimizedPromptChange: (text: string) => void
}
function styleToDescription(kvPairs: Record<string, string>): string {
@@ -26,18 +32,75 @@ export default function PromptEditor({
onPromptChange,
style,
params,
enableAI,
onEnableAIToggle,
optimizedPrompt,
isOptimizing,
onOptimizedPromptChange,
}: PromptEditorProps) {
const styleDesc = styleToDescription(style)
const displayPrompt = enableAI && optimizedPrompt ? optimizedPrompt : prompt
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%' }}
/>
{/* AI 开关 */}
<div className={styles.toggleRow}>
<button
type="button"
className={`${styles.toggle} ${enableAI ? styles.toggleOn : styles.toggleOff}`}
onClick={() => onEnableAIToggle(!enableAI)}
role="switch"
aria-checked={enableAI}
>
<span
className={`${styles.toggleKnob} ${enableAI ? styles.toggleKnobOn : styles.toggleKnobOff}`}
/>
</button>
<span className={styles.toggleLabel}>启用 AI 提示词优化</span>
</div>
{/* 双栏文本框 */}
<div className={enableAI ? styles.promptPanels : ''}>
<div className={enableAI ? styles.panel : styles.panelSingle}>
<span className={styles.panelLabel}>
{enableAI ? '原始提示词' : '提示词'}
</span>
<textarea
value={prompt}
onChange={e => onPromptChange(e.target.value)}
placeholder="描述你想要生成的游戏素材..."
rows={4}
className={styles.textarea}
/>
</div>
{enableAI && (
<div className={styles.panel}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className={styles.panelLabel}>优化后提示词</span>
{optimizedPrompt && (
<span className={styles.optimizedBadge}>AI 优化</span>
)}
{isOptimizing && (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
优化中...
</span>
)}
</div>
<textarea
value={optimizedPrompt ?? ''}
onChange={e => onOptimizedPromptChange(e.target.value)}
placeholder={isOptimizing ? 'AI 正在优化提示词...' : '点击「优化提示词」按钮生成'}
rows={4}
className={styles.textarea}
disabled={isOptimizing}
style={{ opacity: isOptimizing ? 0.6 : 1 }}
/>
</div>
)}
</div>
{/* 预览面板 */}
<div
style={{
background: 'var(--bg-input)',
@@ -55,7 +118,7 @@ export default function PromptEditor({
</div>
<div>
<strong style={{ color: 'var(--accent)' }}>[主体]</strong>{' '}
{prompt || '未输入'}
{displayPrompt || '未输入'}
</div>
<div>
<strong style={{ color: 'var(--accent)' }}>[参数]</strong> 分辨率=
@@ -0,0 +1,56 @@
.shimmer {
background: linear-gradient(
90deg,
var(--skeleton-base) 25%,
var(--skeleton-shine) 50%,
var(--skeleton-base) 75%
);
background-size: 200% 100%;
animation: skeletonShine 1.5s infinite;
border-radius: var(--radius);
}
@keyframes skeletonShine {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.text {
height: 16px;
margin-bottom: 10px;
}
.textShort {
composes: text;
width: 60%;
}
.textMedium {
composes: text;
width: 80%;
}
.card {
height: 200px;
width: 100%;
}
.tableRow {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.tableCell {
height: 14px;
flex: 1;
}
.tableCellNarrow {
height: 14px;
width: 80px;
}
+40
View File
@@ -0,0 +1,40 @@
import styles from './Skeleton.module.css'
interface SkeletonProps {
variant?: 'text' | 'card' | 'table'
lines?: number
}
export default function Skeleton({ variant = 'text', lines = 3 }: SkeletonProps) {
if (variant === 'card') {
return <div className={`${styles.shimmer} ${styles.card}`} />
}
if (variant === 'table') {
return (
<div>
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className={styles.tableRow}>
<div className={`${styles.shimmer} ${styles.tableCell}`} />
<div className={`${styles.shimmer} ${styles.tableCellNarrow}`} />
<div className={`${styles.shimmer} ${styles.tableCellNarrow}`} />
<div className={`${styles.shimmer} ${styles.tableCellNarrow}`} />
</div>
))}
</div>
)
}
const widths = ['100%', '80%', '60%']
return (
<div>
{Array.from({ length: lines }, (_, i) => (
<div
key={i}
className={`${styles.shimmer} ${i === lines - 1 && lines > 1 ? styles.textShort : styles.text}`}
style={{ width: widths[i % widths.length] }}
/>
))}
</div>
)
}
@@ -50,6 +50,11 @@
color: #fff;
}
.pill:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* compact 模式 */
.compact .category {
flex-direction: row;
+70
View File
@@ -0,0 +1,70 @@
.container {
position: fixed;
top: 72px;
right: 24px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius);
background: var(--toast-bg);
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
color: var(--text-primary);
font-size: 13px;
min-width: 240px;
max-width: 380px;
pointer-events: auto;
animation: slideIn 0.3s ease;
}
.toastSuccess {
composes: toast;
border-left: 4px solid var(--success);
}
.toastError {
composes: toast;
border-left: 4px solid var(--error);
}
.toastInfo {
composes: toast;
border-left: 4px solid var(--accent);
}
.closeBtn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 16px;
padding: 2px 4px;
line-height: 1;
transition: color 0.15s;
flex-shrink: 0;
}
.closeBtn:hover {
color: var(--text-primary);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { useToastStore } from '../stores/toast'
import type { ToastType } from '../stores/toast'
import styles from './Toast.module.css'
const TYPE_CLASS: Record<ToastType, string> = {
success: styles.toastSuccess,
error: styles.toastError,
info: styles.toastInfo,
}
export default function ToastContainer() {
const { toasts, removeToast } = useToastStore()
if (toasts.length === 0) return null
return (
<div className={styles.container}>
{toasts.map((t) => (
<div key={t.id} className={TYPE_CLASS[t.type]}>
<span>{t.message}</span>
<button
className={styles.closeBtn}
onClick={() => removeToast(t.id)}
aria-label="关闭"
>
×
</button>
</div>
))}
</div>
)
}
+15 -27
View File
@@ -1,14 +1,16 @@
import { useEffect } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useNavigate, useParams } from 'react-router-dom'
import { useTaskStore } from '../stores/task'
import { useGenerationStore } from '../stores/generation'
import { useToastStore } from '../stores/toast'
import GenerateForm from '../components/GenerateForm'
import ProgressBar from '../components/ProgressBar'
export default function GeneratePage() {
const { projectId = 'proj-default' } = useParams()
const navigate = useNavigate()
const { prompt, assetType, reset: resetTask } = useTaskStore()
const { assetType, reset: resetTask } = useTaskStore()
const addToast = useToastStore(s => s.addToast)
const {
status,
stage,
@@ -25,6 +27,13 @@ export default function GeneratePage() {
return () => resetGeneration()
}, [resetGeneration])
// 失败时显示 toast
useEffect(() => {
if (status === 'failed') {
addToast({ type: 'error', message: '素材生成失败,请重试' })
}
}, [status, addToast])
// 完成后自动跳转
useEffect(() => {
if (status === 'completed' && taskId) {
@@ -35,8 +44,8 @@ export default function GeneratePage() {
}
}, [status, taskId, projectId, navigate])
const handleSubmit = async () => {
await submit(projectId, prompt, assetType)
const handleSubmit = async (finalPrompt: string) => {
await submit(projectId, finalPrompt, assetType)
}
const handleReset = () => {
@@ -45,29 +54,8 @@ export default function GeneratePage() {
}
return (
<div className="container" style={{ paddingTop: 24, paddingBottom: 40 }}>
{/* 顶栏 */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 32,
}}
>
<h1 style={{ fontSize: 24 }}>新建生成</h1>
<Link
to={`/projects/${projectId}`}
className="btn-secondary"
style={{
padding: '8px 20px',
fontSize: 13,
borderRadius: 'var(--radius)',
}}
>
返回工程
</Link>
</div>
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>新建生成</h1>
{/* 生成表单 */}
{status === 'idle' || status === 'submitting' ? (
+1 -1
View File
@@ -27,7 +27,7 @@ export default function LoginPage() {
justifyContent: 'center',
}}
>
<div className="card" style={{ width: 380, padding: 40 }}>
<div className="card page-enter" style={{ width: 380, padding: 40 }}>
<h1 style={{ textAlign: 'center', marginBottom: 8, fontSize: 28 }}>gen2d</h1>
<p
style={{
+18 -27
View File
@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useProjectStore } from '../stores/project'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
import { getTasks } from '../api/project'
import type { Task } from '../api/types'
import StyleSelector from '../components/StyleSelector'
import Skeleton from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '等待中', color: 'var(--text-muted)' },
@@ -15,7 +17,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
export default function ProjectPage() {
const { projectId = 'proj-default' } = useParams()
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const {
name,
style,
@@ -34,36 +36,23 @@ export default function ProjectPage() {
getTasks(projectId).then(setTasks)
}, [projectId, loadProject, loadStyle])
const addToast = useToastStore(s => s.addToast)
const handleSave = async () => {
setSaving(true)
try {
await saveStyle()
addToast({ type: 'success', message: '风格已保存' })
} catch {
addToast({ type: 'error', message: '保存失败,请重试' })
} finally {
setSaving(false)
}
}
return (
<div className="container" style={{ paddingTop: 24, paddingBottom: 40 }}>
{/* 顶栏 */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 32,
}}
>
<h1 style={{ fontSize: 24 }}>{loading ? '加载中...' : name}</h1>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{user?.username}
</span>
<button className="btn-secondary" onClick={logout} style={{ padding: '6px 16px', fontSize: 13 }}>
退出
</button>
</div>
</div>
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>{loading ? <Skeleton variant="text" lines={1} /> : name}</h1>
{/* 工程风格 */}
<section className="card" style={{ marginBottom: 24 }}>
@@ -114,9 +103,11 @@ export default function ProjectPage() {
</div>
{tasks.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 32 }}>
暂无任务
</p>
<EmptyState
title="暂无任务"
description="点击「新建生成」开始创建你的第一个游戏素材"
action={{ label: '新建生成', onClick: () => navigate(`/projects/${projectId}/generate`) }}
/>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
@@ -132,7 +123,7 @@ export default function ProjectPage() {
{tasks.map(task => {
const statusInfo = STATUS_LABELS[task.status] ?? STATUS_LABELS.pending
return (
<tr key={task.id} style={{ borderBottom: '1px solid var(--border)' }}>
<tr key={task.id} className="table-row-hover" style={{ borderBottom: '1px solid var(--border)' }}>
<td style={tdStyle}>{task.prompt}</td>
<td style={tdStyle}>{task.assetType}</td>
<td style={{ ...tdStyle, color: statusInfo.color }}>{statusInfo.label}</td>
+1 -1
View File
@@ -28,7 +28,7 @@ export default function RegisterPage() {
justifyContent: 'center',
}}
>
<div className="card" style={{ width: 380, padding: 40 }}>
<div className="card page-enter" style={{ width: 380, padding: 40 }}>
<h1 style={{ textAlign: 'center', marginBottom: 8, fontSize: 28 }}>gen2d</h1>
<p
style={{
+13 -25
View File
@@ -3,6 +3,7 @@ import { Link, useParams } from 'react-router-dom'
import { getTask, getAssets } from '../api/generate'
import type { Asset, Task } from '../api/types'
import AssetPreview from '../components/AssetPreview'
import Skeleton from '../components/Skeleton'
export default function ResultPage() {
const { projectId = 'proj-default', taskId } = useParams()
@@ -23,8 +24,16 @@ export default function ResultPage() {
if (loading) {
return (
<div className="container" style={{ paddingTop: 40, textAlign: 'center' }}>
<p style={{ color: 'var(--text-secondary)' }}>加载中...</p>
<div className="container page-enter" style={{ paddingTop: 40 }}>
<div className="card" style={{ marginBottom: 24 }}>
<Skeleton variant="text" lines={1} />
<div style={{ marginTop: 16 }}>
<Skeleton variant="text" lines={4} />
</div>
</div>
<div className="card">
<Skeleton variant="card" />
</div>
</div>
)
}
@@ -41,29 +50,8 @@ export default function ResultPage() {
}
return (
<div className="container" style={{ paddingTop: 24, paddingBottom: 40 }}>
{/* 顶栏 */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 32,
}}
>
<h1 style={{ fontSize: 24 }}>生成结果</h1>
<Link
to={`/projects/${projectId}`}
className="btn-secondary"
style={{
padding: '8px 20px',
fontSize: 13,
borderRadius: 'var(--radius)',
}}
>
返回工程
</Link>
</div>
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>生成结果</h1>
{/* 任务信息 */}
<section className="card" style={{ marginBottom: 24 }}>
+20 -14
View File
@@ -1,5 +1,6 @@
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom'
import { useAuthStore } from '../stores/auth'
import Layout from '../components/Layout'
import LoginPage from '../pages/LoginPage'
import RegisterPage from '../pages/RegisterPage'
import ProjectPage from '../pages/ProjectPage'
@@ -25,20 +26,25 @@ export const router = createBrowserRouter([
element: <ProtectedRoute />,
children: [
{
path: '/',
element: <Navigate to="/projects/proj-default" replace />,
},
{
path: '/projects/:projectId',
element: <ProjectPage />,
},
{
path: '/projects/:projectId/generate',
element: <GeneratePage />,
},
{
path: '/projects/:projectId/tasks/:taskId',
element: <ResultPage />,
element: <Layout />,
children: [
{
path: '/',
element: <Navigate to="/projects/proj-default" replace />,
},
{
path: '/projects/:projectId',
element: <ProjectPage />,
},
{
path: '/projects/:projectId/generate',
element: <GeneratePage />,
},
{
path: '/projects/:projectId/tasks/:taskId',
element: <ResultPage />,
},
],
},
],
},
+46 -4
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand'
import type { AssetType } from '../api/types'
import { optimizePrompt, extractTags } from '../api/prompt'
import { mergeStyles } from '../utils/style'
interface TaskParams {
resolution: number
@@ -12,10 +14,16 @@ interface TaskState {
assetType: AssetType
taskStyle: Record<string, string>
params: TaskParams
enableAI: boolean
optimizedPrompt: string | null
isOptimizing: boolean
setPrompt: (text: string) => void
setAssetType: (type: AssetType) => void
toggleTaskStyle: (key: string, value: string) => void
setParams: (params: Partial<TaskParams>) => void
setEnableAI: (enable: boolean) => void
setOptimizedPrompt: (text: string | null) => void
runOptimize: (projectStyle: Record<string, string>) => Promise<void>
reset: () => void
}
@@ -24,15 +32,18 @@ const DEFAULT_PARAMS: TaskParams = {
format: 'spritesheet',
}
export const useTaskStore = create<TaskState>((set) => ({
export const useTaskStore = create<TaskState>((set, get) => ({
prompt: '',
assetType: 'sprite',
taskStyle: {},
params: { ...DEFAULT_PARAMS },
enableAI: true,
optimizedPrompt: null,
isOptimizing: false,
setPrompt: (text) => set({ prompt: text }),
setPrompt: (text) => set({ prompt: text, optimizedPrompt: null }),
setAssetType: (type) => set({ assetType: type }),
setAssetType: (type) => set({ assetType: type, optimizedPrompt: null }),
toggleTaskStyle: (key, value) =>
set(state => {
@@ -42,17 +53,48 @@ export const useTaskStore = create<TaskState>((set) => ({
} else {
next[key] = value
}
return { taskStyle: next }
return { taskStyle: next, optimizedPrompt: null }
}),
setParams: (params) =>
set(state => ({ params: { ...state.params, ...params } })),
setEnableAI: (enable) => set({ enableAI: enable, optimizedPrompt: null }),
setOptimizedPrompt: (text) => set({ optimizedPrompt: text }),
runOptimize: async (projectStyle) => {
const { prompt, assetType, taskStyle } = get()
if (!prompt.trim()) return
const mergedStyle = mergeStyles(projectStyle, taskStyle)
const tags = extractTags(mergedStyle)
if (tags.length === 0) return
set({ isOptimizing: true })
try {
const result = await optimizePrompt({
tags,
assetType,
prompt,
})
set({ optimizedPrompt: result.prompt })
} catch (err) {
console.error('提示词优化失败:', err)
throw err
} finally {
set({ isOptimizing: false })
}
},
reset: () =>
set({
prompt: '',
assetType: 'sprite',
taskStyle: {},
params: { ...DEFAULT_PARAMS },
enableAI: true,
optimizedPrompt: null,
isOptimizing: false,
}),
}))
+22
View File
@@ -0,0 +1,22 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type Theme = 'light' | 'dark'
interface ThemeState {
theme: Theme
toggleTheme: () => void
setTheme: (t: Theme) => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
setTheme: (t) => set({ theme: t }),
}),
{ name: 'gen2d_theme' },
),
)
+34
View File
@@ -0,0 +1,34 @@
import { create } from 'zustand'
export type ToastType = 'success' | 'error' | 'info'
export interface Toast {
id: string
type: ToastType
message: string
duration?: number
}
interface ToastState {
toasts: Toast[]
addToast: (t: Omit<Toast, 'id'>) => void
removeToast: (id: string) => void
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (t) => {
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
const toast: Toast = { ...t, id }
set((state) => ({ toasts: [...state.toasts, toast] }))
const duration = t.duration ?? 3000
setTimeout(() => {
set((state) => ({ toasts: state.toasts.filter((x) => x.id !== id) }))
}, duration)
},
removeToast: (id) =>
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
}))
+73 -3
View File
@@ -1,4 +1,30 @@
:root {
[data-theme="light"] {
--bg-primary: #f8f9fa;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--bg-input: #f1f3f5;
--text-primary: #212529;
--text-secondary: #6c757d;
--text-muted: #adb5bd;
--accent: #e94560;
--accent-hover: #d63851;
--accent-dim: rgba(233, 69, 96, 0.1);
--success: #2b8a3e;
--warning: #e67700;
--error: #c92a2a;
--border: #dee2e6;
--radius: 8px;
--radius-lg: 12px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.1);
--nav-bg: #ffffff;
--nav-border: #e9ecef;
--skeleton-base: #e9ecef;
--skeleton-shine: #f8f9fa;
--toast-bg: #ffffff;
}
[data-theme="dark"] {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #0f3460;
@@ -15,6 +41,13 @@
--border: #2a2a4a;
--radius: 8px;
--radius-lg: 12px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4);
--nav-bg: #16213e;
--nav-border: #2a2a4a;
--skeleton-base: #2a2a4a;
--skeleton-shine: #3a3a5a;
--toast-bg: #1e1e3a;
}
*,
@@ -32,11 +65,13 @@ body {
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
a {
color: var(--accent);
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
@@ -60,13 +95,14 @@ select {
border-radius: var(--radius);
padding: 10px 14px;
outline: none;
transition: border-color 0.2s;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
button {
@@ -75,7 +111,7 @@ button {
border-radius: var(--radius);
padding: 10px 20px;
font-weight: 600;
transition: all 0.2s;
transition: all 0.2s ease;
}
button:disabled {
@@ -92,6 +128,10 @@ button:disabled {
background: var(--accent-hover);
}
.btn-primary:active:not(:disabled) {
transform: scale(0.97);
}
.btn-secondary {
background: transparent;
color: var(--text-primary);
@@ -101,6 +141,11 @@ button:disabled {
.btn-secondary:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
}
.btn-secondary:active:not(:disabled) {
transform: scale(0.97);
}
.card {
@@ -108,6 +153,8 @@ button:disabled {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.container {
@@ -115,3 +162,26 @@ button:disabled {
margin: 0 auto;
padding: 0 20px;
}
.table-row-hover {
transition: background-color 0.15s ease;
}
.table-row-hover:hover {
background: var(--accent-dim);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-enter {
animation: fadeIn 0.3s ease forwards;
}