Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39cef82eff | ||
|
|
0493d900dc | ||
|
|
cccd1c5428 | ||
|
|
03cecab59b | ||
|
|
9af74408b8 | ||
|
|
5a6aba16a0 | ||
|
|
433bf88698 | ||
|
|
882e05955b | ||
|
|
55aaa26549 | ||
|
|
96285014b1 | ||
|
|
e650cea11c | ||
|
|
1a78744766 | ||
|
|
d611a2c142 | ||
|
|
b88f9aaf03 | ||
|
|
5be74e3ce1 | ||
|
|
8e3b576954 | ||
|
|
d30bee18a7 | ||
|
|
47b31b5c13 | ||
|
|
7cc472052f |
@@ -26,6 +26,18 @@
|
||||
<b><a href="#api">API</a></b>
|
||||
</p>
|
||||
|
||||
|
||||
> 🎈 **哔哩哔哩视频**:【七牛云 XEngineer 暑期实训营 | 2D 游戏素材 | 团体作品演示】 https://www.bilibili.com/video/BV1jAGo6DEbC
|
||||
|
||||
> 🔥 **在线体验**:http://47.121.181.112:10000/
|
||||
|
||||
> 详细架构说明见 [docs/_index.md](docs/_index.md)。
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
@@ -161,6 +173,8 @@ graph TD
|
||||
C --> D["三段式规范提示词<br/>【主题】【风格】【技术】"]
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 预设风格键
|
||||
|
||||
| 分类 | 键名 | 可选值示例 |
|
||||
@@ -172,6 +186,8 @@ graph TD
|
||||
| 光照 | `lighting` | bright, dim, dramatic, ambient, neon |
|
||||
| 情绪 | `mood` | cheerful, dark, mysterious, epic, calm |
|
||||
|
||||
|
||||
|
||||
## API
|
||||
|
||||
接口统一前缀 `/api/v1/`,统一响应格式:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -78,6 +78,7 @@ func main() {
|
||||
v1Auth.POST("/generate", handler.Generate) // 素材生成管线
|
||||
v1Auth.GET("/tasks/:taskId", handler.GetTask) // 查询任务
|
||||
v1Auth.GET("/tasks/:taskId/assets", handler.GetAssets) // 查询任务素材
|
||||
v1Auth.GET("/assets", handler.GetRecentAssets) // 首页最近素材
|
||||
// 图片编辑
|
||||
v1Auth.POST("/images/edit", handler.EditImage) // 图片编辑
|
||||
}
|
||||
|
||||
@@ -306,6 +306,78 @@ func getTaskDBID(ctx context.Context, taskID string) uint {
|
||||
return task.ID
|
||||
}
|
||||
|
||||
// RecentAssetItem 首页素材展示项。
|
||||
type RecentAssetItem struct {
|
||||
Key string `json:"key"`
|
||||
URL string `json:"url"`
|
||||
Format string `json:"format"`
|
||||
Prompt string `json:"prompt"`
|
||||
AssetType string `json:"assetType"`
|
||||
MetaType string `json:"metaType"`
|
||||
TaskID string `json:"taskId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
ProjectName string `json:"projectName"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// GetRecentAssets 获取最近生成的素材列表(已完成任务的全部素材)。
|
||||
func GetRecentAssets(c *gin.Context) {
|
||||
limit := 60
|
||||
|
||||
var dbRows []struct {
|
||||
Key string
|
||||
URL string
|
||||
Format string
|
||||
Prompt string
|
||||
AssetType string
|
||||
MetaType string
|
||||
TaskID string
|
||||
ProjectID uint
|
||||
ProjectName string
|
||||
Width int
|
||||
Height int
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
db.GetDB().WithContext(c.Request.Context()).
|
||||
Raw(`SELECT a.key, a.url, a.format, t.prompt, t.asset_type as asset_type,
|
||||
COALESCE(json_extract(a.metadata, '$.type'), 'frame') as meta_type,
|
||||
t.external_id as task_id, t.project_id,
|
||||
p.name as project_name,
|
||||
CAST(COALESCE(json_extract(t.metadata, '$.frameWidth'), '0') AS INTEGER) as width,
|
||||
CAST(COALESCE(json_extract(t.metadata, '$.frameHeight'), '0') AS INTEGER) as height,
|
||||
t.created_at as created_at
|
||||
FROM assets a
|
||||
INNER JOIN tasks t ON a.task_id = t.id
|
||||
INNER JOIN projects p ON t.project_id = p.id
|
||||
WHERE t.status = 'completed'
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ?`, limit).
|
||||
Scan(&dbRows)
|
||||
|
||||
result := make([]RecentAssetItem, len(dbRows))
|
||||
for i, r := range dbRows {
|
||||
result[i] = RecentAssetItem{
|
||||
Key: r.Key,
|
||||
URL: storageSvc.GetSignedURL(r.Key),
|
||||
Format: r.Format,
|
||||
Prompt: r.Prompt,
|
||||
AssetType: r.AssetType,
|
||||
MetaType: r.MetaType,
|
||||
TaskID: r.TaskID,
|
||||
ProjectID: fmt.Sprintf("%d", r.ProjectID),
|
||||
ProjectName: r.ProjectName,
|
||||
Width: r.Width,
|
||||
Height: r.Height,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.OK(result))
|
||||
}
|
||||
|
||||
// toTaskResponse 转换任务响应格式。
|
||||
func toTaskResponse(task *model.Task) model.TaskResponse {
|
||||
return model.TaskResponse{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<title>gen2d</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -1,4 +1,9 @@
|
||||
import { post, get } from './client'
|
||||
import type { RecentAssetItem } from './types'
|
||||
|
||||
export async function getRecentAssets(): Promise<RecentAssetItem[]> {
|
||||
return get<RecentAssetItem[]>('/api/v1/assets')
|
||||
}
|
||||
import type {
|
||||
Asset,
|
||||
AssetsResponse,
|
||||
|
||||
@@ -98,6 +98,22 @@ export interface Asset {
|
||||
index: number
|
||||
}
|
||||
|
||||
// 首页最近素材
|
||||
export interface RecentAssetItem {
|
||||
key: string
|
||||
url: string
|
||||
format: string
|
||||
prompt: string
|
||||
assetType: string
|
||||
metaType: string
|
||||
taskId: string
|
||||
projectId: string
|
||||
projectName: string
|
||||
width: number
|
||||
height: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 生成请求 — 对应 POST /api/v1/generate
|
||||
export interface GenerateRequest {
|
||||
projectId: string
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { getRecentAssets } from '../api/generate'
|
||||
import type { RecentAssetItem } from '../api/types'
|
||||
|
||||
const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||
sprite: '精灵',
|
||||
background: '背景',
|
||||
ui: 'UI',
|
||||
animation: '动画',
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'spritesheet', label: '精灵表' },
|
||||
{ key: 'frame', label: '帧' },
|
||||
{ key: 'animation', label: '帧动画' },
|
||||
{ key: 'tileset', label: '场景瓦片' },
|
||||
{ key: 'background', label: '背景' },
|
||||
{ key: 'ui', label: 'UI' },
|
||||
]
|
||||
|
||||
function matchCategory(item: RecentAssetItem, cat: string): boolean {
|
||||
switch (cat) {
|
||||
case 'all':
|
||||
return true
|
||||
case 'spritesheet':
|
||||
return item.metaType === 'spritesheet'
|
||||
case 'frame':
|
||||
return item.metaType === 'frame'
|
||||
case 'animation':
|
||||
return item.metaType === 'preview' || item.format === 'gif'
|
||||
case 'tileset':
|
||||
return item.assetType === 'sprite' && item.metaType === 'spritesheet'
|
||||
case 'background':
|
||||
return item.assetType === 'background'
|
||||
case 'ui':
|
||||
return item.assetType === 'ui'
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function DetailModal({ item, onClose }: { item: RecentAssetItem; onClose: () => void }) {
|
||||
const downloadUrl = `/api/v1/assets/download?key=${encodeURIComponent(item.key)}`
|
||||
|
||||
return createPortal(
|
||||
<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.6)' }}
|
||||
/>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
maxWidth: 640,
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
padding: 24,
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: 20,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'var(--bg-input)',
|
||||
borderRadius: 'var(--radius)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 20,
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.prompt}
|
||||
style={{ maxWidth: '100%', maxHeight: 400, objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
<Row label="提示词" value={item.prompt} />
|
||||
<Row label="工程" value={item.projectName} />
|
||||
<Row label="素材类型" value={`${ASSET_TYPE_LABELS[item.assetType] || item.assetType} / ${item.metaType}`} />
|
||||
<Row label="分辨率" value={item.width > 0 ? `${item.width} x ${item.height}` : '—'} />
|
||||
<Row label="格式" value={item.format} />
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
className="btn-primary"
|
||||
style={{ textDecoration: 'none', padding: '8px 24px', fontSize: 14 }}
|
||||
download
|
||||
>
|
||||
下载
|
||||
</a>
|
||||
<button className="btn-secondary" onClick={onClose} style={{ padding: '8px 24px', fontSize: 14 }}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ padding: '6px 12px 6px 0', color: 'var(--text-secondary)', whiteSpace: 'nowrap', verticalAlign: 'top', width: 80 }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ padding: '6px 0', wordBreak: 'break-all' }}>{value}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AssetGallery() {
|
||||
const [assets, setAssets] = useState<RecentAssetItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeCat, setActiveCat] = useState('all')
|
||||
const [detailItem, setDetailItem] = useState<RecentAssetItem | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getRecentAssets()
|
||||
.then(setAssets)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(
|
||||
() => assets.filter(item => matchCategory(item, activeCat)),
|
||||
[assets, activeCat]
|
||||
)
|
||||
|
||||
if (loading) return null
|
||||
if (assets.length === 0) return null
|
||||
|
||||
return (
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h2 style={{ fontSize: 18, marginBottom: 12 }}>最近生成</h2>
|
||||
|
||||
{/* 分类标签 */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat.key}
|
||||
type="button"
|
||||
onClick={() => setActiveCat(cat.key)}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: '4px 14px',
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${activeCat === cat.key ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: activeCat === cat.key ? 'var(--accent-dim)' : 'transparent',
|
||||
color: activeCat === cat.key ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontWeight: activeCat === cat.key ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 素材网格 */}
|
||||
{filtered.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>暂无此类素材</p>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{filtered.map((item, i) => (
|
||||
<div
|
||||
key={`${item.taskId}-${i}`}
|
||||
className="card"
|
||||
style={{ padding: 8, cursor: 'pointer' }}
|
||||
onClick={() => setDetailItem(item)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 'var(--radius)',
|
||||
background: 'var(--bg-input)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.prompt}
|
||||
loading="lazy"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--accent-dim)',
|
||||
color: 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{ASSET_TYPE_LABELS[item.assetType] || item.assetType}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: 4,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.prompt}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{detailItem && (
|
||||
<DetailModal item={detailItem} onClose={() => setDetailItem(null)} />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -27,13 +27,16 @@ export default function Layout() {
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: 'var(--accent)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
gen2d
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="gen2d"
|
||||
style={{ height: 38, width: 64 }}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useProjectListStore } from '../stores/projectList'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import ProjectCard from '../components/ProjectCard'
|
||||
import AssetGallery from '../components/AssetGallery'
|
||||
import CreateProjectModal from '../components/CreateProjectModal'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import Skeleton from '../components/Skeleton'
|
||||
@@ -39,6 +40,8 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="container page-enter" style={{ paddingTop: 32, paddingBottom: 32 }}>
|
||||
<AssetGallery />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 22 }}>我的工程</h1>
|
||||
<button className="btn-primary" onClick={() => setModalOpen(true)}>
|
||||
|
||||
Reference in New Issue
Block a user