refactor: 前端重构为项目+任务双核心模型

- 新增 HomePage 项目画廊页,支持新建/删除工程
- 新增 ProjectDetailPage 三栏工作台(配置+生成+任务列表)
- 新增 CustomTagsEditor 支持自定义风格标签(custom: 前缀存入 kvPairs)
- 重构 project store 为 draft/commit 模式,支持手动保存/取消
- 新增 projectList store 管理工程列表
- 扩展 mock.ts 为多工程数据结构
- 扩展 api/project.ts 新增项目 CRUD API(mock-gated)
- 简化 ResultPage,仅展示素材预览
- 删除 GeneratePage、ProjectPage 及未使用的 hooks
- 更新 docs/ 文档(frontend.md、api.md、style-keys.md、_index.md)
This commit is contained in:
2026-05-25 16:15:31 +08:00
parent 2bc0ec2eaa
commit a603f5548c
26 changed files with 1397 additions and 644 deletions
+4
View File
@@ -64,3 +64,7 @@ export function put<T>(url: string, body?: unknown): Promise<T> {
body: body ? JSON.stringify(body) : undefined,
})
}
export function del<T>(url: string): Promise<T> {
return request<T>(url, { method: 'DELETE' })
}
+172 -58
View File
@@ -3,60 +3,114 @@ import type { Asset, PipelineProgress, PipelineStage, Project, Task } from './ty
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const randomDelay = () => delay(300 + Math.random() * 500)
// Mock 数据
const MOCK_PROJECT: Project = {
id: 'proj-default',
name: '我的工程',
style: {
kvPairs: {
artStyle: 'pixel',
palette: 'warm',
lineWeight: 'thin',
scene: 'forest',
lighting: 'bright',
mood: 'cheerful',
// Mock 工程数据
const MOCK_PROJECTS: Project[] = [
{
id: 'proj-pixel-forest',
name: '像素森林',
style: {
kvPairs: {
artStyle: 'pixel',
palette: 'warm',
lineWeight: 'thin',
scene: 'forest',
lighting: 'bright',
mood: 'cheerful',
},
},
},
createdAt: '2026-05-20T10:00:00Z',
}
const MOCK_TASKS: Task[] = [
{
id: 'task-001',
projectId: 'proj-default',
prompt: '一个拿剑的小人',
assetType: 'sprite',
status: 'completed',
progress: 100,
createdAt: '2026-05-24T09:00:00Z',
updatedAt: '2026-05-24T09:02:00Z',
createdAt: '2026-05-20T10:00:00Z',
taskCount: 3,
lastActivityAt: '2026-05-24T09:02:00Z',
},
{
id: 'task-002',
projectId: 'proj-default',
prompt: '森林背景',
assetType: 'background',
status: 'failed',
error: '生成超时',
createdAt: '2026-05-24T08:00:00Z',
updatedAt: '2026-05-24T08:10:00Z',
id: 'proj-dark-dungeon',
name: '暗黑地牢',
style: {
kvPairs: {
artStyle: 'hand-drawn',
palette: 'muted',
lineWeight: 'thick',
scene: 'dungeon',
lighting: 'dim',
mood: 'dark',
},
},
createdAt: '2026-05-18T14:00:00Z',
taskCount: 1,
lastActivityAt: '2026-05-22T16:30:00Z',
},
{
id: 'task-003',
projectId: 'proj-default',
prompt: '魔法药水瓶',
assetType: 'ui',
status: 'completed',
progress: 100,
createdAt: '2026-05-23T15:00:00Z',
updatedAt: '2026-05-23T15:03:00Z',
id: 'proj-neon-city',
name: '霓虹都市',
style: {
kvPairs: {
artStyle: 'vector',
palette: 'vibrant',
lineWeight: 'medium',
scene: 'city',
lighting: 'neon',
mood: 'epic',
},
},
createdAt: '2026-05-15T08:00:00Z',
taskCount: 0,
lastActivityAt: '2026-05-15T08:00:00Z',
},
]
// Mock 任务数据(按工程分组)
const MOCK_TASKS: Record<string, Task[]> = {
'proj-pixel-forest': [
{
id: 'task-001',
projectId: 'proj-pixel-forest',
prompt: '一个拿剑的小人',
assetType: 'sprite',
status: 'completed',
progress: 100,
createdAt: '2026-05-24T09:00:00Z',
updatedAt: '2026-05-24T09:02:00Z',
},
{
id: 'task-002',
projectId: 'proj-pixel-forest',
prompt: '森林背景',
assetType: 'background',
status: 'failed',
error: '生成超时',
createdAt: '2026-05-24T08:00:00Z',
updatedAt: '2026-05-24T08:10:00Z',
},
{
id: 'task-003',
projectId: 'proj-pixel-forest',
prompt: '魔法药水瓶',
assetType: 'ui',
status: 'completed',
progress: 100,
createdAt: '2026-05-23T15:00:00Z',
updatedAt: '2026-05-23T15:03:00Z',
},
],
'proj-dark-dungeon': [
{
id: 'task-004',
projectId: 'proj-dark-dungeon',
prompt: '骷髅战士',
assetType: 'sprite',
status: 'completed',
progress: 100,
createdAt: '2026-05-22T16:28:00Z',
updatedAt: '2026-05-22T16:30:00Z',
},
],
'proj-neon-city': [],
}
const MOCK_ASSETS: Asset[] = [
{
id: 'asset-001',
key: 'generation/proj-001/task-001/0.png',
key: 'generation/proj-pixel-forest/task-001/0.png',
url: 'https://placehold.co/256x256/e94560/1a1a2e?text=Sprite',
format: 'png',
width: 256,
@@ -70,36 +124,89 @@ const MOCK_ASSETS: Asset[] = [
},
]
// Mock API 函数
export async function mockGetProject(_id: string): Promise<Project> {
// Mock API 函数 — 工程
export async function mockListProjects(): Promise<Project[]> {
await randomDelay()
return { ...MOCK_PROJECT }
return MOCK_PROJECTS.map(p => ({ ...p }))
}
export async function mockGetProject(projectId: string): Promise<Project> {
await randomDelay()
const project = MOCK_PROJECTS.find(p => p.id === projectId)
if (!project) throw new Error('工程不存在')
return { ...project }
}
export async function mockCreateProject(
name: string,
style?: Record<string, string>
): Promise<Project> {
await delay(500)
const id = `proj-${Date.now()}`
const now = new Date().toISOString()
const project: Project = {
id,
name,
style: { kvPairs: style || {} },
createdAt: now,
taskCount: 0,
lastActivityAt: now,
}
MOCK_PROJECTS.unshift(project)
MOCK_TASKS[id] = []
return { ...project }
}
export async function mockDeleteProject(projectId: string): Promise<void> {
await randomDelay()
const index = MOCK_PROJECTS.findIndex(p => p.id === projectId)
if (index !== -1) MOCK_PROJECTS.splice(index, 1)
delete MOCK_TASKS[projectId]
}
export async function mockUpdateProject(
projectId: string,
data: { name?: string }
): Promise<Project> {
await randomDelay()
const project = MOCK_PROJECTS.find(p => p.id === projectId)
if (!project) throw new Error('工程不存在')
if (data.name !== undefined) project.name = data.name
return { ...project }
}
// Mock API 函数 — 风格
export async function mockGetStyle(
_projectId: string
projectId: string
): Promise<Record<string, string>> {
await randomDelay()
return { ...MOCK_PROJECT.style.kvPairs }
const project = MOCK_PROJECTS.find(p => p.id === projectId)
if (!project) throw new Error('工程不存在')
return { ...project.style.kvPairs }
}
export async function mockSaveStyle(
_projectId: string,
_kvPairs: Record<string, string>
projectId: string,
kvPairs: Record<string, string>
): Promise<void> {
await randomDelay()
const project = MOCK_PROJECTS.find(p => p.id === projectId)
if (project) project.style.kvPairs = { ...kvPairs }
}
export async function mockGetTasks(_projectId: string): Promise<Task[]> {
// Mock API 函数 — 任务
export async function mockGetTasks(projectId: string): Promise<Task[]> {
await randomDelay()
return MOCK_TASKS.map(t => ({ ...t }))
return (MOCK_TASKS[projectId] || []).map(t => ({ ...t }))
}
export async function mockGetTask(taskId: string): Promise<Task> {
await randomDelay()
const task = MOCK_TASKS.find(t => t.id === taskId)
if (!task) throw new Error('任务不存在')
return { ...task }
for (const tasks of Object.values(MOCK_TASKS)) {
const task = tasks.find(t => t.id === taskId)
if (task) return { ...task }
}
throw new Error('任务不存在')
}
export async function mockGetAssets(_taskId: string): Promise<Asset[]> {
@@ -108,21 +215,28 @@ export async function mockGetAssets(_taskId: string): Promise<Asset[]> {
}
export async function mockSubmitGenerate(
_projectId: string,
projectId: string,
prompt: string,
assetType: string
): Promise<string> {
await delay(500)
const taskId = `task-${Date.now()}`
MOCK_TASKS.unshift({
if (!MOCK_TASKS[projectId]) MOCK_TASKS[projectId] = []
MOCK_TASKS[projectId].unshift({
id: taskId,
projectId: _projectId,
projectId,
prompt,
assetType,
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
// 更新工程的 lastActivityAt 和 taskCount
const project = MOCK_PROJECTS.find(p => p.id === projectId)
if (project) {
project.lastActivityAt = new Date().toISOString()
project.taskCount = (project.taskCount || 0) + 1
}
return taskId
}
+31 -2
View File
@@ -1,16 +1,45 @@
import type { Project, Task } from './types'
import {
mockListProjects,
mockGetProject,
mockCreateProject,
mockDeleteProject,
mockUpdateProject,
mockGetStyle,
mockGetTasks,
mockSaveStyle,
mockGetTasks,
} from './mock'
const USE_MOCK = true
export async function listProjects(): Promise<Project[]> {
if (USE_MOCK) return mockListProjects()
throw new Error('Not implemented')
}
export async function getProject(projectId: string): Promise<Project> {
if (USE_MOCK) return mockGetProject(projectId)
// TODO: 真实 API 调用
throw new Error('Not implemented')
}
export async function createProject(
name: string,
style?: Record<string, string>
): Promise<Project> {
if (USE_MOCK) return mockCreateProject(name, style)
throw new Error('Not implemented')
}
export async function deleteProject(projectId: string): Promise<void> {
if (USE_MOCK) return mockDeleteProject(projectId)
throw new Error('Not implemented')
}
export async function updateProject(
projectId: string,
data: { name?: string }
): Promise<Project> {
if (USE_MOCK) return mockUpdateProject(projectId, data)
throw new Error('Not implemented')
}
+5 -3
View File
@@ -1,5 +1,5 @@
import { post } from './client'
import { STYLE_CATEGORIES } from '../utils/style'
import { STYLE_CATEGORIES, getCustomTags } from '../utils/style'
interface OptimizePromptParams {
tags: string[]
@@ -14,15 +14,17 @@ interface OptimizePromptResponse {
}
/**
* 从风格键值对中提取标签的中文名称作为 tags
* 从风格键值对中提取标签的中文名称作为 tags,包含自定义标签
*/
export function extractTags(style: Record<string, string>): string[] {
return STYLE_CATEGORIES.flatMap(cat => {
const presetTags = 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] : []
})
const customTags = getCustomTags(style)
return [...presetTags, ...customTags]
}
/**
+3
View File
@@ -50,10 +50,13 @@ export interface Project {
name: string
style: { kvPairs: Record<string, string> }
createdAt: string
taskCount?: number
lastActivityAt?: string
}
export interface CreateProjectRequest {
name: string
style?: Record<string, string>
}
// 风格
@@ -0,0 +1,115 @@
import { useState } from 'react'
import StyleSelector from './StyleSelector'
import CustomTagsEditor from './CustomTagsEditor'
interface CreateProjectModalProps {
open: boolean
onClose: () => void
onSubmit: (name: string, style: Record<string, string>) => Promise<void>
}
export default function CreateProjectModal({
open,
onClose,
onSubmit,
}: CreateProjectModalProps) {
const [name, setName] = useState('')
const [style, setStyle] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
if (!open) return null
const handleStyleChange = (key: string, value: string) => {
setStyle(prev =>
prev[key] === value
? (() => {
const { [key]: _, ...rest } = prev
return rest
})()
: { ...prev, [key]: value }
)
}
const handleSubmit = async () => {
if (!name.trim()) return
setSubmitting(true)
try {
await onSubmit(name.trim(), style)
setName('')
setStyle({})
onClose()
} finally {
setSubmitting(false)
}
}
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onClose}
>
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
}}
/>
<div
className="card"
style={{
position: 'relative',
width: '100%',
maxWidth: 520,
maxHeight: '80vh',
overflow: 'auto',
zIndex: 1,
}}
onClick={e => e.stopPropagation()}
>
<h2 style={{ fontSize: 18, marginBottom: 20 }}>新建工程</h2>
<label style={{ display: 'block', marginBottom: 16 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--text-secondary)' }}>
工程名称
</span>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="输入工程名称"
style={{ width: '100%' }}
/>
</label>
<div style={{ marginBottom: 16 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>
风格配置
</span>
<StyleSelector value={style} onChange={handleStyleChange} compact />
<CustomTagsEditor style={style} onChange={setStyle} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<button className="btn-secondary" onClick={onClose} disabled={submitting}>
取消
</button>
<button
className="btn-primary"
onClick={handleSubmit}
disabled={!name.trim() || submitting}
>
{submitting ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,87 @@
import { useState } from 'react'
import { getCustomTags, addCustomTag, removeCustomTag } from '../utils/style'
interface CustomTagsEditorProps {
style: Record<string, string>
onChange: (style: Record<string, string>) => void
}
export default function CustomTagsEditor({ style, onChange }: CustomTagsEditorProps) {
const [input, setInput] = useState('')
const tags = getCustomTags(style)
const handleAdd = () => {
const tag = input.trim()
if (!tag) return
if (tags.includes(tag)) return
onChange(addCustomTag(style, tag))
setInput('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAdd()
}
}
return (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>
自定义标签
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{tags.map(tag => (
<span
key={tag}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
fontSize: 12,
padding: '3px 10px',
borderRadius: 999,
background: 'var(--bg-input)',
border: '1px solid var(--border)',
}}
>
{tag}
<button
type="button"
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-muted)',
fontSize: 14,
padding: 0,
lineHeight: 1,
cursor: 'pointer',
}}
onClick={() => onChange(removeCustomTag(style, tag))}
>
x
</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入标签后按 Enter 添加"
style={{ flex: 1, padding: '6px 10px', fontSize: 13 }}
/>
<button
type="button"
className="btn-secondary"
style={{ padding: '6px 14px', fontSize: 13 }}
onClick={handleAdd}
>
添加
</button>
</div>
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { useNavigate } from 'react-router-dom'
import type { Project } from '../api/types'
import { STYLE_CATEGORIES } from '../utils/style'
interface ProjectCardProps {
project: Project
onDelete?: (projectId: string) => void
}
function formatRelativeTime(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diff = now - then
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
return `${days} 天前`
}
export default function ProjectCard({ project, onDelete }: ProjectCardProps) {
const navigate = useNavigate()
const presetTags = STYLE_CATEGORIES.flatMap(cat => {
const value = project.style.kvPairs[cat.key]
if (!value) return []
const option = cat.options.find(o => o.value === value)
return option ? [option.label] : []
}).slice(0, 4)
return (
<div
className="card"
style={{ cursor: 'pointer', position: 'relative' }}
onClick={() => navigate(`/projects/${project.id}`)}
>
{onDelete && (
<button
style={{
position: 'absolute',
top: 12,
right: 12,
background: 'transparent',
border: 'none',
color: 'var(--text-muted)',
fontSize: 16,
padding: '4px 8px',
borderRadius: 'var(--radius)',
}}
onClick={e => {
e.stopPropagation()
onDelete(project.id)
}}
>
x
</button>
)}
<h3 style={{ fontSize: 16, marginBottom: 12 }}>{project.name}</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{presetTags.map(tag => (
<span
key={tag}
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 999,
background: 'var(--accent-dim)',
color: 'var(--accent)',
}}
>
{tag}
</span>
))}
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 12,
color: 'var(--text-muted)',
}}
>
<span>{project.taskCount || 0} 个任务</span>
{project.lastActivityAt && (
<span>{formatRelativeTime(project.lastActivityAt)}</span>
)}
</div>
</div>
)
}
@@ -0,0 +1,109 @@
import { useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useProjectStore } from '../stores/project'
import { useToastStore } from '../stores/toast'
import StyleSelector from './StyleSelector'
import CustomTagsEditor from './CustomTagsEditor'
import Skeleton from './Skeleton'
export default function ProjectConfigPanel() {
const { projectId = '' } = useParams()
const {
draftName,
draftStyle,
hasUnsavedChanges,
loading,
loadProject,
loadStyle,
startEditing,
updateDraft,
setDraftStyle,
setDraftName,
discardDraft,
commitDraft,
} = useProjectStore()
const addToast = useToastStore(s => s.addToast)
useEffect(() => {
if (projectId) {
loadProject(projectId)
loadStyle(projectId)
startEditing()
}
}, [projectId, loadProject, loadStyle, startEditing])
const handleSave = async () => {
try {
await commitDraft()
addToast({ type: 'success', message: '配置已保存' })
} catch {
addToast({ type: 'error', message: '保存失败' })
}
}
if (loading) {
return (
<div style={{ padding: 16 }}>
<Skeleton variant="text" lines={6} />
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border)' }}>
<input
type="text"
value={draftName}
onChange={e => setDraftName(e.target.value)}
style={{
width: '100%',
fontSize: 16,
fontWeight: 600,
padding: '4px 0',
border: 'none',
background: 'transparent',
outline: 'none',
borderBottom: '2px solid transparent',
}}
onFocus={e => (e.target.style.borderBottomColor = 'var(--accent)')}
onBlur={e => (e.target.style.borderBottomColor = 'transparent')}
/>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '12px 16px' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>
工程风格
</div>
<StyleSelector value={draftStyle} onChange={updateDraft} compact />
<CustomTagsEditor style={draftStyle} onChange={setDraftStyle} />
</div>
<div
style={{
padding: 12,
borderTop: '1px solid var(--border)',
display: 'flex',
gap: 8,
}}
>
<button
className="btn-primary"
onClick={handleSave}
disabled={!hasUnsavedChanges}
style={{ flex: 1, padding: '8px 0', fontSize: 13 }}
>
保存
</button>
<button
className="btn-secondary"
onClick={discardDraft}
disabled={!hasUnsavedChanges}
style={{ flex: 1, padding: '8px 0', fontSize: 13 }}
>
取消
</button>
</div>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
interface StatusBadgeProps {
status: string
}
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
pending: { label: '等待中', color: 'var(--text-muted)', bg: 'var(--bg-input)' },
submitted: { label: '已提交', color: 'var(--text-secondary)', bg: 'var(--bg-input)' },
running: { label: '运行中', color: 'var(--warning)', bg: 'rgba(230, 119, 0, 0.12)' },
completed: { label: '已完成', color: 'var(--success)', bg: 'rgba(43, 138, 62, 0.12)' },
failed: { label: '失败', color: 'var(--error)', bg: 'rgba(201, 42, 42, 0.12)' },
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
return (
<span
style={{
display: 'inline-block',
fontSize: 11,
fontWeight: 600,
padding: '2px 8px',
borderRadius: 999,
color: config.color,
background: config.bg,
}}
>
{config.label}
</span>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import { getTasks } from '../api/project'
import type { Task } from '../api/types'
import StatusBadge from './StatusBadge'
import Skeleton from './Skeleton'
const ASSET_TYPE_LABELS: Record<string, string> = {
sprite: '精灵',
background: '背景',
ui: 'UI',
animation: '动画',
}
function formatRelativeTime(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diff = now - then
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}小时前`
const days = Math.floor(hours / 24)
return `${days}天前`
}
export default function TaskListPanel() {
const { projectId = '' } = useParams()
const [tasks, setTasks] = useState<Task[]>([])
const [loading, setLoading] = useState(true)
const fetchTasks = useCallback(async () => {
if (!projectId) return
try {
const data = await getTasks(projectId)
setTasks(data)
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => {
fetchTasks()
const timer = setInterval(fetchTasks, 15000)
return () => clearInterval(timer)
}, [fetchTasks])
if (loading) {
return (
<div style={{ padding: 16 }}>
<Skeleton variant="text" lines={4} />
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<h3 style={{ fontSize: 14, fontWeight: 600, padding: '16px 16px 12px', borderBottom: '1px solid var(--border)' }}>
任务列表 ({tasks.length})
</h3>
<div style={{ flex: 1, overflow: 'auto' }}>
{tasks.length === 0 ? (
<p style={{ padding: 16, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
暂无任务
</p>
) : (
tasks.map(task => (
<a
key={task.id}
href={task.status === 'completed' ? `/projects/${projectId}/tasks/${task.id}` : undefined}
style={{
display: 'block',
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
textDecoration: 'none',
color: 'inherit',
cursor: task.status === 'completed' ? 'pointer' : 'default',
transition: 'background 0.15s',
}}
onMouseEnter={e => {
if (task.status === 'completed')
(e.currentTarget as HTMLElement).style.background = 'var(--accent-dim)'
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.background = ''
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{ASSET_TYPE_LABELS[task.assetType] || task.assetType}
</span>
<StatusBadge status={task.status} />
</div>
<p
style={{
fontSize: 13,
lineHeight: 1.4,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{task.prompt}
</p>
<span style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4, display: 'block' }}>
{formatRelativeTime(task.createdAt)}
</span>
</a>
))
)}
</div>
</div>
)
}
-14
View File
@@ -1,14 +0,0 @@
import { useGenerationStore } from '../stores/generation'
export function useGenerate() {
const store = useGenerationStore()
return {
submit: store.submit,
taskId: store.taskId,
progress: store.progress,
status: store.status,
assets: store.assets,
error: store.error,
reset: store.reset,
}
}
-14
View File
@@ -1,14 +0,0 @@
import { useEffect } from 'react'
import { useProjectStore } from '../stores/project'
export function useProjectStyle(projectId: string) {
const { style, loading, loadProject, loadStyle, updateStyle, saveStyle } =
useProjectStore()
useEffect(() => {
loadProject(projectId)
loadStyle(projectId)
}, [projectId, loadProject, loadStyle])
return { style, loading, updateStyle, saveStyle }
}
-132
View File
@@ -1,132 +0,0 @@
import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useTaskStore } from '../stores/task'
import { useProjectStore } from '../stores/project'
import { useGenerationStore } from '../stores/generation'
import { useToastStore } from '../stores/toast'
import { extractTags } from '../api/prompt'
import { mergeStyles } from '../utils/style'
import type { GenerateRequest } from '../api/types'
import GenerateForm from '../components/GenerateForm'
import ProgressBar from '../components/ProgressBar'
export default function GeneratePage() {
const { projectId = 'proj-default' } = useParams()
const navigate = useNavigate()
const addToast = useToastStore(s => s.addToast)
const taskStore = useTaskStore()
const { style: projectStyle, loadProject } = useProjectStore()
const {
status,
stage,
progress,
taskId,
statusText,
retryCount,
rejectReason,
submit,
reset: resetGeneration,
} = useGenerationStore()
// 加载工程风格
useEffect(() => {
loadProject(projectId)
}, [projectId, loadProject])
// 组件卸载时重置生成状态
useEffect(() => {
return () => resetGeneration()
}, [resetGeneration])
// 失败时显示 toast
useEffect(() => {
if (status === 'failed') {
const errText = useGenerationStore.getState().error || '未知错误'
addToast({ type: 'error', message: `素材生成失败:${errText}` })
}
}, [status, addToast])
const handleSubmit = async (finalPrompt: string) => {
const { taskStyle, params, enableAI, optimizedPrompt } = taskStore
const mergedStyle = mergeStyles(projectStyle, taskStyle)
const tags = extractTags(mergedStyle)
const req: GenerateRequest = {
projectId,
prompt: enableAI && optimizedPrompt ? optimizedPrompt : finalPrompt,
assetType: taskStore.assetType,
tags,
projectStyle,
taskStyle,
resolution: params.resolution,
directions: params.frames?.directions,
framesPerDir: params.frames?.framesPerDirection,
format: params.format,
}
await submit(req)
}
const handleViewResult = () => {
if (taskId) navigate(`/projects/${projectId}/tasks/${taskId}`)
}
const handleReset = () => {
resetGeneration()
taskStore.reset()
}
return (
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>新建生成</h1>
{status === 'idle' || status === 'submitting' ? (
<GenerateForm onSubmit={handleSubmit} submitting={status === 'submitting'} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<ProgressBar
stage={stage}
progress={progress}
status={status}
retryCount={retryCount}
rejectReason={rejectReason}
/>
{status === 'running' && (
<p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>
{statusText || '管线执行中,请稍候...'}
</p>
)}
{status === 'completed' && (
<div style={{ textAlign: 'center' }}>
<p style={{ color: 'var(--success)', marginBottom: 16 }}>
生成完成
</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
<button className="btn-primary" onClick={handleViewResult}>
查看结果
</button>
<button className="btn-secondary" onClick={handleReset}>
继续生成
</button>
</div>
</div>
)}
{status === 'failed' && (
<div style={{ textAlign: 'center' }}>
<p style={{ color: 'var(--error)', marginBottom: 16 }}>
{useGenerationStore.getState().error || '生成失败'}
</p>
<button className="btn-primary" onClick={handleReset}>
重新开始
</button>
</div>
)}
</div>
)}
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import { useProjectListStore } from '../stores/projectList'
import { useToastStore } from '../stores/toast'
import ProjectCard from '../components/ProjectCard'
import CreateProjectModal from '../components/CreateProjectModal'
import EmptyState from '../components/EmptyState'
import Skeleton from '../components/Skeleton'
export default function HomePage() {
const { projects, loading, loadProjects, createProject, deleteProject } =
useProjectListStore()
const addToast = useToastStore(s => s.addToast)
const [modalOpen, setModalOpen] = useState(false)
useEffect(() => {
loadProjects()
}, [loadProjects])
const handleCreate = async (name: string, style: Record<string, string>) => {
await createProject(name, style)
addToast({ type: 'success', message: '工程创建成功' })
}
const handleDelete = async (projectId: string) => {
if (!confirm('确定删除该工程?所有任务和素材将被永久删除。')) return
await deleteProject(projectId)
addToast({ type: 'success', message: '工程已删除' })
}
if (loading) {
return (
<div className="container page-enter" style={{ paddingTop: 32 }}>
<Skeleton variant="card" />
<div style={{ height: 16 }} />
<Skeleton variant="card" />
</div>
)
}
return (
<div className="container page-enter" style={{ paddingTop: 32, paddingBottom: 32 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1 style={{ fontSize: 22 }}>我的工程</h1>
<button className="btn-primary" onClick={() => setModalOpen(true)}>
新建工程
</button>
</div>
{projects.length === 0 ? (
<EmptyState
title="还没有工程"
description="创建你的第一个工程,开始生成游戏素材"
action={{ label: '新建工程', onClick: () => setModalOpen(true) }}
/>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 16,
}}
>
{projects.map(project => (
<ProjectCard
key={project.id}
project={project}
onDelete={handleDelete}
/>
))}
</div>
)}
<CreateProjectModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSubmit={handleCreate}
/>
</div>
)
}
+173
View File
@@ -0,0 +1,173 @@
import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useProjectStore } from '../stores/project'
import { useTaskStore } from '../stores/task'
import { useGenerationStore } from '../stores/generation'
import { useToastStore } from '../stores/toast'
import { extractTags } from '../api/prompt'
import { mergeStyles } from '../utils/style'
import type { GenerateRequest } from '../api/types'
import ProjectConfigPanel from '../components/ProjectConfigPanel'
import GenerateForm from '../components/GenerateForm'
import ProgressBar from '../components/ProgressBar'
import TaskListPanel from '../components/TaskListPanel'
export default function ProjectDetailPage() {
const { projectId = '' } = useParams()
const navigate = useNavigate()
const addToast = useToastStore(s => s.addToast)
const { style: projectStyle } = useProjectStore()
const taskStore = useTaskStore()
const {
status,
stage,
progress,
taskId,
statusText,
retryCount,
rejectReason,
submit,
reset: resetGeneration,
} = useGenerationStore()
// 重置生成状态(切换工程时)
useEffect(() => {
resetGeneration()
taskStore.reset()
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
// 失败时 toast
useEffect(() => {
if (status === 'failed') {
const errText = useGenerationStore.getState().error || '未知错误'
addToast({ type: 'error', message: `素材生成失败:${errText}` })
}
}, [status, addToast])
const handleSubmit = async (finalPrompt: string) => {
const { taskStyle, params, enableAI, optimizedPrompt } = taskStore
const mergedStyle = mergeStyles(projectStyle, taskStyle)
const tags = extractTags(mergedStyle)
const req: GenerateRequest = {
projectId,
prompt: enableAI && optimizedPrompt ? optimizedPrompt : finalPrompt,
assetType: taskStore.assetType,
tags,
projectStyle,
taskStyle,
resolution: params.resolution,
directions: params.frames?.directions,
framesPerDir: params.frames?.framesPerDirection,
format: params.format,
}
await submit(req)
}
const handleViewResult = () => {
if (taskId) navigate(`/projects/${projectId}/tasks/${taskId}`)
}
const handleReset = () => {
resetGeneration()
taskStore.reset()
}
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '260px 1fr 320px',
height: 'calc(100vh - 56px)',
overflow: 'hidden',
}}
>
{/* 左栏:工程配置 */}
<aside
style={{
borderRight: '1px solid var(--border)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<ProjectConfigPanel />
</aside>
{/* 中间:生成表单 + 进度 */}
<main
className="page-enter"
style={{ overflow: 'auto', padding: '24px 32px' }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 32, maxWidth: 720 }}>
{/* 上半:生成表单 */}
<section>
<h2 style={{ fontSize: 18, marginBottom: 20 }}>新建生成</h2>
<GenerateForm onSubmit={handleSubmit} submitting={status === 'submitting'} />
</section>
{/* 下半:当前任务进度 */}
{status !== 'idle' && (
<section className="card">
<h2 style={{ fontSize: 16, marginBottom: 16 }}>生成进度</h2>
<ProgressBar
stage={stage}
progress={progress}
status={status}
retryCount={retryCount}
rejectReason={rejectReason}
/>
{status === 'running' && (
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', marginTop: 12 }}>
{statusText || '管线执行中,请稍候...'}
</p>
)}
{status === 'completed' && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<p style={{ color: 'var(--success)', marginBottom: 12 }}>
生成完成
</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
<button className="btn-primary" onClick={handleViewResult}>
查看结果
</button>
<button className="btn-secondary" onClick={handleReset}>
继续生成
</button>
</div>
</div>
)}
{status === 'failed' && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<p style={{ color: 'var(--error)', marginBottom: 12 }}>
{useGenerationStore.getState().error || '生成失败'}
</p>
<button className="btn-primary" onClick={handleReset}>
重新开始
</button>
</div>
)}
</section>
)}
</div>
</main>
{/* 右栏:任务列表 */}
<aside
style={{
borderLeft: '1px solid var(--border)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<TaskListPanel />
</aside>
</div>
)
}
-165
View File
@@ -1,165 +0,0 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useProjectStore } from '../stores/project'
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)' },
running: { label: '运行中', color: 'var(--warning)' },
completed: { label: '已完成', color: 'var(--success)' },
failed: { label: '失败', color: 'var(--error)' },
}
export default function ProjectPage() {
const { projectId = 'proj-default' } = useParams()
const navigate = useNavigate()
const {
name,
style,
loading,
loadProject,
loadStyle,
updateStyle,
saveStyle,
} = useProjectStore()
const [tasks, setTasks] = useState<Task[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
loadProject(projectId)
loadStyle(projectId)
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 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 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
}}
>
<h2 style={{ fontSize: 18 }}>工程风格</h2>
<button
className="btn-primary"
onClick={handleSave}
disabled={saving}
style={{ padding: '8px 20px', fontSize: 13 }}
>
{saving ? '保存中...' : '保存风格'}
</button>
</div>
<StyleSelector value={style} onChange={updateStyle} />
</section>
{/* 任务列表 */}
<section className="card">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
}}
>
<h2 style={{ fontSize: 18 }}>历史任务</h2>
<Link
to={`/projects/${projectId}/generate`}
className="btn-primary"
style={{
padding: '8px 20px',
fontSize: 13,
borderRadius: 'var(--radius)',
display: 'inline-block',
}}
>
新建生成
</Link>
</div>
{tasks.length === 0 ? (
<EmptyState
title="暂无任务"
description="点击「新建生成」开始创建你的第一个游戏素材"
action={{ label: '新建生成', onClick: () => navigate(`/projects/${projectId}/generate`) }}
/>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={thStyle}>提示词</th>
<th style={thStyle}>类型</th>
<th style={thStyle}>状态</th>
<th style={thStyle}>创建时间</th>
<th style={thStyle}>操作</th>
</tr>
</thead>
<tbody>
{tasks.map(task => {
const statusInfo = STATUS_LABELS[task.status] ?? STATUS_LABELS.pending
return (
<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>
<td style={tdStyle}>
{new Date(task.createdAt).toLocaleString('zh-CN')}
</td>
<td style={tdStyle}>
{task.status === 'completed' && (
<Link
to={`/projects/${projectId}/tasks/${task.id}`}
style={{ fontSize: 13 }}
>
查看结果
</Link>
)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</section>
</div>
)
}
const thStyle: React.CSSProperties = {
textAlign: 'left',
padding: '10px 12px',
fontSize: 13,
color: 'var(--text-secondary)',
fontWeight: 600,
}
const tdStyle: React.CSSProperties = {
padding: '10px 12px',
fontSize: 13,
}
+23 -58
View File
@@ -6,7 +6,7 @@ import AssetPreview from '../components/AssetPreview'
import Skeleton from '../components/Skeleton'
export default function ResultPage() {
const { projectId = 'proj-default', taskId } = useParams()
const { projectId = '', taskId } = useParams()
const [task, setTask] = useState<Task | null>(null)
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
@@ -33,12 +33,10 @@ export default function ResultPage() {
setPolling(false)
setLoading(false)
} else if (!pollRef.current) {
// 开始轮询
setPolling(true)
pollRef.current = setInterval(fetchTask, 2000)
}
} catch {
// 出错也停止加载态
setLoading(false)
}
}
@@ -53,10 +51,10 @@ export default function ResultPage() {
if (loading || polling) {
return (
<div className="container page-enter" style={{ paddingTop: 40 }}>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card">
<Skeleton variant="text" lines={1} />
<div style={{ marginTop: 16 }}>
<Skeleton variant="text" lines={4} />
<Skeleton variant="card" />
</div>
{task && (
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', marginTop: 16 }}>
@@ -66,9 +64,6 @@ export default function ResultPage() {
</p>
)}
</div>
<div className="card">
<Skeleton variant="card" />
</div>
</div>
)
}
@@ -86,58 +81,28 @@ export default function ResultPage() {
return (
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>生成结果</h1>
<section className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ fontSize: 16, marginBottom: 0 }}>任务信息</h2>
<div style={{ display: 'flex', gap: 12 }}>
{assets.length > 0 && (
<button
className="btn-primary"
onClick={() => downloadAssets(assets)}
style={{ padding: '8px 20px', fontSize: 13 }}
>
下载全部素材
</button>
)}
<Link
to={`/projects/${projectId}/generate`}
className="btn-secondary"
style={{ padding: '8px 20px', fontSize: 13, borderRadius: 'var(--radius)', display: 'inline-block' }}
>
继续生成
</Link>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Link
to={`/projects/${projectId}`}
style={{ fontSize: 13, color: 'var(--text-secondary)' }}
>
&larr; 返回工程
</Link>
<h1 style={{ fontSize: 22 }}>生成结果</h1>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '8px 16px', fontSize: 13 }}>
<span style={{ color: 'var(--text-secondary)' }}>提示词</span>
<span>{task.prompt}</span>
<span style={{ color: 'var(--text-secondary)' }}>素材类型</span>
<span>{task.assetType}</span>
<span style={{ color: 'var(--text-secondary)' }}>状态</span>
<span style={{ color: task.status === 'completed' ? 'var(--success)' : 'var(--error)' }}>
{task.status === 'completed' ? '已完成' : '失败'}
</span>
<span style={{ color: 'var(--text-secondary)' }}>创建时间</span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
{task.retryCount != null && task.retryCount > 0 && (
<>
<span style={{ color: 'var(--text-secondary)' }}>重试次数</span>
<span>{task.retryCount}</span>
</>
)}
{task.error && (
<>
<span style={{ color: 'var(--text-secondary)' }}>错误</span>
<span style={{ color: 'var(--error)' }}>{task.error}</span>
</>
)}
</div>
</section>
{assets.length > 0 && (
<button
className="btn-primary"
onClick={() => downloadAssets(assets)}
style={{ padding: '8px 20px', fontSize: 13 }}
>
下载全部素材
</button>
)}
</div>
<section className="card" style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 16, marginBottom: 16 }}>素材预览</h2>
<section className="card">
<AssetPreview assets={assets} />
</section>
</div>
+3 -8
View File
@@ -3,8 +3,7 @@ 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'
import GeneratePage from '../pages/GeneratePage'
import HomePage from '../pages/HomePage'
import ResultPage from '../pages/ResultPage'
function ProtectedRoute() {
@@ -30,15 +29,11 @@ export const router = createBrowserRouter([
children: [
{
path: '/',
element: <Navigate to="/projects/proj-default" replace />,
element: <HomePage />,
},
{
path: '/projects/:projectId',
element: <ProjectPage />,
},
{
path: '/projects/:projectId/generate',
element: <GeneratePage />,
lazy: () => import('../pages/ProjectDetailPage').then(m => ({ Component: m.default })),
},
{
path: '/projects/:projectId/tasks/:taskId',
+63 -8
View File
@@ -5,17 +5,28 @@ interface ProjectState {
projectId: string
name: string
style: Record<string, string>
draftStyle: Record<string, string>
draftName: string
hasUnsavedChanges: boolean
loading: boolean
loadProject: (projectId: string) => Promise<void>
loadStyle: (projectId: string) => Promise<void>
updateStyle: (key: string, value: string) => void
saveStyle: () => Promise<void>
startEditing: () => void
updateDraft: (key: string, value: string) => void
setDraftStyle: (style: Record<string, string>) => void
setDraftName: (name: string) => void
discardDraft: () => void
commitDraft: () => Promise<void>
updateName: (name: string) => Promise<void>
}
export const useProjectStore = create<ProjectState>((set, get) => ({
projectId: '',
name: '',
style: {},
draftStyle: {},
draftName: '',
hasUnsavedChanges: false,
loading: false,
loadProject: async (projectId) => {
@@ -25,7 +36,10 @@ export const useProjectStore = create<ProjectState>((set, get) => ({
set({
projectId: project.id,
name: project.name,
draftName: project.name,
style: project.style.kvPairs,
draftStyle: { ...project.style.kvPairs },
hasUnsavedChanges: false,
loading: false,
})
} catch {
@@ -35,15 +49,56 @@ export const useProjectStore = create<ProjectState>((set, get) => ({
loadStyle: async (projectId) => {
const kvPairs = await projectApi.getStyle(projectId)
set({ style: kvPairs })
set({ style: kvPairs, draftStyle: { ...kvPairs }, hasUnsavedChanges: false })
},
updateStyle: (key, value) => {
set(state => ({ style: { ...state.style, [key]: value } }))
startEditing: () => {
const { style, name } = get()
set({ draftStyle: { ...style }, draftName: name, hasUnsavedChanges: false })
},
saveStyle: async () => {
const { projectId, style } = get()
await projectApi.saveStyle(projectId, style)
updateDraft: (key, value) => {
set(state => {
const next = { ...state.draftStyle }
if (next[key] === value) {
delete next[key]
} else {
next[key] = value
}
return {
draftStyle: next,
hasUnsavedChanges: true,
}
})
},
setDraftStyle: (style) => set({ draftStyle: style, hasUnsavedChanges: true }),
setDraftName: (name) => set({ draftName: name, hasUnsavedChanges: true }),
discardDraft: () => {
const { style, name } = get()
set({ draftStyle: { ...style }, draftName: name, hasUnsavedChanges: false })
},
commitDraft: async () => {
const { projectId, draftStyle, draftName, name } = get()
// save style
await projectApi.saveStyle(projectId, draftStyle)
// save name if changed
if (draftName !== name) {
await projectApi.updateProject(projectId, { name: draftName })
}
set({
style: { ...draftStyle },
name: draftName,
hasUnsavedChanges: false,
})
},
updateName: async (name) => {
const { projectId } = get()
await projectApi.updateProject(projectId, { name })
set({ name, draftName: name })
},
}))
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand'
import type { Project } from '../api/types'
import * as projectApi from '../api/project'
interface ProjectListState {
projects: Project[]
loading: boolean
loadProjects: () => Promise<void>
createProject: (name: string, style?: Record<string, string>) => Promise<Project>
deleteProject: (projectId: string) => Promise<void>
}
export const useProjectListStore = create<ProjectListState>((set, get) => ({
projects: [],
loading: false,
loadProjects: async () => {
set({ loading: true })
try {
const projects = await projectApi.listProjects()
set({ projects, loading: false })
} catch {
set({ loading: false })
}
},
createProject: async (name, style) => {
const project = await projectApi.createProject(name, style)
set({ projects: [project, ...get().projects] })
return project
},
deleteProject: async (projectId) => {
await projectApi.deleteProject(projectId)
set({ projects: get().projects.filter(p => p.id !== projectId) })
},
}))
+27
View File
@@ -8,6 +8,33 @@ export function mergeStyles(
return { ...projectStyle, ...taskOverrides }
}
const CUSTOM_PREFIX = 'custom:'
export function isCustomKey(key: string): boolean {
return key.startsWith(CUSTOM_PREFIX)
}
export function getCustomTags(style: Record<string, string>): string[] {
return Object.keys(style)
.filter(isCustomKey)
.map(key => key.slice(CUSTOM_PREFIX.length))
}
export function addCustomTag(
style: Record<string, string>,
tag: string
): Record<string, string> {
return { ...style, [`${CUSTOM_PREFIX}${tag}`]: 'true' }
}
export function removeCustomTag(
style: Record<string, string>,
tag: string
): Record<string, string> {
const { [`${CUSTOM_PREFIX}${tag}`]: _, ...rest } = style
return rest
}
/**
* 风格键分类定义
*/