Files
image2api/frontend/src/views/LogsView.vue
T
chiyiandDevin b700294ad3 style(ui): 表格配色收敛到全局墨色基调
- 统计卡:大数字统一墨色、标签统一次要灰,去掉绿/红/橙彩虹数字
- 所有语义色文字 300/200 浅色加深为 600/500(淡底深字,适配亮色主题)
- 徽章/胶囊 ring 与 border 统一为 500/25 细描边
- 滑动开关开态由绿改墨黑(对齐 vivid switch.on)
- .fp-* 徽章改为 vivid 同款淡底深字
- 跳过媒体覆盖层胶囊、暗色代码块等本就属于暗底的元素

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:44:57 +08:00

438 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { api } from '../api'
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
import { copyText } from '../utils/clipboard'
import { generatedUrl, thumbUrl } from '../api'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
const items = ref([])
const stats = ref({ total: 0, success: 0, failed: 0, pending: 0 })
const loading = ref(false)
const kindFilter = ref('') // '' | 'image' | 'video'
const statusFilter = ref('') // '' | 'success' | 'failed' | 'pending'
const sourceFilter = ref('') // '' | 'v1' | 'user' | 'admin'
const search = ref('')
const page = ref(1)
const pageSize = ref(15)
const total = ref(0)
const toast = ref('')
let toastTimer = null
async function copyPrompt(e) {
if (!e.prompt) return
toast.value = (await copyText(e.prompt)) ? '指令已复制' : '复制失败'
clearTimeout(toastTimer)
toastTimer = setTimeout(() => (toast.value = ''), 1800)
}
async function copyError(e) {
if (!e.error) return
toast.value = (await copyText(e.error)) ? '错误已复制' : '复制失败'
clearTimeout(toastTimer)
toastTimer = setTimeout(() => (toast.value = ''), 1800)
}
async function load() {
loading.value = true
const offset = (page.value - 1) * pageSize.value
const qs = new URLSearchParams({ limit: String(pageSize.value), offset: String(offset) })
// Admin 日志 page: request the full cross-user view. The backend only honors
// scope=all for admins; without it /logs returns the caller's own records.
qs.set('scope', 'all')
if (kindFilter.value) qs.set('kind', kindFilter.value)
if (statusFilter.value) qs.set('status', statusFilter.value)
if (sourceFilter.value) qs.set('source', sourceFilter.value)
if (search.value.trim()) qs.set('kw', search.value.trim())
const r = await api('/logs?' + qs.toString())
items.value = r.data?.data || []
total.value = Number(r.data?.total ?? items.value.length)
stats.value = r.data?.stats || { total: 0, success: 0, failed: 0, pending: 0 }
loading.value = false
}
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pageStart = computed(() => total.value === 0 ? 0 : (page.value - 1) * pageSize.value + 1)
const pageEnd = computed(() => Math.min(total.value, page.value * pageSize.value))
// Numbered pagination strip: always shows first + last + a window around
// the current page; gaps collapse to `null` (rendered as "…").
const pageNumbers = computed(() => {
const n = totalPages.value
const cur = page.value
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
const want = new Set([1, n, cur - 1, cur, cur + 1])
// pad the second slot from each end so 1 2 … X … N-1 N feels balanced
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
const out = []
for (let i = 0; i < list.length; i++) {
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
out.push(list[i])
}
return out
})
function goPage(n) {
const target = Math.max(1, Math.min(totalPages.value, n))
if (target === page.value) return
page.value = target
load()
}
// Filters reset the cursor so a narrower view always starts on page 1.
const KIND_OPTS = [{ value: '', label: '全部类型' }, { value: 'image', label: '图像' }, { value: 'video', label: '视频' }]
const STATUS_OPTS = [{ value: '', label: '所有状态' }, { value: 'success', label: '成功' }, { value: 'failed', label: '失败' }, { value: 'pending', label: '进行中' }]
const SOURCE_OPTS = [{ value: '', label: '所有来源' }, { value: 'user', label: '画图台' }, { value: 'v1', label: 'API' }, { value: 'admin', label: '测试' }]
function setKind(v) { kindFilter.value = v; page.value = 1; load() }
function setStatus(v) { statusFilter.value = v; page.value = 1; load() }
function setSource(v) { sourceFilter.value = v; page.value = 1; load() }
function doUserSearch() { page.value = 1; load() }
// 搜索全部走服务端(跨页),页面直接展示服务端返回的当页结果。
const filtered = computed(() => items.value)
function fmtMs(ms) {
if (!ms) return '—'
if (ms < 1000) return ms + 'ms'
return Math.round(ms / 1000) + 's'
}
// One-line timestamp: within 3 days show a relative phrase ("12h 前"),
// older entries collapse to a full Y-M-D H:M:S so the row stays compact.
function fmtWhen(ts) {
if (!ts) return '—'
return fmtTs(ts)
}
// Video rows whose first-frame thumbnail is missing (old videos) — fall back
// to the muted <video> preview for those.
const thumbFail = reactive({})
const previewing = ref(null) // entry whose generated file is open in the lightbox
function openPreview(e) {
// API (v1) outputs aren't persisted/served by us (image=b64 inline, video=an
// upstream URL for /content) — no in-log preview, same as images. Skip them.
if (e.status !== 'success' || !e.file || e.source === 'v1') return
previewing.value = e
}
function closePreview() { previewing.value = null }
function onKey(ev) { if (ev.key === 'Escape') closePreview() }
// 日志不支持手动清空(清空按钮已移除);仅由后台保留期策略自动清理。
// Auto-refresh removed: the admin can hit 刷新 / change a filter to reload.
onMounted(() => {
load()
window.addEventListener('keydown', onKey)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKey)
})
// ---- chip helpers ----
const statusLabel = (s) => ({ success: '成功', failed: '失败', pending: '进行中' }[s] || s)
const statusPill = (s) => ({
success: 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/25',
failed: 'bg-rose-500/10 text-rose-600 ring-rose-500/25',
pending: 'bg-amber-500/10 text-amber-600 ring-amber-500/25',
}[s] || 'bg-white/[0.06] text-white/65 ring-white/15')
const statusDot = (s) => ({
success: 'bg-emerald-500',
failed: 'bg-rose-500',
pending: 'bg-amber-500',
}[s] || 'bg-white/40')
// Source: backend stamps "v1" (API key), "user" (画图台), "admin" (后台测试模型).
const sourceLabel = (s) => ({ v1: 'API', user: '画图台', admin: '测试' }[s] || '画图台')
const sourcePill = (s) => ({
v1: 'bg-orange-500/15 text-orange-600 ring-orange-500/25',
admin: 'bg-amber-500/15 text-amber-600 ring-amber-500/25',
user: 'bg-sky-500/15 text-sky-600 ring-sky-500/25',
}[s] || 'bg-sky-500/15 text-sky-600 ring-sky-500/25')
</script>
<template>
<section class="space-y-4">
<div class="page-head">
<h2 class="page-title">日志管理</h2>
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
<!-- KPI strip — dense pills aligned with the dashboard tints -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">总计</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">成功</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.success }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">失败</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.failed }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">进行中</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.pending }}</div>
</div>
</div>
<!-- Toolbar -->
<div class="card toolbar">
<SelectMenu :model-value="kindFilter" compact :options="KIND_OPTS" @update:model-value="setKind" />
<SelectMenu :model-value="statusFilter" compact :options="STATUS_OPTS" @update:model-value="setStatus" />
<SelectMenu :model-value="sourceFilter" compact :options="SOURCE_OPTS" @update:model-value="setSource" />
<div class="flex-1 min-w-[200px]">
<input v-model="search" @keyup.enter="doUserSearch" @change="doUserSearch"
class="field !py-1.5 text-xs" placeholder="搜索 用户 / 模型 / 提示词 / 错误…" />
</div>
</div>
<!-- Table -->
<div class="card overflow-hidden">
<div v-if="loading && !items.length" class="empty"><span class="empty-text">加载中…</span></div>
<div v-else-if="!filtered.length" class="empty">
<span class="empty-ico"><Icon name="files" class="w-6 h-6" /></span>
<span class="empty-text">{{ search.trim() ? '没有匹配的记录' : '还没有日志' }}</span>
</div>
<!-- Each row is a thumbnail + a stack of model/prompt + a meta line.
Beats a 9-column table for scanability — the eye lands on the
image first, then reads the model + intent, then params. -->
<table v-else class="w-full text-sm table-fixed log-table">
<colgroup>
<col class="w-20" /> <!-- preview -->
<col class="w-32" /> <!-- time -->
<col class="w-24" /> <!-- status -->
<col class="w-28" /> <!-- user -->
<col class="w-56" /> <!-- model -->
<col /> <!-- prompt + error -->
<col class="w-48" /> <!-- params -->
<col class="w-16" /> <!-- credits -->
<col class="w-16" /> <!-- elapsed -->
</colgroup>
<thead>
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
<th class="text-center px-4 py-3 font-medium">预览</th>
<th class="text-left px-4 py-3 font-medium">时间</th>
<th class="text-left px-3 py-3 font-medium">状态</th>
<th class="text-left px-3 py-3 font-medium">用户 / 渠道</th>
<th class="text-left px-3 py-3 font-medium">模型</th>
<th class="text-left px-3 py-3 font-medium">提示词 / 错误</th>
<th class="text-left px-3 py-3 font-medium">参数</th>
<th class="text-right px-3 py-3 font-medium">积分</th>
<th class="text-right px-4 py-3 font-medium">耗时</th>
</tr>
</thead>
<tbody>
<tr v-for="e in filtered" :key="e.id" class="log-row">
<td class="px-4 py-3.5 align-middle text-center">
<button v-if="e.status === 'success' && e.file && e.source !== 'v1'"
@click="openPreview(e)"
class="block w-12 h-12 mx-auto rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-rose-500/25 transition-all">
<img v-if="e.kind !== 'video' || !thumbFail[e.id]" :src="thumbUrl(e.file)" loading="lazy"
class="w-full h-full object-cover"
@error="e.kind === 'video' && (thumbFail[e.id] = true)" />
<video v-else :src="generatedUrl(e.file)" muted loop preload="metadata" playsinline
class="w-full h-full object-cover"
@mouseenter="$event.target.play && $event.target.play()"
@mouseleave="$event.target.pause && $event.target.pause()" />
</button>
<div v-else-if="e.status === 'pending'" class="w-12 h-12 mx-auto rounded-lg bg-amber-500/10 ring-1 ring-amber-500/25 grid place-items-center">
<span class="w-2 h-2 rounded-full bg-amber-500 animate-pulse"></span>
</div>
<!-- failed (and any non-success/non-pending) rows: no thumbnail, just a dash.
The 状态 column already flags the failure. -->
<span v-else class="text-white/20">—</span>
</td>
<td class="px-4 py-3.5 align-middle text-xs whitespace-nowrap" :title="fmtTs(e.ts)">
<div v-if="e.ts" class="leading-tight">
<div class="text-white/80 tabular-nums">{{ fmtDate(e.ts) }}</div>
<div class="text-white/45 tabular-nums">{{ fmtClock(e.ts) }}</div>
</div>
<span v-else class="text-white/25">—</span>
</td>
<td class="px-3 py-3.5 align-middle">
<span class="chip ring-1" :class="statusPill(e.status)" :title="e.error || ''">
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(e.status)"></span>
{{ statusLabel(e.status) }}
</span>
</td>
<td class="px-3 py-3.5 align-middle min-w-0">
<div class="text-xs text-white/80 truncate" :title="e.user_name || '匿名'">{{ e.user_name || '匿名' }}</div>
<div v-if="e.account" class="mt-0.5 text-[11px] text-white/45 truncate" :title="e.account">{{ e.account }}</div>
</td>
<td class="px-3 py-3.5 align-middle min-w-0">
<div class="font-mono text-xs text-white/90 break-all" :title="e.model">{{ e.model }}</div>
<div class="mt-1 flex items-center gap-1.5 min-w-0">
<span class="text-[10px] uppercase tracking-wider font-medium truncate min-w-0"
:class="e.kind === 'video' ? 'text-rose-600' : 'text-sky-600'">
{{ e.kind === 'video' ? '视频' : '图像' }}
<span v-if="e.provider" class="text-white/30 ml-1">· {{ e.provider }}</span>
</span>
<span class="inline-flex items-center rounded px-1.5 py-px text-[10px] font-medium ring-1 whitespace-nowrap shrink-0"
:class="sourcePill(e.source)">{{ sourceLabel(e.source) }}</span>
</div>
</td>
<!-- Prompt with error inline; the error reads as a follow-up rather
than wasting a whole column when there's nothing to show. -->
<td class="px-3 py-3.5 align-middle min-w-0">
<div class="text-xs text-white/80 truncate transition-colors"
:class="e.prompt ? 'cursor-pointer hover:text-white' : ''"
:title="e.prompt ? '点击复制提示词' : ''"
@click="e.prompt && copyPrompt(e)">{{ e.prompt || '—' }}</div>
<div v-if="e.error" class="mt-1 text-[11px] text-rose-600 truncate flex items-center gap-1.5 cursor-pointer hover:text-rose-500 transition-colors"
:title="e.error + ' — 点击复制'"
@click.stop="copyError(e)">
{{ e.error }}
</div>
</td>
<!-- Compact single-line params, dot-separated. -->
<td class="px-3 py-3.5 align-middle text-[11px] text-white/55 font-mono whitespace-nowrap tabular-nums">
<span>{{ e.ratio || '—' }}</span>
<span class="text-white/25 mx-1.5">·</span>
<span>{{ e.resolution || '—' }}</span>
<template v-if="e.duration">
<span class="text-white/25 mx-1.5">·</span>
<span>{{ e.duration }}</span>
</template>
<template v-if="e.refs > 0">
<span class="text-white/25 mx-1.5">·</span>
<span class="text-white/40">参考 {{ e.refs }}</span>
</template>
<template v-if="e.deai">
<span class="text-white/25 mx-1.5">·</span>
<span class="text-[#71717a]">去AI特征</span>
</template>
</td>
<td class="px-3 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap">
<span v-if="e.cost > 0" class="text-amber-600 font-medium">{{ e.cost }}</span>
<span v-else class="text-white/25">0</span>
</td>
<td class="px-4 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap text-white/85">
{{ fmtMs(e.elapsed_ms) }}
</td>
</tr>
</tbody>
</table>
<!-- pagination — numbered with ellipsis, no prev/next buttons -->
<div v-if="!loading && total > 0"
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
<div>
<span class="tabular-nums text-white/85">{{ pageStart }}–{{ pageEnd }}</span>
<span class="ml-1">/ {{ total }} 条</span>
</div>
<div class="flex items-center gap-1">
<template v-for="(n, i) in pageNumbers" :key="i">
<span v-if="n === null" class="px-1 text-white/35">…</span>
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
</template>
</div>
</div>
</div>
<!-- Lightbox (shared component) -->
<MediaLightbox
v-if="previewing"
:src="generatedUrl(previewing.file)"
:kind="previewing.kind"
:prompt="previewing.prompt"
:meta="[previewing.model, previewing.ratio, previewing.resolution, previewing.duration, fmtMs(previewing.elapsed_ms)].filter(Boolean).join(' · ')"
:download-name="previewing.file"
@close="closePreview" />
<div v-if="toast"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg">
{{ toast }}
</div>
</section>
</template>
<style scoped>
/* --- filter pills --- */
.fp {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
font-size: 0.72rem;
border-radius: 0.55rem;
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.fp:hover { background: var(--hover); color: var(--fg); }
.fp-on {
background: var(--surface-solid);
color: rgb(15 23 42);
box-shadow: none;
}
.fp-emerald { background: rgb(16 185 129 / 0.1); color: #059669; box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.22); }
.fp-rose { background: rgb(239 68 68 / 0.09); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(239 68 68 / 0.2); }
.fp-amber { background: rgb(245 158 11 / 0.12); color: #b45309; box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.25); }
/* --- type / status chip used inside table rows --- */
.chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.18rem 0.55rem;
font-size: 0.7rem;
font-weight: 500;
border-radius: 9999px;
white-space: nowrap;
}
/* --- "danger" variant for the 清空 button --- */
.btn-soft.danger {
color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
}
.btn-soft.danger:hover {
color: white;
background: rgb(244 63 94 / 0.25);
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* --- log table: subtle row separators + a barely-there hover tint that
extends a soft orange accent on the left of the row (read as a
focus indicator without being noisy). --- */
.log-table { border-collapse: separate; border-spacing: 0; }
.log-row td {
border-bottom: 1px solid var(--hairline);
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.log-row:hover td { background: var(--surface-2); }
.log-row:hover td:first-child {
box-shadow: inset 2px 0 0 rgb(253 186 116 / 0.55);
}
.log-row:last-child td { border-bottom: none; }
/* --- pagination buttons --- */
.pg {
min-width: 1.75rem;
padding: 0.3rem 0.55rem;
font-size: 0.72rem;
font-weight: 500;
text-align: center;
border-radius: 0.45rem;
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
.pg-on {
background: var(--surface-solid);
color: rgb(15 23 42);
box-shadow: none;
}
</style>