diff --git a/docs/api.md b/docs/api.md index c4bb44a..74bf155 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 过期或无效时返回: @@ -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" + } } } ``` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3ffe69..1672697 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 } diff --git a/frontend/src/api/prompt.ts b/frontend/src/api/prompt.ts new file mode 100644 index 0000000..0d41baf --- /dev/null +++ b/frontend/src/api/prompt.ts @@ -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[] { + 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 { + return post('/api/v1/prompt/optimize', params) +} diff --git a/frontend/src/components/AssetPreview.tsx b/frontend/src/components/AssetPreview.tsx index 5686d7a..e983f78 100644 --- a/frontend/src/components/AssetPreview.tsx +++ b/frontend/src/components/AssetPreview.tsx @@ -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 ( -
- 暂无素材 -
- ) + return } return ( diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..d21c457 --- /dev/null +++ b/frontend/src/components/EmptyState.tsx @@ -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 ( +
+ {icon} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} + {action && ( + + )} +
+ ) +} diff --git a/frontend/src/components/GenerateForm.module.css b/frontend/src/components/GenerateForm.module.css new file mode 100644 index 0000000..11efb51 --- /dev/null +++ b/frontend/src/components/GenerateForm.module.css @@ -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; + } +} diff --git a/frontend/src/components/GenerateForm.tsx b/frontend/src/components/GenerateForm.tsx index e3c2ec0..a0ebad9 100644 --- a/frontend/src/components/GenerateForm.tsx +++ b/frontend/src/components/GenerateForm.tsx @@ -5,6 +5,29 @@ import { mergeStyles } from '../utils/style' import StyleSelector from './StyleSelector' import PromptEditor from './PromptEditor' +const ASSET_TYPE_ICONS: Record = { + sprite: ( + + + + ), + background: ( + + + + ), + ui: ( + + + + ), + animation: ( + + + + ), +} + 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 (
@@ -39,18 +81,37 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps

素材类型

-
- {ASSET_TYPES.map(t => ( - - ))} +
+ {ASSET_TYPES.map(t => { + const isSelected = assetType === t.value + return ( + + ) + })}
@@ -62,16 +123,41 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
- {/* 提示词 */} + {/* 提示词(含 AI 优化) */}
-

- 提示词 -

+
+

+ 提示词 +

+ {enableAI && ( + + )} +
@@ -113,8 +199,8 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps + + {user?.username} + + + + +
+ +
+ + + ) +} diff --git a/frontend/src/components/PromptEditor.tsx b/frontend/src/components/PromptEditor.tsx index bfbf7fb..06ee98e 100644 --- a/frontend/src/components/PromptEditor.tsx +++ b/frontend/src/components/PromptEditor.tsx @@ -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 { @@ -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 (
-