!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` 的四阶段管线,支持质检重试与降级输出:
@@ -161,6 +163,8 @@ graph TD
C --> D["三段式规范提示词<br/>【主题】【风格】【技术】"]
```
![后端架构](assets/backend-architecture.png)
### 预设风格键
| 分类 | 键名 | 可选值示例 |
@@ -172,6 +176,10 @@ graph TD
| 光照 | `lighting` | bright, dim, dramatic, ambient, neon |
| 情绪 | `mood` | cheerful, dark, mysterious, epic, calm |
![前端架构](assets/frontend-architecture.png)
详细架构说明见 [docs/_index.md](docs/_index.md)。
## 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

+42 -26
View File
@@ -308,14 +308,18 @@ func getTaskDBID(ctx context.Context, taskID string) uint {
// 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"` // frame / spritesheet / preview
TaskID string `json:"taskId"`
CreatedAt string `json:"createdAt"`
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 获取最近生成的素材列表(已完成任务的全部素材)。
@@ -323,23 +327,31 @@ 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
CreatedAt string
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, 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
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).
@@ -348,14 +360,18 @@ func GetRecentAssets(c *gin.Context) {
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,
CreatedAt: r.CreatedAt,
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,
}
}
+4
View File
@@ -107,6 +107,10 @@ export interface RecentAssetItem {
assetType: string
metaType: string
taskId: string
projectId: string
projectName: string
width: number
height: number
createdAt: string
}
+116 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getRecentAssets } from '../api/generate'
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() {
const [assets, setAssets] = useState<RecentAssetItem[]>([])
const [loading, setLoading] = useState(true)
const [activeCat, setActiveCat] = useState('all')
const [detailItem, setDetailItem] = useState<RecentAssetItem | null>(null)
useEffect(() => {
getRecentAssets()
@@ -99,7 +208,8 @@ export default function AssetGallery() {
<div
key={`${item.taskId}-${i}`}
className="card"
style={{ padding: 8 }}
style={{ padding: 8, cursor: 'pointer' }}
onClick={() => setDetailItem(item)}
>
<div
style={{
@@ -153,6 +263,11 @@ export default function AssetGallery() {
))}
</div>
)}
{/* 详情弹窗 */}
{detailItem && (
<DetailModal item={detailItem} onClose={() => setDetailItem(null)} />
)}
</section>
)
}