!23 docs(api): 同步 api.md 与后端实际行为
Merge pull request !23 from 何朝晖/feat/frontend
This commit is contained in:
+22
-34
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ? '提交中...' : '开始生成'}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -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' },
|
||||
),
|
||||
)
|
||||
@@ -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) })),
|
||||
}))
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user