!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,后续请求自动携带。
|
除健康检查和注册登录外,所有接口需认证。登录成功后,Token 在响应体 `data.token` 字段中返回,客户端需自行存储(如 localStorage)并通过 `Authorization: Bearer <token>` 请求头携带。
|
||||||
|
|
||||||
Cookie 属性:
|
|
||||||
- `Name`: `token`
|
|
||||||
- `HttpOnly`: true
|
|
||||||
- `SameSite`: Lax
|
|
||||||
- `Path`: `/`
|
|
||||||
- `MaxAge`: 由 `GEN2D_JWT_EXPIRE` 控制(默认 7200 秒)
|
|
||||||
|
|
||||||
Token 过期或无效时返回:
|
Token 过期或无效时返回:
|
||||||
|
|
||||||
@@ -66,7 +59,7 @@ Token 过期或无效时返回:
|
|||||||
| [ ] | GET | `/api/v1/auth/me` | 获取当前用户信息 |
|
| [ ] | GET | `/api/v1/auth/me` | 获取当前用户信息 |
|
||||||
| [ ] | PUT | `/api/v1/auth/password` | 修改密码 |
|
| [ ] | PUT | `/api/v1/auth/password` | 修改密码 |
|
||||||
|
|
||||||
### POST /api/v1/auth/register
|
### POST /auth/register
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -78,20 +71,19 @@ Token 过期或无效时返回:
|
|||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `username` | string | 是 | 3-64 字符,字母数字下划线 |
|
| `username` | string | 是 | 3-32 字符 |
|
||||||
| `email` | string | 是 | 邮箱地址 |
|
| `email` | string | 否 | 邮箱地址 |
|
||||||
| `password` | string | 是 | 8-128 字符 |
|
| `password` | string | 是 | 6-64 字符 |
|
||||||
|
|
||||||
响应(Token 通过 `Set-Cookie` 响应头写入,不在 body 中返回):
|
响应(HTTP 状态码 201):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "ok",
|
"message": "ok",
|
||||||
"data": {
|
"data": {
|
||||||
"id": "user_a1B2c3D4",
|
"id": 1,
|
||||||
"username": "player1",
|
"username": "player1"
|
||||||
"email": "player1@example.com"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -100,13 +92,12 @@ Token 过期或无效时返回:
|
|||||||
|
|
||||||
| 场景 | code | message |
|
| 场景 | code | message |
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| 用户名格式不合法(非 3-64 字符或含特殊字符) | 400 | 用户名格式不合法 |
|
| 用户名格式不合法(非 3-32 字符) | 400 | 参数错误 |
|
||||||
| 密码长度不足 | 400 | 密码长度不能少于 8 位 |
|
| 密码长度不足 | 400 | 参数错误 |
|
||||||
| 邮箱格式不合法 | 400 | 邮箱格式不正确 |
|
| 邮箱格式不合法 | 400 | 参数错误 |
|
||||||
| 用户名已存在 | 409 | 用户名已被注册 |
|
| 用户名已存在 | 409 | 用户名已被注册 |
|
||||||
| 邮箱已存在 | 409 | 邮箱已被注册 |
|
|
||||||
|
|
||||||
### POST /api/v1/auth/login
|
### POST /auth/login
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -115,25 +106,22 @@ Token 过期或无效时返回:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
支持 `username` 或 `email` 登录:
|
响应(Token 在 body 中返回):
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"email": "player1@example.com",
|
|
||||||
"password": "s3cretP@ss"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
响应(Token 通过 `Set-Cookie` 响应头写入,不在 body 中返回):
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "ok",
|
"message": "ok",
|
||||||
"data": {
|
"data": {
|
||||||
"id": "user_a1B2c3D4",
|
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
"username": "player1",
|
"expiresIn": 7200,
|
||||||
"email": "player1@example.com"
|
"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 { RouterProvider } from 'react-router-dom'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { useThemeStore } from './stores/theme'
|
||||||
import './styles/global.css'
|
import './styles/global.css'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const loadFromStorage = useAuthStore(s => s.loadFromStorage)
|
const loadFromStorage = useAuthStore(s => s.loadFromStorage)
|
||||||
|
const theme = useThemeStore(s => s.theme)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFromStorage()
|
loadFromStorage()
|
||||||
}, [loadFromStorage])
|
}, [loadFromStorage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
return <RouterProvider router={router} />
|
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 type { Asset } from '../api/types'
|
||||||
|
import EmptyState from './EmptyState'
|
||||||
|
|
||||||
interface AssetPreviewProps {
|
interface AssetPreviewProps {
|
||||||
assets: Asset[]
|
assets: Asset[]
|
||||||
@@ -6,11 +7,7 @@ interface AssetPreviewProps {
|
|||||||
|
|
||||||
export default function AssetPreview({ assets }: AssetPreviewProps) {
|
export default function AssetPreview({ assets }: AssetPreviewProps) {
|
||||||
if (assets.length === 0) {
|
if (assets.length === 0) {
|
||||||
return (
|
return <EmptyState title="暂无素材" description="生成完成后,素材将在这里展示" />
|
||||||
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>
|
|
||||||
暂无素材
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 StyleSelector from './StyleSelector'
|
||||||
import PromptEditor from './PromptEditor'
|
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 }[] = [
|
const ASSET_TYPES: { value: AssetType; label: string }[] = [
|
||||||
{ value: 'sprite', label: '精灵' },
|
{ value: 'sprite', label: '精灵' },
|
||||||
{ value: 'background', label: '背景' },
|
{ value: 'background', label: '背景' },
|
||||||
@@ -13,7 +36,7 @@ const ASSET_TYPES: { value: AssetType; label: string }[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
interface GenerateFormProps {
|
interface GenerateFormProps {
|
||||||
onSubmit: () => void
|
onSubmit: (prompt: string) => void
|
||||||
submitting: boolean
|
submitting: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +47,32 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
|
|||||||
assetType,
|
assetType,
|
||||||
taskStyle,
|
taskStyle,
|
||||||
params,
|
params,
|
||||||
|
enableAI,
|
||||||
|
optimizedPrompt,
|
||||||
|
isOptimizing,
|
||||||
setPrompt,
|
setPrompt,
|
||||||
setAssetType,
|
setAssetType,
|
||||||
toggleTaskStyle,
|
toggleTaskStyle,
|
||||||
setParams,
|
setParams,
|
||||||
|
setEnableAI,
|
||||||
|
setOptimizedPrompt,
|
||||||
|
runOptimize,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
|
|
||||||
const mergedStyle = mergeStyles(projectStyle, taskStyle)
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
<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 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||||
素材类型
|
素材类型
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
{ASSET_TYPES.map(t => (
|
{ASSET_TYPES.map(t => {
|
||||||
<button
|
const isSelected = assetType === t.value
|
||||||
key={t.value}
|
return (
|
||||||
type="button"
|
<button
|
||||||
className={assetType === t.value ? 'btn-primary' : 'btn-secondary'}
|
key={t.value}
|
||||||
onClick={() => setAssetType(t.value)}
|
type="button"
|
||||||
style={{ padding: '8px 20px' }}
|
onClick={() => setAssetType(t.value)}
|
||||||
>
|
style={{
|
||||||
{t.label}
|
width: 80,
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,16 +123,41 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
|
|||||||
<StyleSelector value={taskStyle} onChange={toggleTaskStyle} compact />
|
<StyleSelector value={taskStyle} onChange={toggleTaskStyle} compact />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 提示词 */}
|
{/* 提示词(含 AI 优化) */}
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
|
<div
|
||||||
提示词
|
style={{
|
||||||
</h3>
|
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
|
<PromptEditor
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
onPromptChange={setPrompt}
|
onPromptChange={setPrompt}
|
||||||
style={mergedStyle}
|
style={mergedStyle}
|
||||||
params={{ resolution: params.resolution, format: params.format }}
|
params={{ resolution: params.resolution, format: params.format }}
|
||||||
|
enableAI={enableAI}
|
||||||
|
onEnableAIToggle={setEnableAI}
|
||||||
|
optimizedPrompt={optimizedPrompt}
|
||||||
|
isOptimizing={isOptimizing}
|
||||||
|
onOptimizedPromptChange={setOptimizedPrompt}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,8 +199,8 @@ export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
onClick={onSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitting || !prompt.trim()}
|
disabled={submitting || !finalPrompt.trim()}
|
||||||
style={{ alignSelf: 'flex-start', padding: '12px 32px', fontSize: 16 }}
|
style={{ alignSelf: 'flex-start', padding: '12px 32px', fontSize: 16 }}
|
||||||
>
|
>
|
||||||
{submitting ? '提交中...' : '开始生成'}
|
{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 { STYLE_CATEGORIES } from '../utils/style'
|
||||||
|
import styles from './GenerateForm.module.css'
|
||||||
|
|
||||||
interface PromptEditorProps {
|
interface PromptEditorProps {
|
||||||
prompt: string
|
prompt: string
|
||||||
@@ -8,6 +9,11 @@ interface PromptEditorProps {
|
|||||||
resolution: number
|
resolution: number
|
||||||
format: string
|
format: string
|
||||||
}
|
}
|
||||||
|
enableAI: boolean
|
||||||
|
onEnableAIToggle: (enable: boolean) => void
|
||||||
|
optimizedPrompt: string | null
|
||||||
|
isOptimizing: boolean
|
||||||
|
onOptimizedPromptChange: (text: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function styleToDescription(kvPairs: Record<string, string>): string {
|
function styleToDescription(kvPairs: Record<string, string>): string {
|
||||||
@@ -26,18 +32,75 @@ export default function PromptEditor({
|
|||||||
onPromptChange,
|
onPromptChange,
|
||||||
style,
|
style,
|
||||||
params,
|
params,
|
||||||
|
enableAI,
|
||||||
|
onEnableAIToggle,
|
||||||
|
optimizedPrompt,
|
||||||
|
isOptimizing,
|
||||||
|
onOptimizedPromptChange,
|
||||||
}: PromptEditorProps) {
|
}: PromptEditorProps) {
|
||||||
const styleDesc = styleToDescription(style)
|
const styleDesc = styleToDescription(style)
|
||||||
|
const displayPrompt = enableAI && optimizedPrompt ? optimizedPrompt : prompt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<textarea
|
{/* AI 开关 */}
|
||||||
value={prompt}
|
<div className={styles.toggleRow}>
|
||||||
onChange={e => onPromptChange(e.target.value)}
|
<button
|
||||||
placeholder="描述你想要生成的游戏素材..."
|
type="button"
|
||||||
rows={4}
|
className={`${styles.toggle} ${enableAI ? styles.toggleOn : styles.toggleOff}`}
|
||||||
style={{ resize: 'vertical', width: '100%' }}
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-input)',
|
background: 'var(--bg-input)',
|
||||||
@@ -55,7 +118,7 @@ export default function PromptEditor({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ color: 'var(--accent)' }}>[主体]</strong>{' '}
|
<strong style={{ color: 'var(--accent)' }}>[主体]</strong>{' '}
|
||||||
{prompt || '未输入'}
|
{displayPrompt || '未输入'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ color: 'var(--accent)' }}>[参数]</strong> 分辨率=
|
<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;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pill:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* compact 模式 */
|
/* compact 模式 */
|
||||||
.compact .category {
|
.compact .category {
|
||||||
flex-direction: row;
|
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 { useEffect } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useTaskStore } from '../stores/task'
|
import { useTaskStore } from '../stores/task'
|
||||||
import { useGenerationStore } from '../stores/generation'
|
import { useGenerationStore } from '../stores/generation'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
import GenerateForm from '../components/GenerateForm'
|
import GenerateForm from '../components/GenerateForm'
|
||||||
import ProgressBar from '../components/ProgressBar'
|
import ProgressBar from '../components/ProgressBar'
|
||||||
|
|
||||||
export default function GeneratePage() {
|
export default function GeneratePage() {
|
||||||
const { projectId = 'proj-default' } = useParams()
|
const { projectId = 'proj-default' } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { prompt, assetType, reset: resetTask } = useTaskStore()
|
const { assetType, reset: resetTask } = useTaskStore()
|
||||||
|
const addToast = useToastStore(s => s.addToast)
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
stage,
|
stage,
|
||||||
@@ -25,6 +27,13 @@ export default function GeneratePage() {
|
|||||||
return () => resetGeneration()
|
return () => resetGeneration()
|
||||||
}, [resetGeneration])
|
}, [resetGeneration])
|
||||||
|
|
||||||
|
// 失败时显示 toast
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'failed') {
|
||||||
|
addToast({ type: 'error', message: '素材生成失败,请重试' })
|
||||||
|
}
|
||||||
|
}, [status, addToast])
|
||||||
|
|
||||||
// 完成后自动跳转
|
// 完成后自动跳转
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'completed' && taskId) {
|
if (status === 'completed' && taskId) {
|
||||||
@@ -35,8 +44,8 @@ export default function GeneratePage() {
|
|||||||
}
|
}
|
||||||
}, [status, taskId, projectId, navigate])
|
}, [status, taskId, projectId, navigate])
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (finalPrompt: string) => {
|
||||||
await submit(projectId, prompt, assetType)
|
await submit(projectId, finalPrompt, assetType)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
@@ -45,29 +54,8 @@ export default function GeneratePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
||||||
{/* 顶栏 */}
|
<h1 style={{ fontSize: 24, marginBottom: 32 }}>新建生成</h1>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 生成表单 */}
|
{/* 生成表单 */}
|
||||||
{status === 'idle' || status === 'submitting' ? (
|
{status === 'idle' || status === 'submitting' ? (
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function LoginPage() {
|
|||||||
justifyContent: 'center',
|
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>
|
<h1 style={{ textAlign: 'center', marginBottom: 8, fontSize: 28 }}>gen2d</h1>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { useProjectStore } from '../stores/project'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useToastStore } from '../stores/toast'
|
||||||
import { getTasks } from '../api/project'
|
import { getTasks } from '../api/project'
|
||||||
import type { Task } from '../api/types'
|
import type { Task } from '../api/types'
|
||||||
import StyleSelector from '../components/StyleSelector'
|
import StyleSelector from '../components/StyleSelector'
|
||||||
|
import Skeleton from '../components/Skeleton'
|
||||||
|
import EmptyState from '../components/EmptyState'
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
pending: { label: '等待中', color: 'var(--text-muted)' },
|
pending: { label: '等待中', color: 'var(--text-muted)' },
|
||||||
@@ -15,7 +17,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
|||||||
|
|
||||||
export default function ProjectPage() {
|
export default function ProjectPage() {
|
||||||
const { projectId = 'proj-default' } = useParams()
|
const { projectId = 'proj-default' } = useParams()
|
||||||
const { user, logout } = useAuthStore()
|
const navigate = useNavigate()
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
style,
|
style,
|
||||||
@@ -34,36 +36,23 @@ export default function ProjectPage() {
|
|||||||
getTasks(projectId).then(setTasks)
|
getTasks(projectId).then(setTasks)
|
||||||
}, [projectId, loadProject, loadStyle])
|
}, [projectId, loadProject, loadStyle])
|
||||||
|
|
||||||
|
const addToast = useToastStore(s => s.addToast)
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await saveStyle()
|
await saveStyle()
|
||||||
|
addToast({ type: 'success', message: '风格已保存' })
|
||||||
|
} catch {
|
||||||
|
addToast({ type: 'error', message: '保存失败,请重试' })
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
||||||
{/* 顶栏 */}
|
<h1 style={{ fontSize: 24, marginBottom: 32 }}>{loading ? <Skeleton variant="text" lines={1} /> : name}</h1>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 工程风格 */}
|
{/* 工程风格 */}
|
||||||
<section className="card" style={{ marginBottom: 24 }}>
|
<section className="card" style={{ marginBottom: 24 }}>
|
||||||
@@ -114,9 +103,11 @@ export default function ProjectPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 32 }}>
|
<EmptyState
|
||||||
暂无任务
|
title="暂无任务"
|
||||||
</p>
|
description="点击「新建生成」开始创建你的第一个游戏素材"
|
||||||
|
action={{ label: '新建生成', onClick: () => navigate(`/projects/${projectId}/generate`) }}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -132,7 +123,7 @@ export default function ProjectPage() {
|
|||||||
{tasks.map(task => {
|
{tasks.map(task => {
|
||||||
const statusInfo = STATUS_LABELS[task.status] ?? STATUS_LABELS.pending
|
const statusInfo = STATUS_LABELS[task.status] ?? STATUS_LABELS.pending
|
||||||
return (
|
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.prompt}</td>
|
||||||
<td style={tdStyle}>{task.assetType}</td>
|
<td style={tdStyle}>{task.assetType}</td>
|
||||||
<td style={{ ...tdStyle, color: statusInfo.color }}>{statusInfo.label}</td>
|
<td style={{ ...tdStyle, color: statusInfo.color }}>{statusInfo.label}</td>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function RegisterPage() {
|
|||||||
justifyContent: 'center',
|
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>
|
<h1 style={{ textAlign: 'center', marginBottom: 8, fontSize: 28 }}>gen2d</h1>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link, useParams } from 'react-router-dom'
|
|||||||
import { getTask, getAssets } from '../api/generate'
|
import { getTask, getAssets } from '../api/generate'
|
||||||
import type { Asset, Task } from '../api/types'
|
import type { Asset, Task } from '../api/types'
|
||||||
import AssetPreview from '../components/AssetPreview'
|
import AssetPreview from '../components/AssetPreview'
|
||||||
|
import Skeleton from '../components/Skeleton'
|
||||||
|
|
||||||
export default function ResultPage() {
|
export default function ResultPage() {
|
||||||
const { projectId = 'proj-default', taskId } = useParams()
|
const { projectId = 'proj-default', taskId } = useParams()
|
||||||
@@ -23,8 +24,16 @@ export default function ResultPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: 40, textAlign: 'center' }}>
|
<div className="container page-enter" style={{ paddingTop: 40 }}>
|
||||||
<p style={{ color: 'var(--text-secondary)' }}>加载中...</p>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -41,29 +50,8 @@ export default function ResultPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
||||||
{/* 顶栏 */}
|
<h1 style={{ fontSize: 24, marginBottom: 32 }}>生成结果</h1>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 任务信息 */}
|
{/* 任务信息 */}
|
||||||
<section className="card" style={{ marginBottom: 24 }}>
|
<section className="card" style={{ marginBottom: 24 }}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom'
|
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import Layout from '../components/Layout'
|
||||||
import LoginPage from '../pages/LoginPage'
|
import LoginPage from '../pages/LoginPage'
|
||||||
import RegisterPage from '../pages/RegisterPage'
|
import RegisterPage from '../pages/RegisterPage'
|
||||||
import ProjectPage from '../pages/ProjectPage'
|
import ProjectPage from '../pages/ProjectPage'
|
||||||
@@ -25,20 +26,25 @@ export const router = createBrowserRouter([
|
|||||||
element: <ProtectedRoute />,
|
element: <ProtectedRoute />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '/',
|
element: <Layout />,
|
||||||
element: <Navigate to="/projects/proj-default" replace />,
|
children: [
|
||||||
},
|
{
|
||||||
{
|
path: '/',
|
||||||
path: '/projects/:projectId',
|
element: <Navigate to="/projects/proj-default" replace />,
|
||||||
element: <ProjectPage />,
|
},
|
||||||
},
|
{
|
||||||
{
|
path: '/projects/:projectId',
|
||||||
path: '/projects/:projectId/generate',
|
element: <ProjectPage />,
|
||||||
element: <GeneratePage />,
|
},
|
||||||
},
|
{
|
||||||
{
|
path: '/projects/:projectId/generate',
|
||||||
path: '/projects/:projectId/tasks/:taskId',
|
element: <GeneratePage />,
|
||||||
element: <ResultPage />,
|
},
|
||||||
|
{
|
||||||
|
path: '/projects/:projectId/tasks/:taskId',
|
||||||
|
element: <ResultPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { AssetType } from '../api/types'
|
import type { AssetType } from '../api/types'
|
||||||
|
import { optimizePrompt, extractTags } from '../api/prompt'
|
||||||
|
import { mergeStyles } from '../utils/style'
|
||||||
|
|
||||||
interface TaskParams {
|
interface TaskParams {
|
||||||
resolution: number
|
resolution: number
|
||||||
@@ -12,10 +14,16 @@ interface TaskState {
|
|||||||
assetType: AssetType
|
assetType: AssetType
|
||||||
taskStyle: Record<string, string>
|
taskStyle: Record<string, string>
|
||||||
params: TaskParams
|
params: TaskParams
|
||||||
|
enableAI: boolean
|
||||||
|
optimizedPrompt: string | null
|
||||||
|
isOptimizing: boolean
|
||||||
setPrompt: (text: string) => void
|
setPrompt: (text: string) => void
|
||||||
setAssetType: (type: AssetType) => void
|
setAssetType: (type: AssetType) => void
|
||||||
toggleTaskStyle: (key: string, value: string) => void
|
toggleTaskStyle: (key: string, value: string) => void
|
||||||
setParams: (params: Partial<TaskParams>) => void
|
setParams: (params: Partial<TaskParams>) => void
|
||||||
|
setEnableAI: (enable: boolean) => void
|
||||||
|
setOptimizedPrompt: (text: string | null) => void
|
||||||
|
runOptimize: (projectStyle: Record<string, string>) => Promise<void>
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,15 +32,18 @@ const DEFAULT_PARAMS: TaskParams = {
|
|||||||
format: 'spritesheet',
|
format: 'spritesheet',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTaskStore = create<TaskState>((set) => ({
|
export const useTaskStore = create<TaskState>((set, get) => ({
|
||||||
prompt: '',
|
prompt: '',
|
||||||
assetType: 'sprite',
|
assetType: 'sprite',
|
||||||
taskStyle: {},
|
taskStyle: {},
|
||||||
params: { ...DEFAULT_PARAMS },
|
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) =>
|
toggleTaskStyle: (key, value) =>
|
||||||
set(state => {
|
set(state => {
|
||||||
@@ -42,17 +53,48 @@ export const useTaskStore = create<TaskState>((set) => ({
|
|||||||
} else {
|
} else {
|
||||||
next[key] = value
|
next[key] = value
|
||||||
}
|
}
|
||||||
return { taskStyle: next }
|
return { taskStyle: next, optimizedPrompt: null }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setParams: (params) =>
|
setParams: (params) =>
|
||||||
set(state => ({ params: { ...state.params, ...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: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
prompt: '',
|
prompt: '',
|
||||||
assetType: 'sprite',
|
assetType: 'sprite',
|
||||||
taskStyle: {},
|
taskStyle: {},
|
||||||
params: { ...DEFAULT_PARAMS },
|
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-primary: #1a1a2e;
|
||||||
--bg-secondary: #16213e;
|
--bg-secondary: #16213e;
|
||||||
--bg-card: #0f3460;
|
--bg-card: #0f3460;
|
||||||
@@ -15,6 +41,13 @@
|
|||||||
--border: #2a2a4a;
|
--border: #2a2a4a;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-lg: 12px;
|
--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);
|
color: var(--text-primary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
@@ -60,13 +95,14 @@ select {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
select:focus {
|
select:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -75,7 +111,7 @@ button {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
@@ -92,6 +128,10 @@ button:disabled {
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-primary:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -101,6 +141,11 @@ button:disabled {
|
|||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@@ -108,6 +153,8 @@ button:disabled {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -115,3 +162,26 @@ button:disabled {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
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