feat: 支持管理端查看生成媒体 (#72)

This commit is contained in:
ila
2026-08-27 10:05:16 +08:00
parent e3d2782978
commit 71c7142f0c
21 changed files with 752 additions and 13 deletions
+2
View File
@@ -25,6 +25,8 @@ export const updateRoutePool = (id, data) => request({ url: `${base}/route-pools
export const listPortalUsers = () => request({ url: `${base}/users`, method: 'get' })
export const updatePortalUserStatus = (id, status) => request({ url: `${base}/users/${id}`, method: 'put', data: { status }})
export const listGenerations = () => request({ url: `${base}/generations`, method: 'get' })
export const getGeneration = id => request({ url: `${base}/generations/${id}`, method: 'get' })
export const getGenerationMedia = url => request({ url, method: 'get', responseType: 'blob' })
export const listAPIKeys = params => request({ url: `${base}/api-keys`, method: 'get', params })
export const getAPIKey = id => request({ url: `${base}/api-keys/${id}`, method: 'get' })
+2 -1
View File
@@ -42,8 +42,9 @@ service.interceptors.response.use(
* Determine the request status by custom code
* Here is just an example
* You can also judge the status by HTTP Status Code
*/
*/
response => {
if (response.config.responseType === 'blob') return response.data
const code = response.data.code
if (code === 401) {
store.dispatch('user/resetToken')
@@ -12,27 +12,108 @@
<div class="detail-head"><h2>任务 #{{ selected.id }}</h2><el-tag :type="statusType(selected.status)">{{ statusText(selected.status) }}</el-tag></div>
<dl class="detail-list"><dt>用户账号</dt><dd>{{ selected.username }}<span v-if="selected.display_name">({{ selected.display_name }})</span></dd><dt>生成类型</dt><dd>{{ kindText(selected.kind) }}</dd><dt>Provider 尝试</dt><dd>{{ selected.provider_attempt_count }}</dd><dt>总耗时</dt><dd class="numeric">{{ selectedTiming.total }}</dd><dt>排队耗时</dt><dd class="numeric">{{ selectedTiming.queue }}</dd><dt>处理耗时</dt><dd class="numeric">{{ selectedTiming.processing }}</dd><dt>上游耗时</dt><dd class="numeric">{{ selectedTiming.upstream }}</dd><dt>提交时间</dt><dd>{{ formatTime(selected.created_at) }}</dd><dt>开始时间</dt><dd>{{ formatTime(selected.started_at) }}</dd><dt>完成时间</dt><dd>{{ formatTime(selected.completed_at) }}</dd><template v-if="selected.error_code"><dt>错误码</dt><dd class="danger-text mono">{{ selected.error_code }}</dd></template><template v-if="selected.error_message"><dt>错误摘要</dt><dd>{{ selected.error_message }}</dd></template></dl>
<el-collapse class="prompt-collapse"><el-collapse-item title="渲染 Prompt"><pre class="prompt">{{ selected.rendered_prompt || '-' }}</pre></el-collapse-item></el-collapse>
<el-skeleton v-if="detailLoading" :rows="6" animated />
<el-alert v-else-if="detailError" title="详情加载失败" :description="detailError" type="error" :closable="false" show-icon />
<template v-else>
<h3>提示词原图</h3>
<div v-if="inputMedia.length" class="media-grid">
<article v-for="item in inputMedia" :key="`input-${item.id}`" class="media-item">
<button v-if="item.preview_url" class="image-button" type="button" :aria-label="`查看原图 ${item.name}`" @click="openPreview(item, 'input')"><img :src="item.preview_url" :alt="item.name || '提示词原图'"></button>
<div v-else class="media-placeholder"><span v-if="item.loading">加载中...</span><span v-else>{{ item.error || '无法预览' }}</span></div>
<div class="media-meta"><strong>{{ item.name || `输入 #${item.id}` }}</strong><span>{{ mediaMeta(item) }}</span></div>
</article>
</div>
<p v-else class="muted">本任务没有提示词原图。</p>
<h3>生成结果</h3>
<div v-if="imageOutputs.length" class="media-grid">
<article v-for="item in imageOutputs" :key="`output-${item.id}`" class="media-item">
<button v-if="item.preview_url" class="image-button" type="button" :aria-label="`查看生成图片 #${item.id}`" @click="openPreview(item, 'output')"><img :src="item.preview_url" :alt="`生成图片 #${item.id}`"></button>
<div v-else class="media-placeholder"><span v-if="item.loading">加载中...</span><span v-else>{{ item.error || '无法预览' }}</span></div>
<div class="media-meta"><strong>图片 #{{ item.id }}</strong><span>{{ mediaMeta(item) }}</span></div>
</article>
</div>
<div v-if="textOutputs.length" class="text-results"><article v-for="item in textOutputs" :key="`text-${item.id}`"><strong>文本 #{{ item.id }}</strong><pre>{{ item.text || '-' }}</pre></article></div>
<p v-if="!imageOutputs.length && !textOutputs.length" class="muted">本任务尚无生成结果。</p>
</template>
<h3>尝试记录</h3><el-timeline v-if="attempts.length"><el-timeline-item v-for="(attempt, index) in attempts" :key="index" :type="attempt.presentation.type" :timestamp="formatTime(attempt.presentation.timestamp)"><strong>{{ attempt.presentation.label }} · {{ attempt.presentation.title }}</strong><p>{{ attempt.presentation.status }}<span v-if="attempt.presentation.duration !== '-'"> · {{ attempt.presentation.duration }}</span><span v-if="attempt.error_message"> · {{ attempt.error_message }}</span></p></el-timeline-item></el-timeline><p v-else class="muted">尚无尝试记录。</p>
</section>
</el-drawer>
<el-dialog v-model="previewOpen" :title="previewTitle" width="min(92vw, 1100px)" append-to-body @closed="clearPreview"><div v-loading="previewLoading" class="preview-stage"><el-alert v-if="previewError" title="原图加载失败" :description="previewError" type="error" :closable="false" show-icon /><img v-else-if="previewURL" :src="previewURL" :alt="previewTitle"></div></el-dialog>
</main></template></BasicLayout>
</template>
<script>
import { listGenerations } from '@/api/chorus'
import { getGeneration, getGenerationMedia, listGenerations } from '@/api/chorus'
import { contains, dataOf, formatTime } from '../shared'
import { attemptPresentation, generationDetailSize, generationTiming, kindText, reconcileGenerationDetail } from './helpers'
export default {
name: 'ChorusGenerations',
data() { return { loading: false, rows: [], selected: null, detailOpen: false, viewportWidth: window.innerWidth, keyword: '', status: '', statuses: ['pending', 'running', 'succeeded', 'failed'], nowMs: Date.now(), clock: null } },
data() { return { loading: false, rows: [], selected: null, detailOpen: false, detailLoading: false, detailError: '', detailVersion: 0, inputMedia: [], imageOutputs: [], textOutputs: [], objectURLs: [], previewOpen: false, previewLoading: false, previewURL: '', previewTitle: '', previewError: '', previewVersion: 0, viewportWidth: window.innerWidth, keyword: '', status: '', statuses: ['pending', 'running', 'succeeded', 'failed'], nowMs: Date.now(), clock: null } },
computed: { filtered() { return this.rows.filter(row => (!this.keyword || [row.id, row.user_id, row.username, row.display_name, row.error_code].some(value => contains(value, this.keyword))) && (!this.status || row.status === this.status)) }, attempts() { return Array.isArray(this.selected && this.selected.attempts) ? this.selected.attempts.map(attempt => ({ ...attempt, presentation: attemptPresentation(attempt) })) : [] }, selectedTiming() { return generationTiming(this.selected, this.nowMs) }, detailSize() { return generationDetailSize(this.viewportWidth) } },
created() { if (this.$route.query.user) this.keyword = String(this.$route.query.user); this.load(); this.clock = window.setInterval(() => { this.nowMs = Date.now() }, 1000) },
mounted() { window.addEventListener('resize', this.handleResize) },
beforeUnmount() { window.clearInterval(this.clock); window.removeEventListener('resize', this.handleResize) },
methods: { formatTime, kindText, timing(row) { return generationTiming(row, this.nowMs) }, openDetail(row) { this.selected = row; this.detailOpen = true }, clearDetail() { this.selected = null }, handleResize() { this.viewportWidth = window.innerWidth }, syncSelected() { const state = reconcileGenerationDetail(this.rows, this.selected, this.detailOpen); this.selected = state.selected; this.detailOpen = state.detailOpen }, async load() { this.loading = true; try { this.rows = dataOf(await listGenerations()); this.syncSelected() } finally { this.loading = false } }, reset() { this.keyword = ''; this.status = '' }, statusText(value) { return ({ pending: '等待中', running: '处理中', succeeded: '成功', failed: '失败' })[value] || value }, statusType(value) { return ({ pending: 'info', running: 'warning', succeeded: 'success', failed: 'danger' })[value] || 'info' } }
beforeUnmount() { window.clearInterval(this.clock); window.removeEventListener('resize', this.handleResize); this.revokeObjectURLs() },
methods: {
formatTime, kindText,
timing(row) { return generationTiming(row, this.nowMs) },
async openDetail(row) {
const version = ++this.detailVersion
this.revokeObjectURLs(); this.inputMedia = []; this.imageOutputs = []; this.textOutputs = []; this.detailError = ''
this.selected = row; this.detailOpen = true; this.detailLoading = true
try {
const detail = dataOf(await getGeneration(row.id))
if (version !== this.detailVersion) return
this.selected = detail
this.inputMedia = (detail.inputs || []).map(item => ({ ...item, loading: true, error: '', preview_url: '' }))
this.imageOutputs = (detail.outputs || []).filter(item => item.url).map(item => ({ ...item, loading: true, error: '', preview_url: '', original_url: '' }))
this.textOutputs = (detail.outputs || []).filter(item => item.text !== undefined && item.text !== null)
await Promise.all([
...this.inputMedia.map(item => this.loadMedia(item, item.url, version, 'preview_url')),
...this.imageOutputs.map(item => this.loadMedia(item, item.thumbnail_url || item.url, version, 'preview_url'))
])
} catch (error) {
if (version === this.detailVersion) this.detailError = error && error.message ? error.message : '请求失败'
} finally {
if (version === this.detailVersion) this.detailLoading = false
}
},
async loadMedia(item, url, version, field) {
if (!url) { item.loading = false; item.error = '没有可用文件'; return }
try {
const blob = await getGenerationMedia(url)
const objectURL = URL.createObjectURL(blob)
if (version !== this.detailVersion) { URL.revokeObjectURL(objectURL); return }
this.objectURLs.push(objectURL); item[field] = objectURL
} catch (error) { if (version === this.detailVersion) item.error = error && error.message ? error.message : '加载失败' } finally { if (version === this.detailVersion) item.loading = false }
},
async openPreview(item, type) {
const version = ++this.previewVersion
this.previewOpen = true; this.previewLoading = true; this.previewError = ''; this.previewURL = ''; this.previewTitle = type === 'input' ? (item.name || `输入 #${item.id}`) : `生成图片 #${item.id}`
if (type === 'input') { this.previewURL = item.preview_url; this.previewLoading = false; return }
if (item.original_url) { this.previewURL = item.original_url; this.previewLoading = false; return }
try {
const blob = await getGenerationMedia(item.url)
const objectURL = URL.createObjectURL(blob)
if (!this.previewOpen || version !== this.previewVersion) { URL.revokeObjectURL(objectURL); return }
this.objectURLs.push(objectURL); item.original_url = objectURL; this.previewURL = objectURL
} catch (error) { if (version === this.previewVersion) this.previewError = error && error.message ? error.message : '加载失败' } finally { if (version === this.previewVersion) this.previewLoading = false }
},
mediaMeta(item) { const dimensions = item.width && item.height ? `${item.width} × ${item.height}` : ''; const size = Number(item.size_bytes) > 0 ? `${(Number(item.size_bytes) / 1024).toFixed(1)} KB` : ''; return [dimensions, size, item.mime_type].filter(Boolean).join(' · ') || '媒体文件' },
clearPreview() { this.previewVersion++; this.previewOpen = false; this.previewLoading = false; this.previewURL = ''; this.previewTitle = ''; this.previewError = '' },
revokeObjectURLs() { this.objectURLs.forEach(url => URL.revokeObjectURL(url)); this.objectURLs = [] },
clearDetail() { this.detailVersion++; this.revokeObjectURLs(); this.clearPreview(); this.selected = null; this.inputMedia = []; this.imageOutputs = []; this.textOutputs = []; this.detailError = ''; this.detailLoading = false },
handleResize() { this.viewportWidth = window.innerWidth },
syncSelected() { const previous = this.selected; const state = reconcileGenerationDetail(this.rows, previous, this.detailOpen); this.selected = state.selected && previous ? { ...previous, ...state.selected } : state.selected; this.detailOpen = state.detailOpen },
async load() { this.loading = true; try { this.rows = dataOf(await listGenerations()); this.syncSelected() } finally { this.loading = false } },
reset() { this.keyword = ''; this.status = '' },
statusText(value) { return ({ pending: '等待中', running: '处理中', succeeded: '成功', failed: '失败' })[value] || value },
statusType(value) { return ({ pending: 'info', running: 'warning', succeeded: 'success', failed: 'danger' })[value] || 'info' }
}
}
</script>
<style lang="scss" scoped>
@use '../shared.scss';
.detail-head { display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--el-border-color-light); padding-bottom: 10px; }
.detail-head h2 { margin: 0; font-size: 16px; }.generation-detail h3 { font-size: 15px; margin: 18px 0 12px; }.prompt-collapse { margin-top: 14px; }.prompt { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; margin: 0; font-family: inherit; }.numeric { font-variant-numeric: tabular-nums; white-space: nowrap; }.user-name { display: block; color: var(--el-text-color-secondary); font-size: 12px; }
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }.media-item { min-width: 0; border: 1px solid var(--el-border-color-light); border-radius: 6px; overflow: hidden; background: var(--el-bg-color); }.image-button { display: block; width: 100%; aspect-ratio: 1 / 1; padding: 0; border: 0; background: var(--el-fill-color-light); cursor: zoom-in; }.image-button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -2px; }.image-button img { display: block; width: 100%; height: 100%; object-fit: contain; }.media-placeholder { display: grid; place-items: center; aspect-ratio: 1 / 1; padding: 12px; color: var(--el-text-color-secondary); text-align: center; background: var(--el-fill-color-light); }.media-meta { display: grid; gap: 4px; padding: 9px 10px; }.media-meta strong { overflow-wrap: anywhere; }.media-meta span { color: var(--el-text-color-secondary); font-size: 12px; overflow-wrap: anywhere; }.text-results { display: grid; gap: 10px; margin-top: 12px; }.text-results article { border-left: 3px solid var(--el-border-color); padding: 8px 10px; background: var(--el-fill-color-lighter); }.text-results pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }.preview-stage { min-height: 180px; display: grid; place-items: center; }.preview-stage img { display: block; max-width: 100%; max-height: 75vh; object-fit: contain; }
@media (max-width: 520px) { .media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>