!79 fix: 详情弹窗使用 Portal 渲染到 body,确保始终居中
Merge pull request !79 from 郭永昊/fix/tag-labels-and-click
This commit is contained in:
@@ -133,6 +133,8 @@ npm run dev
|
|||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### 生成管线
|
### 生成管线
|
||||||
|
|
||||||
基于 Eino `compose.Graph` 的四阶段管线,支持质检重试与降级输出:
|
基于 Eino `compose.Graph` 的四阶段管线,支持质检重试与降级输出:
|
||||||
@@ -161,6 +163,8 @@ graph TD
|
|||||||
C --> D["三段式规范提示词<br/>【主题】【风格】【技术】"]
|
C --> D["三段式规范提示词<br/>【主题】【风格】【技术】"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### 预设风格键
|
### 预设风格键
|
||||||
|
|
||||||
| 分类 | 键名 | 可选值示例 |
|
| 分类 | 键名 | 可选值示例 |
|
||||||
@@ -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 |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
详细架构说明见 [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 |
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user