!69 feat: 结果页添加帧预览、GIF动画和导出选项
Merge pull request !69 from 郭永昊/feature/result-preview-export
This commit is contained in:
@@ -136,7 +136,7 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe
|
|||||||
updateTaskInDB(ctx, taskID, "saving", "format_adapter", "", 90)
|
updateTaskInDB(ctx, taskID, "saving", "format_adapter", "", 90)
|
||||||
|
|
||||||
// 上传素材并保存到数据库
|
// 上传素材并保存到数据库
|
||||||
var lastCDNURL string
|
var lastGIFCDNURL string
|
||||||
for i, a := range output.Assets {
|
for i, a := range output.Assets {
|
||||||
key := fmt.Sprintf("generation/%s/%s/%d.%s", projectID, taskID, i, a.Format)
|
key := fmt.Sprintf("generation/%s/%s/%d.%s", projectID, taskID, i, a.Format)
|
||||||
cdnURL, err := storageSvc.Upload(ctx, key, a.Data)
|
cdnURL, err := storageSvc.Upload(ctx, key, a.Data)
|
||||||
@@ -145,13 +145,15 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe
|
|||||||
updateTaskInDB(ctx, taskID, "failed", "", "上传素材失败: "+err.Error(), 0)
|
updateTaskInDB(ctx, taskID, "failed", "", "上传素材失败: "+err.Error(), 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastCDNURL = cdnURL
|
|
||||||
|
|
||||||
var assetMeta map[string]interface{}
|
var assetMeta map[string]interface{}
|
||||||
if a.Format == "gif" {
|
if a.Format == "gif" {
|
||||||
assetMeta = map[string]interface{}{"index": i, "type": "preview"}
|
assetMeta = map[string]interface{}{"index": i, "type": "preview"}
|
||||||
|
lastGIFCDNURL = cdnURL
|
||||||
|
} else if strings.Contains(a.URL, "spritesheet") {
|
||||||
|
assetMeta = map[string]interface{}{"index": i, "type": "spritesheet"}
|
||||||
} else {
|
} else {
|
||||||
assetMeta = map[string]interface{}{"index": i}
|
assetMeta = map[string]interface{}{"index": i, "type": "frame"}
|
||||||
}
|
}
|
||||||
metadataJSON, _ := json.Marshal(assetMeta)
|
metadataJSON, _ := json.Marshal(assetMeta)
|
||||||
|
|
||||||
@@ -169,8 +171,8 @@ func runPipelineBg(ctx context.Context, projectID, taskID string, req GenerateRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GIF URL 替换为实际上传后的 CDN 地址
|
// GIF URL 替换为实际上传后的 CDN 地址
|
||||||
if output.Metadata.GIFURL != "" && lastCDNURL != "" {
|
if output.Metadata.GIFURL != "" && lastGIFCDNURL != "" {
|
||||||
output.Metadata.GIFURL = lastCDNURL
|
output.Metadata.GIFURL = lastGIFCDNURL
|
||||||
}
|
}
|
||||||
|
|
||||||
var fullMetadata string
|
var fullMetadata string
|
||||||
@@ -235,6 +237,7 @@ func GetAssets(c *gin.Context) {
|
|||||||
Key: a.Key,
|
Key: a.Key,
|
||||||
URL: storageSvc.GetSignedURL(a.Key),
|
URL: storageSvc.GetSignedURL(a.Key),
|
||||||
Format: a.Format,
|
Format: a.Format,
|
||||||
|
Metadata: a.Metadata,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,18 +19,31 @@ export async function getTask(taskId: string): Promise<Task> {
|
|||||||
|
|
||||||
export async function getAssets(taskId: string): Promise<Asset[]> {
|
export async function getAssets(taskId: string): Promise<Asset[]> {
|
||||||
const resp = await get<AssetsResponse>(`/api/v1/tasks/${taskId}/assets`)
|
const resp = await get<AssetsResponse>(`/api/v1/tasks/${taskId}/assets`)
|
||||||
return resp.assets.map((a, i) => ({
|
return resp.assets.map((a, i) => {
|
||||||
|
let assetType: Asset['assetType'] = 'frame'
|
||||||
|
let index = i
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(a.metadata)
|
||||||
|
if (meta.type) assetType = meta.type
|
||||||
|
if (typeof meta.index === 'number') index = meta.index
|
||||||
|
} catch { /* use defaults */ }
|
||||||
|
|
||||||
|
return {
|
||||||
id: `asset-${i}`,
|
id: `asset-${i}`,
|
||||||
key: a.key,
|
key: a.key,
|
||||||
url: `/api/v1/assets/download?key=${encodeURIComponent(a.key)}`,
|
url: `/api/v1/assets/download?key=${encodeURIComponent(a.key)}`,
|
||||||
format: a.format,
|
format: a.format,
|
||||||
width: resp.metadata.frameWidth,
|
width: resp.metadata.frameWidth,
|
||||||
height: resp.metadata.frameHeight,
|
height: resp.metadata.frameHeight,
|
||||||
|
assetType,
|
||||||
|
index,
|
||||||
metadata: {
|
metadata: {
|
||||||
frameWidth: resp.metadata.frameWidth,
|
frameWidth: resp.metadata.frameWidth,
|
||||||
frameHeight: resp.metadata.frameHeight,
|
frameHeight: resp.metadata.frameHeight,
|
||||||
frameCount: resp.metadata.frameCount,
|
frameCount: resp.metadata.frameCount,
|
||||||
directions: resp.metadata.directions,
|
directions: resp.metadata.directions,
|
||||||
|
gifUrl: resp.metadata.gifUrl,
|
||||||
},
|
},
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ const MOCK_ASSETS: Asset[] = [
|
|||||||
format: 'png',
|
format: 'png',
|
||||||
width: 256,
|
width: 256,
|
||||||
height: 256,
|
height: 256,
|
||||||
|
assetType: 'frame',
|
||||||
|
index: 0,
|
||||||
metadata: {
|
metadata: {
|
||||||
frameWidth: 64,
|
frameWidth: 64,
|
||||||
frameHeight: 64,
|
frameHeight: 64,
|
||||||
|
|||||||
@@ -87,12 +87,15 @@ export interface Asset {
|
|||||||
format: string
|
format: string
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
|
assetType: 'frame' | 'preview' | 'spritesheet'
|
||||||
metadata: {
|
metadata: {
|
||||||
frameWidth?: number
|
frameWidth?: number
|
||||||
frameHeight?: number
|
frameHeight?: number
|
||||||
frameCount?: number
|
frameCount?: number
|
||||||
directions?: number
|
directions?: number
|
||||||
|
gifUrl?: string
|
||||||
}
|
}
|
||||||
|
index: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成请求 — 对应 POST /api/v1/generate
|
// 生成请求 — 对应 POST /api/v1/generate
|
||||||
@@ -121,6 +124,7 @@ export interface AssetsResponse {
|
|||||||
key: string
|
key: string
|
||||||
url: string
|
url: string
|
||||||
format: string
|
format: string
|
||||||
|
metadata: string // JSON: {"index":0,"type":"frame"|"preview"|"spritesheet"}
|
||||||
}[]
|
}[]
|
||||||
metadata: {
|
metadata: {
|
||||||
frameWidth: number
|
frameWidth: number
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import type { Asset } from '../api/types'
|
import type { Asset } from '../api/types'
|
||||||
import EmptyState from './EmptyState'
|
import EmptyState from './EmptyState'
|
||||||
|
|
||||||
@@ -6,26 +7,151 @@ interface AssetPreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AssetPreview({ assets }: AssetPreviewProps) {
|
export default function AssetPreview({ assets }: AssetPreviewProps) {
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
|
const [exportMenuOpen, setExportMenuOpen] = useState(false)
|
||||||
|
|
||||||
if (assets.length === 0) {
|
if (assets.length === 0) {
|
||||||
return <EmptyState title="暂无素材" description="生成完成后,素材将在这里展示" />
|
return <EmptyState title="暂无素材" description="生成完成后,素材将在这里展示" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const frames = assets.filter(a => a.assetType === 'frame')
|
||||||
|
const spritesheet = assets.find(a => a.assetType === 'spritesheet')
|
||||||
|
const preview = assets.find(a => a.assetType === 'preview')
|
||||||
|
const gifUrl = preview?.url || assets[0]?.metadata.gifUrl
|
||||||
|
const hasAnimation = frames.length > 1
|
||||||
|
|
||||||
|
const meta = assets[0]?.metadata
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{assets.map(asset => (
|
{/* 工具栏 */}
|
||||||
<div
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
key={asset.id}
|
{hasAnimation && gifUrl && (
|
||||||
className="card"
|
<button
|
||||||
style={{ padding: 16 }}
|
className="btn-secondary"
|
||||||
|
onClick={() => setPreviewOpen(true)}
|
||||||
|
style={{ padding: '8px 16px', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}
|
||||||
|
title="查看动作帧 GIF 预览"
|
||||||
>
|
>
|
||||||
{/* 图片预览 */}
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M3 3v10l10-5z" />
|
||||||
|
</svg>
|
||||||
|
查看预览
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
(动作帧)
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 导出菜单 */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => setExportMenuOpen(!exportMenuOpen)}
|
||||||
|
style={{ padding: '8px 16px', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 1L5 5h2v5h2V5h2L8 1zM2 9v3h12V9h-2v1H4V9H2z" />
|
||||||
|
</svg>
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
{exportMenuOpen && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9 }}
|
||||||
|
onClick={() => setExportMenuOpen(false)}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'absolute',
|
||||||
display: 'inline-block',
|
top: '100%',
|
||||||
marginBottom: 12,
|
right: 0,
|
||||||
|
marginTop: 4,
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
zIndex: 10,
|
||||||
|
minWidth: 160,
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{spritesheet && (
|
||||||
|
<button
|
||||||
|
onClick={() => { downloadAsset(spritesheet); setExportMenuOpen(false) }}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', textAlign: 'left',
|
||||||
|
padding: '10px 14px', fontSize: 13, background: 'none',
|
||||||
|
border: 'none', borderRadius: 0, color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-dim)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
|
||||||
|
>
|
||||||
|
整张导出(精灵表)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { downloadFrames(frames); setExportMenuOpen(false) }}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', textAlign: 'left',
|
||||||
|
padding: '10px 14px', fontSize: 13, background: 'none',
|
||||||
|
border: 'none', borderRadius: 0, color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-dim)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
|
||||||
|
>
|
||||||
|
自动切分导出({frames.length} 帧)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 帧网格 */}
|
||||||
|
{frames.length > 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{frames.map(frame => (
|
||||||
|
<div
|
||||||
|
key={frame.id}
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
padding: 8,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={frame.url}
|
||||||
|
alt={`帧 ${frame.index}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '1',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
|
||||||
|
#{frame.index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 非精灵表模式:直接展示所有非 preview 素材 */
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{assets.filter(a => a.assetType !== 'preview').map(asset => (
|
||||||
|
<div key={asset.id} className="card" style={{ padding: 16 }}>
|
||||||
<img
|
<img
|
||||||
src={asset.url}
|
src={asset.url}
|
||||||
alt={asset.id}
|
alt={asset.id}
|
||||||
@@ -36,39 +162,90 @@ export default function AssetPreview({ assets }: AssetPreviewProps) {
|
|||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* spritesheet 网格叠加 */}
|
|
||||||
{asset.metadata.frameWidth && asset.metadata.frameHeight && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(to right, rgba(233,69,96,0.3) 1px, transparent 1px),
|
|
||||||
linear-gradient(to bottom, rgba(233,69,96,0.3) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
backgroundSize: `${asset.metadata.frameWidth}px ${asset.metadata.frameHeight}px`,
|
|
||||||
borderRadius: 'var(--radius)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 元数据 */}
|
|
||||||
<div style={{ display: 'flex', gap: 24, fontSize: 13, color: 'var(--text-secondary)' }}>
|
|
||||||
<span>尺寸: {asset.width} × {asset.height}</span>
|
|
||||||
{asset.metadata.frameWidth && (
|
|
||||||
<span>帧大小: {asset.metadata.frameWidth} × {asset.metadata.frameHeight}</span>
|
|
||||||
)}
|
|
||||||
{asset.metadata.frameCount && <span>帧数: {asset.metadata.frameCount}</span>}
|
|
||||||
{asset.metadata.directions && <span>方向: {asset.metadata.directions}</span>}
|
|
||||||
<span>格式: {asset.format}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 元数据 */}
|
||||||
|
{meta && (
|
||||||
|
<div style={{ display: 'flex', gap: 24, fontSize: 13, color: 'var(--text-secondary)', flexWrap: 'wrap' }}>
|
||||||
|
{meta.frameWidth && meta.frameHeight && (
|
||||||
|
<span>帧大小: {meta.frameWidth} x {meta.frameHeight}</span>
|
||||||
|
)}
|
||||||
|
{meta.frameCount && <span>帧数: {meta.frameCount}</span>}
|
||||||
|
{meta.directions && <span>方向: {meta.directions}</span>}
|
||||||
|
{spritesheet && <span>精灵表: {spritesheet.width} x {spritesheet.height}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 预览弹窗 */}
|
||||||
|
{previewOpen && gifUrl && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.7)', zIndex: 100,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
onClick={() => setPreviewOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
maxWidth: '90vw', maxHeight: '90vh', padding: 24,
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', alignItems: 'center' }}>
|
||||||
|
<h3 style={{ fontSize: 16 }}>动作帧预览</h3>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => setPreviewOpen(false)}
|
||||||
|
style={{ padding: '4px 12px', fontSize: 16, lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={gifUrl}
|
||||||
|
alt="预览动画"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%', maxHeight: '70vh',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||||
|
{meta?.frameCount || frames.length} 帧
|
||||||
|
{meta?.directions ? ` x ${meta.directions} 方向` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadAsset(asset: Asset) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(asset.url)
|
||||||
|
const blob = await res.blob()
|
||||||
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = blobUrl
|
||||||
|
link.download = `spritesheet.${asset.format}`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(blobUrl)
|
||||||
|
} catch {
|
||||||
|
window.open(asset.url, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFrames(frames: Asset[]) {
|
||||||
|
for (const f of frames) {
|
||||||
|
await downloadAsset(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,26 +79,41 @@ export default function ResultPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (task.status === 'failed') {
|
||||||
|
return (
|
||||||
|
<div className="container page-enter" style={{ paddingTop: 40 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 24 }}>
|
||||||
|
<Link to={`/projects/${projectId}`} style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||||
|
← 返回工程
|
||||||
|
</Link>
|
||||||
|
<h1 style={{ fontSize: 22 }}>生成失败</h1>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<p style={{ color: 'var(--error)', marginBottom: 8 }}>
|
||||||
|
{task.error || '未知错误'}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||||
|
重试次数: {task.retryCount ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
||||||
<Link
|
<Link
|
||||||
to={`/projects/${projectId}`}
|
to={`/projects/${projectId}`}
|
||||||
style={{ fontSize: 13, color: 'var(--text-secondary)' }}
|
style={{ fontSize: 13, color: 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
← 返回工程
|
← 返回工程
|
||||||
</Link>
|
</Link>
|
||||||
<h1 style={{ fontSize: 22 }}>生成结果</h1>
|
<h1 style={{ fontSize: 22, marginTop: 8 }}>生成结果</h1>
|
||||||
</div>
|
{task.prompt && (
|
||||||
{assets.length > 0 && (
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4 }}>
|
||||||
<button
|
提示词: {task.prompt}
|
||||||
className="btn-primary"
|
</p>
|
||||||
onClick={() => downloadAssets(assets)}
|
|
||||||
style={{ padding: '8px 20px', fontSize: 13 }}
|
|
||||||
>
|
|
||||||
下载全部素材
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,22 +123,3 @@ export default function ResultPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadAssets(assets: Asset[]) {
|
|
||||||
for (const a of assets) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(a.url)
|
|
||||||
const blob = await res.blob()
|
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = blobUrl
|
|
||||||
link.download = `${a.id}.${a.format}`
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
URL.revokeObjectURL(blobUrl)
|
|
||||||
} catch {
|
|
||||||
window.open(a.url, '_blank')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user