!79 fix: 详情弹窗使用 Portal 渲染到 body,确保始终居中

Merge pull request !79 from 郭永昊/fix/tag-labels-and-click
This commit is contained in:
2026-05-25 13:35:49 +00:00
committed by Gitee
7 changed files with 170 additions and 27 deletions
+8
View File
@@ -133,6 +133,8 @@ npm run dev
## 架构 ## 架构
![全局架构](assets/global-architecture.png)
### 生成管线 ### 生成管线
基于 Eino `compose.Graph` 的四阶段管线,支持质检重试与降级输出: 基于 Eino `compose.Graph` 的四阶段管线,支持质检重试与降级输出:
@@ -161,6 +163,8 @@ graph TD
C --> D["三段式规范提示词<br/>【主题】【风格】【技术】"] C --> D["三段式规范提示词<br/>【主题】【风格】【技术】"]
``` ```
![后端架构](assets/backend-architecture.png)
### 预设风格键 ### 预设风格键
| 分类 | 键名 | 可选值示例 | | 分类 | 键名 | 可选值示例 |
@@ -172,6 +176,10 @@ 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 |
![前端架构](assets/frontend-architecture.png)
详细架构说明见 [docs/_index.md](docs/_index.md)。
## 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

+18 -2
View File
@@ -313,8 +313,12 @@ type RecentAssetItem struct {
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"`
ProjectID string `json:"projectId"`
ProjectName string `json:"projectName"`
Width int `json:"width"`
Height int `json:"height"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -331,15 +335,23 @@ func GetRecentAssets(c *gin.Context) {
MetaType string MetaType string
TaskID string TaskID string
ProjectID uint ProjectID uint
ProjectName string
Width int
Height int
CreatedAt string 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).
@@ -355,6 +367,10 @@ func GetRecentAssets(c *gin.Context) {
AssetType: r.AssetType, AssetType: r.AssetType,
MetaType: r.MetaType, MetaType: r.MetaType,
TaskID: r.TaskID, TaskID: r.TaskID,
ProjectID: fmt.Sprintf("%d", r.ProjectID),
ProjectName: r.ProjectName,
Width: r.Width,
Height: r.Height,
CreatedAt: r.CreatedAt, 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
} }
+116 -1
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'
@@ -37,10 +38,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 +208,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 +263,11 @@ export default function AssetGallery() {
))} ))}
</div> </div>
)} )}
{/* 详情弹窗 */}
{detailItem && (
<DetailModal item={detailItem} onClose={() => setDetailItem(null)} />
)}
</section> </section>
) )
} }