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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user