10 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
7 changed files with 182 additions and 28 deletions
+16
View File
@@ -26,6 +26,18 @@
<b><a href="#api">API</a></b> <b><a href="#api">API</a></b>
</p> </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/>【主题】【风格】【技术】"] C --> D["三段式规范提示词<br/>【主题】【风格】【技术】"]
``` ```
### 预设风格键 ### 预设风格键
| 分类 | 键名 | 可选值示例 | | 分类 | 键名 | 可选值示例 |
@@ -172,6 +186,8 @@ graph TD
| 光照 | `lighting` | bright, dim, dramatic, ambient, neon | | 光照 | `lighting` | bright, dim, dramatic, ambient, neon |
| 情绪 | `mood` | cheerful, dark, mysterious, epic, calm | | 情绪 | `mood` | cheerful, dark, mysterious, epic, calm |
## API ## API
接口统一前缀 `/api/v1/`,统一响应格式: 接口统一前缀 `/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

+42 -26
View File
@@ -308,14 +308,18 @@ func getTaskDBID(ctx context.Context, taskID string) uint {
// RecentAssetItem 首页素材展示项。 // RecentAssetItem 首页素材展示项。
type RecentAssetItem struct { type RecentAssetItem struct {
Key string `json:"key"` Key string `json:"key"`
URL string `json:"url"` URL string `json:"url"`
Format string `json:"format"` Format string `json:"format"`
Prompt string `json:"prompt"` Prompt string `json:"prompt"`
AssetType string `json:"assetType"` AssetType string `json:"assetType"`
MetaType string `json:"metaType"` // frame / spritesheet / preview MetaType string `json:"metaType"`
TaskID string `json:"taskId"` TaskID string `json:"taskId"`
CreatedAt string `json:"createdAt"` ProjectID string `json:"projectId"`
ProjectName string `json:"projectName"`
Width int `json:"width"`
Height int `json:"height"`
CreatedAt string `json:"createdAt"`
} }
// GetRecentAssets 获取最近生成的素材列表(已完成任务的全部素材)。 // GetRecentAssets 获取最近生成的素材列表(已完成任务的全部素材)。
@@ -323,23 +327,31 @@ func GetRecentAssets(c *gin.Context) {
limit := 60 limit := 60
var dbRows []struct { var dbRows []struct {
Key string Key string
URL string URL string
Format string Format string
Prompt string Prompt string
AssetType string AssetType string
MetaType string MetaType string
TaskID string TaskID string
ProjectID uint ProjectID uint
CreatedAt string ProjectName string
Width int
Height int
CreatedAt string
} }
db.GetDB().WithContext(c.Request.Context()). db.GetDB().WithContext(c.Request.Context()).
Raw(`SELECT a.key, a.url, a.format, t.prompt, t.asset_type as asset_type, 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, COALESCE(json_extract(a.metadata, '$.type'), 'frame') as meta_type,
t.external_id as task_id, t.project_id, t.created_at as created_at 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 FROM assets a
INNER JOIN tasks t ON a.task_id = t.id INNER JOIN tasks t ON a.task_id = t.id
INNER JOIN projects p ON t.project_id = p.id
WHERE t.status = 'completed' WHERE t.status = 'completed'
ORDER BY a.created_at DESC ORDER BY a.created_at DESC
LIMIT ?`, limit). LIMIT ?`, limit).
@@ -348,14 +360,18 @@ func GetRecentAssets(c *gin.Context) {
result := make([]RecentAssetItem, len(dbRows)) result := make([]RecentAssetItem, len(dbRows))
for i, r := range dbRows { for i, r := range dbRows {
result[i] = RecentAssetItem{ result[i] = RecentAssetItem{
Key: r.Key, Key: r.Key,
URL: storageSvc.GetSignedURL(r.Key), URL: storageSvc.GetSignedURL(r.Key),
Format: r.Format, Format: r.Format,
Prompt: r.Prompt, Prompt: r.Prompt,
AssetType: r.AssetType, AssetType: r.AssetType,
MetaType: r.MetaType, MetaType: r.MetaType,
TaskID: r.TaskID, TaskID: r.TaskID,
CreatedAt: r.CreatedAt, ProjectID: fmt.Sprintf("%d", r.ProjectID),
ProjectName: r.ProjectName,
Width: r.Width,
Height: r.Height,
CreatedAt: r.CreatedAt,
} }
} }
+4
View File
@@ -107,6 +107,10 @@ export interface RecentAssetItem {
assetType: string assetType: string
metaType: string metaType: string
taskId: string taskId: string
projectId: string
projectName: string
width: number
height: number
createdAt: string createdAt: string
} }
+120 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getRecentAssets } from '../api/generate' import { getRecentAssets } from '../api/generate'
import type { RecentAssetItem } from '../api/types' import type { RecentAssetItem } from '../api/types'
@@ -13,6 +14,7 @@ const CATEGORIES = [
{ key: 'all', label: '全部' }, { key: 'all', label: '全部' },
{ key: 'spritesheet', label: '精灵表' }, { key: 'spritesheet', label: '精灵表' },
{ key: 'frame', label: '帧' }, { key: 'frame', label: '帧' },
{ key: 'animation', label: '帧动画' },
{ key: 'tileset', label: '场景瓦片' }, { key: 'tileset', label: '场景瓦片' },
{ key: 'background', label: '背景' }, { key: 'background', label: '背景' },
{ key: 'ui', label: 'UI' }, { key: 'ui', label: 'UI' },
@@ -25,7 +27,9 @@ function matchCategory(item: RecentAssetItem, cat: string): boolean {
case 'spritesheet': case 'spritesheet':
return item.metaType === 'spritesheet' return item.metaType === 'spritesheet'
case 'frame': case 'frame':
return item.metaType === 'frame' || item.metaType === 'preview' || item.format === 'gif' return item.metaType === 'frame'
case 'animation':
return item.metaType === 'preview' || item.format === 'gif'
case 'tileset': case 'tileset':
return item.assetType === 'sprite' && item.metaType === 'spritesheet' return item.assetType === 'sprite' && item.metaType === 'spritesheet'
case 'background': case 'background':
@@ -37,10 +41,118 @@ function matchCategory(item: RecentAssetItem, cat: string): boolean {
} }
} }
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() { export default function AssetGallery() {
const [assets, setAssets] = useState<RecentAssetItem[]>([]) const [assets, setAssets] = useState<RecentAssetItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeCat, setActiveCat] = useState('all') const [activeCat, setActiveCat] = useState('all')
const [detailItem, setDetailItem] = useState<RecentAssetItem | null>(null)
useEffect(() => { useEffect(() => {
getRecentAssets() getRecentAssets()
@@ -99,7 +211,8 @@ export default function AssetGallery() {
<div <div
key={`${item.taskId}-${i}`} key={`${item.taskId}-${i}`}
className="card" className="card"
style={{ padding: 8 }} style={{ padding: 8, cursor: 'pointer' }}
onClick={() => setDetailItem(item)}
> >
<div <div
style={{ style={{
@@ -153,6 +266,11 @@ export default function AssetGallery() {
))} ))}
</div> </div>
)} )}
{/* 详情弹窗 */}
{detailItem && (
<DetailModal item={detailItem} onClose={() => setDetailItem(null)} />
)}
</section> </section>
) )
} }