19 Commits
Author SHA1 Message Date
wonder 39cef82eff docs: 补充哔哩哔哩视频链接
Signed-off-by: 何朝晖 <hezhaohui0807@163.com>
2026-05-25 15:39:34 +00:00
wonder 0493d900dc !81 docs: 优化 README
Merge pull request !81 from 何朝晖/N/A
2026-05-25 13:55:05 +00:00
wonder cccd1c5428 docs: 优化 README
Signed-off-by: 何朝晖 <hezhaohui0807@163.com>
2026-05-25 13:54:43 +00:00
Gmarker689 03cecab59b !80 fix: 将帧动画与单独帧分开为独立分类
Merge pull request !80 from 郭永昊/fix/tag-labels-and-click
2026-05-25 13:37:12 +00:00
Gmarker689 9af74408b8 fix: 将帧动画与单独帧分开为独立分类
- 新增「帧动画」分类:匹配 metaType=preview 或 format=gif
- 「帧」分类仅匹配 metaType=frame(静态单帧)
2026-05-25 21:36:04 +08:00
wonder 5a6aba16a0 !79 fix: 详情弹窗使用 Portal 渲染到 body,确保始终居中
Merge pull request !79 from 郭永昊/fix/tag-labels-and-click
2026-05-25 13:35:49 +00:00
wonder 433bf88698 Merge branch 'master' of gitee.com:hezhaohui123/gen2d into fix/tag-labels-and-click
Signed-off-by: 何朝晖 <hezhaohui0807@163.com>
2026-05-25 13:35:26 +00:00
Gmarker689 882e05955b fix: 详情弹窗使用 Portal 渲染到 body,确保始终居中
父级 .page-enter 动画中的 transform 创建了新的 CSS containing block,
导致 position:fixed 相对父级而非视口定位。改用 createPortal 渲染到
document.body 绕过此限制。
2026-05-25 21:31:27 +08:00
Gmarker689 55aaa26549 docs: 在 README 架构章节添加全局/后端/前端架构图 2026-05-25 21:30:35 +08:00
Gmarker689 96285014b1 feat: 首页素材点击查看详情,支持下载
- 后端 GetRecentAssets 增加 projectName/width/height/projectId 字段
- 前端 AssetGallery 点击图片弹出详情弹窗:提示词/工程/分辨率/格式
- 详情弹窗提供下载按钮,走 /api/v1/assets/download?key= 接口
2026-05-25 21:28:54 +08:00
Gmarker689 e650cea11c !77 feat: 首页素材画廊增加分类筛选标签
Merge pull request !77 from 郭永昊/fix/tag-labels-and-click
2026-05-25 13:08:38 +00:00
Gmarker689 1a78744766 feat: 首页素材画廊增加分类筛选标签
- 后端 SQL 增加 json_extract 提取素材 metaType (frame/spritesheet/preview)
- 前端 AssetGallery 新增分类标签栏:全部/精灵表/帧/场景瓦片/背景/UI
- 按 metaType + assetType 组合过滤展示
2026-05-25 21:07:15 +08:00
wonder d611a2c142 !76 docs: 添加全局/后端/前端架构图生成提示词
Merge pull request !76 from 何朝晖/docs/README
2026-05-25 13:04:39 +00:00
Gmarker689 b88f9aaf03 feat: 首页展示最近生成的素材画廊
- 后端新增 GET /api/v1/assets 接口,返回已完成任务的素材列表(按创建时间倒序)
- 前端新增 AssetGallery 组件,网格展示素材缩略图、类型标签和提示词
- HomePage 中工程列表上方展示最近生成素材
2026-05-25 21:02:03 +08:00
Gmarker689 5be74e3ce1 feat: 左上角品牌图标更换为自定义 logo
- 文本 "gen2d" 替换为 logo.png 图片
- 同时更新浏览器 favicon
2026-05-25 20:52:06 +08:00
Gmarker689 8e3b576954 feat: 更换网页图标为自定义 favicon 2026-05-25 20:49:47 +08:00
wonder d30bee18a7 !75 !74 fix: 任务侧边栏点击详情后"任务不存在"问题
Merge pull request !75 from 何朝晖/develop
2026-05-25 12:30:29 +00:00
Gmarker689 47b31b5c13 !71 !70 fix: 自定义标签融入原生标签栏,点击切换而非消失
Merge pull request !71 from 郭永昊/develop
2026-05-25 12:06:51 +00:00
Gmarker689 7cc472052f !68 !67 fix: 修复管线context取消、添加FIFO任务队列、移除重复提示词优化
Merge pull request !68 from 何朝晖/develop
2026-05-25 11:13:54 +00:00
13 changed files with 397 additions and 4 deletions
+16
View File
@@ -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)。
![全局架构](assets/global-architecture.png)
![后端架构](assets/backend-architecture.png)
![前端架构](assets/frontend-architecture.png)
---
## 目录
@@ -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

+1
View File
@@ -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) // 图片编辑
}
+72
View File
@@ -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{
+1
View File
@@ -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

+5
View File
@@ -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,
+16
View File
@@ -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
+276
View File
@@ -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>
)
}
+7 -4
View File
@@ -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' }}>
+3
View File
@@ -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)}>