Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { site } from '../site'
|
||||
|
||||
const contact = computed(() => site.contact || {})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-12 max-w-3xl">
|
||||
<!-- intro -->
|
||||
<section>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-fuchsia-300/70 font-medium">关于</div>
|
||||
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight text-[color:var(--fg)]">关于 {{ site.title }}</h1>
|
||||
<p class="text-[color:var(--fg-2)] mt-5 text-base md:text-lg leading-relaxed max-w-xl">
|
||||
{{ site.title }} 是一个聚合 GPT、Gemini、Firefly、Flux 等顶级模型的 AI 生图与生视频平台。
|
||||
把脑海里的画面写成一句话,交给我们替你变成图像与视频。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- contact -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold tracking-tight text-[color:var(--fg)] mb-1">联系我们</h2>
|
||||
<p class="text-[color:var(--fg-3)] mb-6">有问题、合作,或想加入交流群?随时找我们。</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<a v-if="contact.shop" :href="contact.shop" target="_blank" rel="noopener"
|
||||
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-fuchsia-300/80">商店</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 group-hover:text-fuchsia-400 transition-colors">前往充值商店</div>
|
||||
</div>
|
||||
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
|
||||
<a v-if="contact.email" :href="`mailto:${contact.email}`"
|
||||
class="group rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">邮箱</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 break-all group-hover:text-sky-400 transition-colors">{{ contact.email }}</div>
|
||||
</a>
|
||||
|
||||
<a v-if="contact.qq && contact.qq_link" :href="contact.qq_link" target="_blank" rel="noopener"
|
||||
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all group-hover:text-sky-400 transition-colors">{{ contact.qq }}</div>
|
||||
</div>
|
||||
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
<div v-else-if="contact.qq" class="rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5">
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all">{{ contact.qq }}</div>
|
||||
</div>
|
||||
|
||||
<a v-if="contact.qq_group && contact.qq_group_link" :href="contact.qq_group_link" target="_blank" rel="noopener"
|
||||
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ 群</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all group-hover:text-emerald-400 transition-colors">{{ contact.qq_group }}</div>
|
||||
</div>
|
||||
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
<div v-else-if="contact.qq_group" class="rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5">
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ 群</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all">{{ contact.qq_group }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!contact.shop && !contact.email && !contact.qq && !contact.qq_group"
|
||||
class="text-sm text-[color:var(--fg-3)]">管理员尚未配置联系方式。</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,651 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { fmtTs, fmtIso, fmtDate, fmtClock } from '../utils/format'
|
||||
import ImportModal from '../components/ImportModal.vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const quotaStatus = ref('')
|
||||
const showImport = ref(false)
|
||||
|
||||
const typeFilter = ref('') // '' | 'openai' | 'adobe' | 'runway' | 'leonardo'
|
||||
const statusFilter = ref('') // '' | 'active' | 'quota' | 'disabled'
|
||||
const search = ref('')
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
// Typing a search term must jump back to page 1 — otherwise a narrowed result
|
||||
// set can leave you stranded on a now-empty page.
|
||||
watch(search, () => { page.value = 1 })
|
||||
|
||||
// 每个类型的 成功/失败/限额 三个数(成功=正常可用, 失败=失效/禁用, 限额=额度耗尽)。
|
||||
const stats = computed(() => {
|
||||
const by = (t) => {
|
||||
const s = rows.value.filter((r) => r.type === t)
|
||||
return {
|
||||
n: s.length,
|
||||
ok: s.filter((r) => r.status === 'active').length,
|
||||
dead: s.filter((r) => r.dead || r.status === 'disabled').length,
|
||||
quota: s.filter((r) => r.status === 'quota').length,
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: rows.value.length,
|
||||
openai: by('openai'), adobe: by('adobe'), runway: by('runway'),
|
||||
leonardo: by('leonardo'), krea: by('krea'), imagine: by('imagine'),
|
||||
}
|
||||
})
|
||||
|
||||
// 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。
|
||||
const deadCount = computed(() => rows.value.filter((r) => r.dead).length)
|
||||
|
||||
function typePill(t) {
|
||||
return {
|
||||
adobe: 'bg-rose-500/10 text-rose-300 ring-rose-400/30',
|
||||
openai: 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30',
|
||||
runway: 'bg-violet-500/10 text-violet-300 ring-violet-400/30',
|
||||
leonardo: 'bg-amber-500/10 text-amber-300 ring-amber-400/30',
|
||||
krea: 'bg-sky-500/10 text-sky-300 ring-sky-400/30',
|
||||
imagine: 'bg-teal-500/10 text-teal-300 ring-teal-400/30',
|
||||
}[t] || 'bg-white/[0.06] text-white/70 ring-white/15'
|
||||
}
|
||||
const STATUS_LABEL = { active: '正常', quota: '额度耗尽', disabled: '已禁用', pending: '检测中' }
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
const sorted = [...rows.value].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
return sorted.filter((a) => {
|
||||
if (typeFilter.value && a.type !== typeFilter.value) return false
|
||||
if (statusFilter.value && a.status !== statusFilter.value) return false
|
||||
if (q && !(
|
||||
(a.email || '').toLowerCase().includes(q) ||
|
||||
(a.id || '').toLowerCase().includes(q) ||
|
||||
(a.type || '').toLowerCase().includes(q)
|
||||
)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filtered.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target !== page.value) page.value = target
|
||||
}
|
||||
function setFilter(fn) { fn(); page.value = 1 }
|
||||
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])
|
||||
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
|
||||
})
|
||||
|
||||
let pendingTimer = null
|
||||
|
||||
async function loadAccounts() {
|
||||
loading.value = true
|
||||
quotaStatus.value = ''
|
||||
const r = await api('/accounts')
|
||||
rows.value = r.data?.data || []
|
||||
loading.value = false
|
||||
if (rows.value.length) reconcile()
|
||||
schedulePendingPoll()
|
||||
}
|
||||
|
||||
// While any imported account is still being checked server-side, re-fetch the
|
||||
// list so it flips pending → active/dead on its own (no manual refresh).
|
||||
function schedulePendingPoll() {
|
||||
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
|
||||
if (!rows.value.some((r) => r.pending)) return
|
||||
pendingTimer = setTimeout(async () => {
|
||||
const r = await api('/accounts')
|
||||
rows.value = r.data?.data || []
|
||||
schedulePendingPoll()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Background reconciliation: openai → live quota; adobe → reset_after;
|
||||
// adobe without email → fetch email. Only ACTIVE accounts — pending ones are
|
||||
// handled by the import worker, dead/disabled ones need no re-check.
|
||||
//
|
||||
// Scope: ONLY the accounts visible on the current page. Probing all 100+ rows on
|
||||
// every open floods the backend; the user only ever sees ~20 at a time, so we
|
||||
// re-check just those and re-run when the page (or filters) change. New imports
|
||||
// are hydrated server-side by the import worker and surfaced via the pending
|
||||
// poll reading the store — they don't need a frontend probe.
|
||||
let reconcileToken = 0
|
||||
async function reconcile() {
|
||||
const myToken = ++reconcileToken // supersede any in-flight run (fast page flips)
|
||||
const visible = pagedItems.value
|
||||
// NEW accounts (still pending the import worker's server-side check) are never
|
||||
// probed here — the pending poll just reads the store until the worker writes
|
||||
// their quota/email. OLD accounts (active) get a real live /quota probe for
|
||||
// up-to-date remaining + refresh time.
|
||||
const quotaRows = visible.filter((r) => !r.pending && r.status === 'active' && (r.type === 'openai' || r.type === 'adobe' || r.type === 'runway' || r.type === 'leonardo' || r.type === 'krea' || r.type === 'imagine'))
|
||||
const adobeNeedEmail = visible.filter((r) => !r.pending && r.type === 'adobe' && !r.email)
|
||||
const total = quotaRows.length + adobeNeedEmail.length
|
||||
if (total === 0) { quotaStatus.value = ''; return }
|
||||
|
||||
let done = 0, updates = 0
|
||||
quotaStatus.value = `后台校对… 0/${total}`
|
||||
const bump = () => {
|
||||
done++
|
||||
if (myToken !== reconcileToken) return // a newer page-flip superseded us
|
||||
quotaStatus.value = `后台校对… ${done}/${total}${updates ? ` · 更新 ${updates}` : ''}`
|
||||
}
|
||||
|
||||
// Build thunks (NOT immediately-invoked) so the pool controls how many run at
|
||||
// once. Each /accounts/.../quota probe is a *synchronous* backend call to
|
||||
// OpenAI/Adobe. We only probe the visible page (≤ pageSize rows), so the
|
||||
// limit below is effectively bounded by that — no full-list flood.
|
||||
const jobs = []
|
||||
for (const row of quotaRows) {
|
||||
jobs.push(async () => {
|
||||
const result = await fetchOneQuota(row.pool, row.id)
|
||||
if (result && result.auth_failed) {
|
||||
// backend auto-disabled this dead (401) token — reflect it immediately
|
||||
row.status = result.status || 'disabled'
|
||||
row.dead = true
|
||||
row.remaining = null
|
||||
row._unknown = true
|
||||
updates++
|
||||
} else if (result && result.unchanged === false) {
|
||||
if (row.type === 'adobe') row.reset_after = result.reset_after
|
||||
else applyQuota(row, result)
|
||||
updates++
|
||||
}
|
||||
bump()
|
||||
})
|
||||
}
|
||||
for (const row of adobeNeedEmail) {
|
||||
jobs.push(async () => {
|
||||
const result = await fetchOneEmail(row.pool, row.id)
|
||||
if (result && result.email && (!row.email || row.email === '—')) {
|
||||
row.email = result.email
|
||||
updates++
|
||||
}
|
||||
bump()
|
||||
})
|
||||
}
|
||||
await runWithLimit(jobs, Infinity) // no JS-side cap — fire all visible-page probes at once (browser still limits ~6 conns/origin)
|
||||
// clear the indicator when done — but only if we're still the current run
|
||||
// (a page flip mid-reconcile starts a fresh one that owns the indicator).
|
||||
if (myToken === reconcileToken) quotaStatus.value = ''
|
||||
}
|
||||
|
||||
// Re-check the newly visible accounts whenever the page or filters change.
|
||||
// Only the on-screen page is ever probed (see reconcile), so flipping pages is
|
||||
// what triggers checking the rest — never all rows at once.
|
||||
watch([page, typeFilter, statusFilter], () => {
|
||||
if (rows.value.length) reconcile()
|
||||
})
|
||||
|
||||
// Bounded-concurrency runner: keeps at most `limit` thunks in flight at once.
|
||||
async function runWithLimit(thunks, limit) {
|
||||
let next = 0
|
||||
const workers = Array.from({ length: Math.min(limit, thunks.length) }, async () => {
|
||||
while (next < thunks.length) {
|
||||
const idx = next++
|
||||
await thunks[idx]()
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
async function fetchOneQuota(pool, id) {
|
||||
try { return (await api(`/accounts/${pool}/${id}/quota`)).data || {} }
|
||||
catch (e) { return { error: String(e) } }
|
||||
}
|
||||
async function fetchOneEmail(pool, id) {
|
||||
try { return (await api(`/accounts/${pool}/${id}/email`)).data || {} }
|
||||
catch (e) { return { error: String(e) } }
|
||||
}
|
||||
|
||||
function applyQuota(row, result) {
|
||||
// A transient probe error (e.g. connection reset when OpenAI is unreachable
|
||||
// without a proxy) must NOT blank the cached number — keep the last-known
|
||||
// value so a network blip doesn't turn the whole column into "—".
|
||||
if (result.error) { row._quotaError = result.error; return }
|
||||
row._quotaError = null
|
||||
if (result.unknown && result.remaining === null) { row.remaining = null; row._unknown = true; return }
|
||||
row._unknown = false
|
||||
row.remaining = result.remaining
|
||||
row.reset_after = result.reset_after
|
||||
}
|
||||
|
||||
async function toggleAccountStatus(pool, id, current) {
|
||||
const row = rows.value.find((r) => r.pool === pool && r.id === id)
|
||||
const next = current === 'active' ? 'disabled' : 'active'
|
||||
// Optimistic: flip the switch instantly so the UI never waits on the network.
|
||||
// The PATCH itself is a cheap in-memory update server-side; the old 5s lag came
|
||||
// from the follow-up loadAccounts() → reconcile() probing every account's quota.
|
||||
if (row) row.status = next
|
||||
try {
|
||||
const r = await api(`/tokens/${pool}/${id}`, jsonBody('PATCH', { status: next }))
|
||||
if (!r.ok && row) row.status = current // revert on server rejection
|
||||
} catch (e) {
|
||||
if (row) row.status = current // revert on network error
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount(pool, id) {
|
||||
if (!confirm(`确认删除 ${pool} / ${id}?`)) return
|
||||
await api(`/tokens/${pool}/${id}`, { method: 'DELETE' })
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// 一键删除全部异常(已失效/红色锁定)账号。逐个走与单删相同的 DELETE 接口。
|
||||
async function deleteDeadAccounts() {
|
||||
const dead = rows.value.filter((r) => r.dead)
|
||||
if (!dead.length) return
|
||||
if (!confirm(`确认删除全部 ${dead.length} 个异常(已失效)账号?此操作不可撤销。`)) return
|
||||
await Promise.all(dead.map((r) => api(`/tokens/${r.pool}/${r.id}`, { method: 'DELETE' })))
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// ===== 多选删除 =====
|
||||
const selected = ref(new Set())
|
||||
function toggleSelect(id) {
|
||||
const s = new Set(selected.value)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
selected.value = s
|
||||
}
|
||||
// Header checkbox controls the whole filtered set (not just the visible page).
|
||||
const allSelected = computed(() =>
|
||||
filtered.value.length > 0 && filtered.value.every((a) => selected.value.has(a.id)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) filtered.value.forEach((a) => s.delete(a.id))
|
||||
else filtered.value.forEach((a) => s.add(a.id))
|
||||
selected.value = s
|
||||
}
|
||||
async function deleteSelected() {
|
||||
const ids = [...selected.value]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`确认删除选中的 ${ids.length} 个账号?此操作不可撤销。`)) return
|
||||
const r = await api('/tokens/delete-bulk', jsonBody('POST', { ids }))
|
||||
if (r.ok) {
|
||||
selected.value = new Set()
|
||||
loadAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAccounts)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — 每个类型显示 成功/失败/限额 三个数(绿/红/琥珀) -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-7 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/45">账号总数</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5">成功/失败/限额</div>
|
||||
</div>
|
||||
<div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-violet-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80']]"
|
||||
:key="t[0]" class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider" :class="t[2]">{{ t[1] }}</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">
|
||||
<span class="text-emerald-300">{{ stats[t[0]].ok }}</span><span class="text-white/30">/</span><span class="text-rose-300">{{ stats[t[0]].dead }}</span><span class="text-white/30">/</span><span class="text-amber-300">{{ stats[t[0]].quota }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5">共 {{ stats[t[0]].n }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button>
|
||||
<button @click="setFilter(() => typeFilter = 'openai')" class="fp" :class="typeFilter === 'openai' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>OpenAI
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'adobe')" class="fp" :class="typeFilter === 'adobe' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>Adobe
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'runway')" class="fp" :class="typeFilter === 'runway' && 'fp-violet'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-violet-400"></span>Runway
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'leonardo')" class="fp" :class="typeFilter === 'leonardo' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>Leonardo
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'krea')" class="fp" :class="typeFilter === 'krea' && 'fp-sky'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-sky-400"></span>Krea
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'imagine')" class="fp" :class="typeFilter === 'imagine' && 'fp-teal'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>Imagine
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'quota')" class="fp" :class="statusFilter === 'quota' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>额度耗尽
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>已禁用
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / ID / 类型…" />
|
||||
</div>
|
||||
<button v-if="selected.size" @click="deleteSelected" class="btn-soft danger" title="删除选中的账号">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
|
||||
</button>
|
||||
<button v-if="deadCount" @click="deleteDeadAccounts" class="btn-soft danger" title="删除全部已失效(401)账号">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除异常账号 ({{ deadCount }})
|
||||
</button>
|
||||
<button @click="loadAccounts" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button @click="showImport = true" class="btn-primary">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 导入账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !rows.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||
<Icon name="accounts" class="w-6 h-6" />
|
||||
</span>
|
||||
<span class="text-sm">{{ rows.length ? '没有匹配的账号' : '还没有账号' }}</span>
|
||||
<button v-if="!rows.length" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-9" /> <!-- select -->
|
||||
<col /> <!-- identity (flex) -->
|
||||
<col class="w-20" /> <!-- type -->
|
||||
<col class="w-24" /> <!-- remaining -->
|
||||
<col class="w-32" /> <!-- reset -->
|
||||
<col class="w-28" /> <!-- created -->
|
||||
<col class="w-28" /> <!-- last used -->
|
||||
<col class="w-40" /> <!-- inflight/success/fail -->
|
||||
<col class="w-16" /> <!-- status switch -->
|
||||
<col class="w-16" /> <!-- actions -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-center px-3 py-3 font-medium">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
||||
class="chk" title="全选" />
|
||||
</th>
|
||||
<th class="text-left px-5 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-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-center 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in pagedItems" :key="a.pool + '/' + a.id"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"
|
||||
:class="a.dead && 'dead-row'">
|
||||
<!-- select -->
|
||||
<td class="px-3 py-3.5 align-middle text-center">
|
||||
<input type="checkbox" :checked="selected.has(a.id)" @change="toggleSelect(a.id)" @click.stop
|
||||
class="chk" />
|
||||
</td>
|
||||
<!-- identity -->
|
||||
<td class="px-5 py-3.5 align-middle">
|
||||
<!-- email + per-kind quota markers on one line. Both-limited shows as
|
||||
额度耗尽 in the status column, so here we only surface the single
|
||||
case. -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-white/90 truncate" :title="a.email || '-'">{{ a.email || '-' }}</span>
|
||||
<span v-if="a.team_id"
|
||||
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-mono bg-violet-500/15 text-violet-300 ring-1 ring-violet-400/20"
|
||||
:title="'Runway team_id ' + a.team_id">{{ a.team_id }}</span>
|
||||
<span v-if="a.image_limited && a.status !== 'quota'"
|
||||
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20"
|
||||
title="图片额度耗尽,仅视频可用">图片限额</span>
|
||||
<span v-if="a.video_limited && a.status !== 'quota'"
|
||||
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20"
|
||||
title="视频额度耗尽,仅图片可用">视频限额</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- type -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
|
||||
:class="typePill(a.type)">{{ a.type }}</span>
|
||||
</td>
|
||||
<!-- remaining -->
|
||||
<td class="px-3 py-3.5 align-middle text-right text-sm tabular-nums whitespace-nowrap">
|
||||
<!-- quota column: 数字 / — (never "未知"/"失败"/"检测中") -->
|
||||
<!-- remaining === -1 is the provider "unlimited" sentinel → show — not a scary red -1 -->
|
||||
<span v-if="(a.type === 'openai' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine') && a.remaining != null && a.remaining !== -1"
|
||||
class="font-mono font-semibold"
|
||||
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}</span>
|
||||
<span v-else class="text-white/25" :title="a._quotaError || ''">—</span>
|
||||
</td>
|
||||
<!-- reset_after -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="a.reset_after" class="leading-tight" :title="fmtIso(a.reset_after)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.reset_after) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.reset_after) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">—</span>
|
||||
</td>
|
||||
<!-- created_at -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div class="leading-tight" :title="fmtTs(a.created_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.created_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.created_at) }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- last_used_at -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="a.last_used_at" class="leading-tight" :title="fmtTs(a.last_used_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.last_used_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.last_used_at) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">从未</span>
|
||||
</td>
|
||||
<!-- inflight / success / fail -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<div class="flex items-center justify-center gap-1.5 text-xs tabular-nums">
|
||||
<span class="px-1.5 py-0.5 rounded"
|
||||
:class="a.in_flight ? 'bg-indigo-500/15 text-indigo-300 font-semibold' : 'text-white/25'"
|
||||
title="在途">{{ a.in_flight || 0 }}</span>
|
||||
<span class="text-white/20">/</span>
|
||||
<span class="px-1.5 py-0.5 rounded text-emerald-300 font-medium" title="成功">{{ a.success_total || 0 }}</span>
|
||||
<span class="text-white/20">/</span>
|
||||
<span class="px-1.5 py-0.5 rounded"
|
||||
:class="a.fail_total ? 'bg-rose-500/15 text-rose-300 font-medium' : 'text-white/25'"
|
||||
title="失败">{{ a.fail_total || 0 }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- status (switch) -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<button class="sw"
|
||||
:class="{ 'sw-on': a.status === 'active', 'sw-dead': a.dead, 'sw-pending': a.status === 'pending', 'sw-quota': a.status === 'quota', 'sw-locked': a.dead || a.status === 'pending' || a.status === 'quota' }"
|
||||
:disabled="a.dead || a.status === 'pending' || a.status === 'quota'"
|
||||
:aria-pressed="a.status === 'active'"
|
||||
:title="a.status === 'pending' ? '正在检测额度…(暂不调度)' : (a.dead ? '号已失效(401) · 已锁定(删除后重新导入有效令牌)' : (a.status === 'quota' ? '额度耗尽 · 已锁定,到恢复时间自动解开' : (a.status === 'active' ? '点击禁用' : '点击启用')))"
|
||||
@click="!(a.dead || a.status === 'pending' || a.status === 'quota') && toggleAccountStatus(a.pool, a.id, a.status)">
|
||||
<span class="sw-thumb"></span>
|
||||
</button>
|
||||
</td>
|
||||
<!-- actions -->
|
||||
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
||||
<button @click="deleteAccount(a.pool, a.id)" class="act danger" title="删除">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
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">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
||||
<span class="ml-1">/ {{ filtered.length }} 条</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>
|
||||
|
||||
<ImportModal v-if="showImport" @close="showImport = false" @imported="loadAccounts" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* --- filter pills (mirrors LogsView/UsersView/ModelsView) */
|
||||
.fp {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem; font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-rose {
|
||||
background: rgb(244 63 94 / 0.22);
|
||||
color: rgb(253 164 175);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
|
||||
}
|
||||
.fp-amber {
|
||||
background: rgb(245 158 11 / 0.22);
|
||||
color: rgb(253 224 71);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 224 71 / 0.4);
|
||||
}
|
||||
.fp-violet {
|
||||
background: rgb(139 92 246 / 0.22);
|
||||
color: rgb(196 181 253);
|
||||
box-shadow: inset 0 0 0 1px rgb(196 181 253 / 0.45);
|
||||
}
|
||||
.fp-sky {
|
||||
background: rgb(56 189 248 / 0.22);
|
||||
color: rgb(125 211 252);
|
||||
box-shadow: inset 0 0 0 1px rgb(125 211 252 / 0.45);
|
||||
}
|
||||
.fp-teal {
|
||||
background: rgb(20 184 166 / 0.22);
|
||||
color: rgb(94 234 212);
|
||||
box-shadow: inset 0 0 0 1px rgb(94 234 212 / 0.45);
|
||||
}
|
||||
|
||||
/* --- icon-only action buttons */
|
||||
.act {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.9rem; height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.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);
|
||||
}
|
||||
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
/* toolbar 「删除异常账号」按钮 — rose 变体 */
|
||||
.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); }
|
||||
|
||||
/* iOS-style switch (mirrors UsersView/ModelsView) */
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 2.25rem; height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.sw-thumb {
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw-on {
|
||||
background: rgb(16 185 129 / 0.7);
|
||||
box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5);
|
||||
}
|
||||
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
/* dead account (401) — red, thumb stays left */
|
||||
.sw-dead {
|
||||
background: rgb(244 63 94 / 0.8);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.6);
|
||||
}
|
||||
/* pending (import quota probe in flight) — neutral indigo, thumb stays left */
|
||||
.sw-pending {
|
||||
background: rgb(99 102 241 / 0.45);
|
||||
box-shadow: inset 0 0 0 1px rgb(99 102 241 / 0.4);
|
||||
}
|
||||
/* quota exhausted — amber (NOT red/dead), thumb stays left, locked until reset */
|
||||
.sw-quota {
|
||||
background: rgb(245 158 11 / 0.5);
|
||||
box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.45);
|
||||
}
|
||||
/* dead / pending toggle is locked — can't be flipped */
|
||||
.sw-locked { cursor: not-allowed; }
|
||||
/* tint the whole row so a dead account is obvious at a glance */
|
||||
.dead-row { background: rgb(244 63 94 / 0.07); }
|
||||
.dead-row:hover { background: rgb(244 63 94 / 0.12); }
|
||||
|
||||
/* --- numbered 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: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,383 @@
|
||||
<script setup>
|
||||
// Admin CDK (redeem code) management — generate fixed-amount codes, list,
|
||||
// copy, delete. Amounts are 积分.
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { fmtTs } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const items = ref([])
|
||||
const stats = ref({ total: 0, active: 0, redeemed: 0, active_amount: 0, redeemed_amount: 0 })
|
||||
const loading = ref(false)
|
||||
|
||||
// filters
|
||||
const statusFilter = ref('') // '' | 'active'(未使用) | 'used'(已使用)
|
||||
const typeFilter = ref('') // '' | 'normal' | 'marketing'
|
||||
const search = ref('')
|
||||
function setFilter(fn) { fn(); page.value = 1 }
|
||||
watch(search, () => { page.value = 1 })
|
||||
const filtered = computed(() => {
|
||||
let list = items.value
|
||||
if (statusFilter.value === 'active') list = list.filter((c) => c.status === 'active')
|
||||
else if (statusFilter.value === 'used') list = list.filter((c) => c.status !== 'active')
|
||||
if (typeFilter.value === 'marketing') list = list.filter((c) => c.type === 'marketing')
|
||||
else if (typeFilter.value === 'normal') list = list.filter((c) => c.type !== 'marketing')
|
||||
const q = search.value.trim().toUpperCase()
|
||||
if (q) list = list.filter((c) => (c.code || '').toUpperCase().includes(q))
|
||||
return list
|
||||
})
|
||||
|
||||
const form = ref({ amount: 5000, count: 10, type: 'normal' })
|
||||
const lastBatch = ref([]) // codes from the most recent generate
|
||||
const flashMsg = ref('')
|
||||
let flashTimer = null
|
||||
function flash(m) { flashMsg.value = m; clearTimeout(flashTimer); flashTimer = setTimeout(() => (flashMsg.value = ''), 2000) }
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const r = await api('/cdks')
|
||||
loading.value = false
|
||||
if (r.ok) { items.value = r.data?.data || []; stats.value = r.data?.stats || stats.value }
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
async function generate() {
|
||||
const amount = Number(form.value.amount), count = Number(form.value.count)
|
||||
if (!amount || amount <= 0) { flash('金额必须大于 0'); return }
|
||||
if (!count || count <= 0) { flash('数量必须大于 0'); return }
|
||||
const r = await api('/cdks', jsonBody('POST', { amount, count, type: form.value.type }))
|
||||
if (!r.ok) { flash(r.data?.detail || '生成失败'); return }
|
||||
lastBatch.value = (r.data?.created || []).map((c) => c.code)
|
||||
flash(`已生成 ${lastBatch.value.length} 个兑换码`)
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function del(code) {
|
||||
if (!confirm(`删除兑换码 ${code}?`)) return
|
||||
const r = await api(`/cdks/${code}`, { method: 'DELETE' })
|
||||
if (r.ok) {
|
||||
flash('已删除')
|
||||
await load()
|
||||
// deleting the last row on the last page would otherwise strand us on an
|
||||
// empty page past the end — clamp back into range.
|
||||
if (page.value > totalPages.value) page.value = totalPages.value
|
||||
} else flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
|
||||
// ===== 多选删除 =====
|
||||
const selected = ref(new Set())
|
||||
function toggleSelect(code) {
|
||||
const s = new Set(selected.value)
|
||||
s.has(code) ? s.delete(code) : s.add(code)
|
||||
selected.value = s
|
||||
}
|
||||
const allSelected = computed(() =>
|
||||
filtered.value.length > 0 && filtered.value.every((c) => selected.value.has(c.code)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) filtered.value.forEach((c) => s.delete(c.code))
|
||||
else filtered.value.forEach((c) => s.add(c.code))
|
||||
selected.value = s
|
||||
}
|
||||
async function delSelected() {
|
||||
const codes = [...selected.value]
|
||||
if (!codes.length) return
|
||||
if (!confirm(`确认删除选中的 ${codes.length} 个兑换码?此操作不可撤销。`)) return
|
||||
const r = await api('/cdks/delete-bulk', jsonBody('POST', { codes }))
|
||||
if (r.ok) {
|
||||
flash(`已删除 ${r.data?.deleted ?? codes.length} 个`)
|
||||
selected.value = new Set()
|
||||
await load()
|
||||
if (page.value > totalPages.value) page.value = totalPages.value
|
||||
} else flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
try { await navigator.clipboard.writeText(text); flash('已复制') } catch { flash('复制失败') }
|
||||
}
|
||||
function copyBatch() { copy(lastBatch.value.join('\n')) }
|
||||
|
||||
// Client-side pagination over the full list (CDK volumes are bounded by
|
||||
// how many the admin generates — comfortably small).
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filtered.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target !== page.value) page.value = target
|
||||
}
|
||||
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])
|
||||
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
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — same shape as LogsView / InvitesAdminView -->
|
||||
<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-white/45">总数</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-emerald-300/80">未使用</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.active }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/35">已使用</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-white/60">{{ stats.redeemed }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">未使用面额</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ Number(stats.active_amount || 0).toLocaleString('en-US') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- generate -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">生成兑换码</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">单个金额 (积分)</label>
|
||||
<input v-model.number="form.amount" type="number" min="1" step="1" class="field w-40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">数量</label>
|
||||
<input v-model.number="form.count" type="number" min="1" max="500" step="1" class="field w-28" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">类型</label>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" @click="form.type = 'normal'" class="fp" :class="form.type === 'normal' && 'fp-on'">普通</button>
|
||||
<button type="button" @click="form.type = 'marketing'" class="fp" :class="form.type === 'marketing' && 'fp-fuchsia'">营销</button>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="generate" class="btn-primary"><Icon name="plus" class="w-3.5 h-3.5" /> 生成</button>
|
||||
</div>
|
||||
<p v-if="form.type === 'marketing'" class="text-[11px] text-fuchsia-300/80 mt-2">营销兑换码:同一批次每个用户只能兑换一次。</p>
|
||||
|
||||
<!-- last batch -->
|
||||
<div v-if="lastBatch.length" class="mt-4 rounded-xl bg-white/[0.04] ring-1 ring-white/10 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-white/75">刚生成 {{ lastBatch.length }} 个 — 请复制保存</span>
|
||||
<button @click="copyBatch" class="text-xs btn-soft"><Icon name="copy" class="w-3.5 h-3.5" /> 全部复制</button>
|
||||
</div>
|
||||
<div class="font-mono text-xs text-white/85 space-y-0.5 max-h-40 overflow-auto">
|
||||
<div v-for="code in lastBatch" :key="code">{{ code }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button>
|
||||
<button @click="setFilter(() => typeFilter = 'normal')" class="fp" :class="typeFilter === 'normal' && 'fp-on'">普通</button>
|
||||
<button @click="setFilter(() => typeFilter = 'marketing')" class="fp" :class="typeFilter === 'marketing' && 'fp-fuchsia'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span>营销
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>未使用
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'used')" class="fp" :class="statusFilter === 'used' && 'fp-on'">已使用</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[160px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索兑换码…" />
|
||||
</div>
|
||||
<button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的兑换码">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
|
||||
</button>
|
||||
<button @click="load" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
|
||||
<div v-else-if="!items.length" class="text-center text-sm text-white/40 py-16">还没有兑换码</div>
|
||||
<div v-else-if="!filtered.length" class="text-center text-sm text-white/40 py-16">没有匹配的兑换码</div>
|
||||
<table v-else class="w-full text-sm">
|
||||
<colgroup>
|
||||
<col class="w-9" />
|
||||
<col />
|
||||
<col class="w-28" />
|
||||
<col class="w-28" />
|
||||
<col class="w-44" />
|
||||
<col class="w-44" />
|
||||
<col class="w-40" />
|
||||
<col class="w-20" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-center px-3 py-3 font-medium">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
||||
class="chk" title="全选" />
|
||||
</th>
|
||||
<th class="text-left px-5 py-3 font-medium">兑换码</th>
|
||||
<th class="text-right 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-5 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in pagedItems" :key="c.code"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<td class="px-3 py-3.5 align-middle text-center">
|
||||
<input type="checkbox" :checked="selected.has(c.code)" @change="toggleSelect(c.code)" @click.stop
|
||||
class="chk" />
|
||||
</td>
|
||||
<td class="px-5 py-3.5 align-middle font-mono text-xs text-white/90 truncate" :title="c.code">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="truncate">{{ c.code }}</span>
|
||||
<span v-if="c.type === 'marketing'" class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-sans font-medium bg-fuchsia-500/15 text-fuchsia-300 ring-1 ring-fuchsia-400/25">营销</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85 whitespace-nowrap">
|
||||
{{ Number(c.amount).toLocaleString('en-US') }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="c.status === 'active'
|
||||
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||
: 'bg-white/[0.06] text-white/55 ring-white/15'">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
:class="c.status === 'active' ? 'bg-emerald-400' : 'bg-slate-400'"></span>
|
||||
{{ c.status === 'active' ? '未使用' : '已使用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">{{ fmtTs(c.created_at) }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">{{ c.redeemed_at ? fmtTs(c.redeemed_at) : '—' }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 truncate">{{ c.redeemed_by_name || '—' }}</td>
|
||||
<td class="px-5 py-3.5 align-middle text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button @click="copy(c.code)" class="act" title="复制"><Icon name="copy" class="w-3.5 h-3.5" /></button>
|
||||
<button @click="del(c.code)" class="act danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
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">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(items.length, page * pageSize) }}</span>
|
||||
<span class="ml-1">/ {{ items.length }} 条</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>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="flashMsg" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-slate-900 text-white text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">{{ flashMsg }}</div>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* row icon action buttons — mirror 账号管理 for a consistent look */
|
||||
.act {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.9rem; height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.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);
|
||||
}
|
||||
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
.fp {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem; font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-fuchsia {
|
||||
background: rgb(217 70 239 / 0.22);
|
||||
color: rgb(245 208 254);
|
||||
box-shadow: inset 0 0 0 1px rgb(245 208 254 / 0.45);
|
||||
}
|
||||
|
||||
.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: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,509 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { site } from '../site'
|
||||
import TagInput from '../components/TagInput.vue'
|
||||
|
||||
// ---- logs (retention window) ----
|
||||
const logsCfg = reactive({ retention_days: 30 })
|
||||
const logsBusy = ref(false); const logsSaved = ref(false)
|
||||
async function loadLogs() {
|
||||
const r = await api('/settings/logs')
|
||||
if (r.ok && r.data) logsCfg.retention_days = Number(r.data.retention_days) || 30
|
||||
}
|
||||
async function saveLogs() {
|
||||
logsBusy.value = true; logsSaved.value = false
|
||||
const r = await api('/settings/logs', jsonBody('PUT', { retention_days: Number(logsCfg.retention_days) || 30 }))
|
||||
logsBusy.value = false
|
||||
if (r.ok) { logsSaved.value = true; setTimeout(() => (logsSaved.value = false), 2000) }
|
||||
}
|
||||
|
||||
// ---- media (生成图片/视频文件留存) ----
|
||||
const mediaCfg = reactive({ retention_days: 30 })
|
||||
const mediaBusy = ref(false); const mediaSaved = ref(false); const mediaRemoved = ref(0)
|
||||
async function loadMedia() {
|
||||
const r = await api('/settings/media')
|
||||
if (r.ok && r.data) mediaCfg.retention_days = Number(r.data.retention_days) || 30
|
||||
}
|
||||
async function saveMedia() {
|
||||
mediaBusy.value = true; mediaSaved.value = false; mediaRemoved.value = 0
|
||||
const r = await api('/settings/media', jsonBody('PUT', { retention_days: Number(mediaCfg.retention_days) || 30 }))
|
||||
mediaBusy.value = false
|
||||
if (r.ok) {
|
||||
mediaSaved.value = true
|
||||
mediaRemoved.value = Number(r.data?.removed || 0)
|
||||
setTimeout(() => (mediaSaved.value = false), 2500)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- site (branding shown across the app) ----
|
||||
const siteForm = reactive({ title: '', qq: '', qq_link: '', qq_group: '', qq_group_link: '', email: '', shop: '' })
|
||||
const siteBusy = ref(false); const siteSaved = ref(false)
|
||||
async function loadSite() {
|
||||
const r = await api('/settings/site')
|
||||
if (r.ok && r.data) {
|
||||
siteForm.title = r.data.title || ''
|
||||
const c = r.data.contact || {}
|
||||
siteForm.qq = c.qq || ''; siteForm.qq_link = c.qq_link || ''
|
||||
siteForm.qq_group = c.qq_group || ''
|
||||
siteForm.qq_group_link = c.qq_group_link || ''
|
||||
siteForm.email = c.email || ''; siteForm.shop = c.shop || ''
|
||||
}
|
||||
}
|
||||
async function saveSite() {
|
||||
siteBusy.value = true; siteSaved.value = false
|
||||
const r = await api('/settings/site', jsonBody('PUT', {
|
||||
title: siteForm.title,
|
||||
contact: { qq: siteForm.qq, qq_link: siteForm.qq_link, qq_group: siteForm.qq_group, qq_group_link: siteForm.qq_group_link, email: siteForm.email, shop: siteForm.shop },
|
||||
}))
|
||||
siteBusy.value = false
|
||||
if (r.ok && r.data) {
|
||||
// Mirror the change into the shared `site` store so every header /
|
||||
// wordmark / tab title updates without a reload. The PUT response is
|
||||
// nested ({ ok, data: { title } }) unlike the flat GET, so read the
|
||||
// saved value from there — falling back to the input we just submitted.
|
||||
site.title = r.data.data?.title || siteForm.title.trim()
|
||||
site.contact = r.data.data?.contact || site.contact
|
||||
siteSaved.value = true
|
||||
setTimeout(() => (siteSaved.value = false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- registration ----
|
||||
const reg = reactive({ open: true, email_code: false, allow_password_reset: true })
|
||||
// Email domain whitelist edited as tag chips so admins can't typo a comma
|
||||
// out of a domain or accidentally leave a stray space.
|
||||
const domains = ref([])
|
||||
const regBusy = ref(false); const regSaved = ref(false)
|
||||
|
||||
// ---- smtp ----
|
||||
const smtp = reactive({ host: '', port: 587, username: '', password: '', from_addr: '', use_tls: true })
|
||||
const smtpBusy = ref(false); const smtpSaved = ref(false)
|
||||
|
||||
// ---- rewards ----
|
||||
const credits = reactive({ checkin_enabled: true, checkin_reward: 3, invite_enabled: true, invite_reward: 3 })
|
||||
const credBusy = ref(false); const credSaved = ref(false)
|
||||
|
||||
// ---- proxy (carried when calling upstream during generation) ----
|
||||
const proxy = reactive({ proxy: '' })
|
||||
const proxyBusy = ref(false); const proxySaved = ref(false)
|
||||
async function loadProxy() {
|
||||
const r = await api('/settings/proxy')
|
||||
if (r.ok && r.data) proxy.proxy = r.data.proxy || ''
|
||||
}
|
||||
async function saveProxy() {
|
||||
proxyBusy.value = true; proxySaved.value = false
|
||||
const r = await api('/settings/proxy', jsonBody('PUT', { proxy: proxy.proxy }))
|
||||
proxyBusy.value = false
|
||||
if (r.ok) { proxySaved.value = true; setTimeout(() => (proxySaved.value = false), 2000) }
|
||||
}
|
||||
// Probe the currently-entered proxy (not necessarily saved) — surfaces the
|
||||
// egress IP on success, or the concrete dial/DNS error on failure.
|
||||
const proxyTestBusy = ref(false)
|
||||
const proxyTest = reactive({ ok: null, msg: '' }) // ok: null=idle, true/false=result
|
||||
async function testProxy() {
|
||||
proxyTestBusy.value = true; proxyTest.ok = null; proxyTest.msg = ''
|
||||
const r = await api('/settings/proxy/test', jsonBody('POST', { proxy: proxy.proxy }))
|
||||
proxyTestBusy.value = false
|
||||
if (r.ok && r.data?.ok) {
|
||||
proxyTest.ok = true
|
||||
const ip = r.data.data?.exit_ip || '未知'
|
||||
const ms = r.data.data?.elapsed_ms
|
||||
proxyTest.msg = `连接成功 · 出口 IP ${ip}${ms != null ? ` · ${ms}ms` : ''}`
|
||||
} else {
|
||||
proxyTest.ok = false
|
||||
proxyTest.msg = r.data?.detail || '代理测试失败'
|
||||
}
|
||||
}
|
||||
|
||||
// Email-code requires SMTP to be configured (host saved) first.
|
||||
const smtpConfigured = computed(() => !!(smtp.host || '').trim())
|
||||
|
||||
// SMTP save requires the four required fields to be non-empty. Password is
|
||||
// optional on update — leaving it blank means "keep the current one".
|
||||
const smtpReady = computed(() =>
|
||||
(smtp.host || '').trim() &&
|
||||
Number(smtp.port) > 0 &&
|
||||
(smtp.username || '').trim() &&
|
||||
(smtp.from_addr || '').trim()
|
||||
)
|
||||
|
||||
async function loadReg() {
|
||||
const r = await api('/settings/registration')
|
||||
if (r.ok && r.data) {
|
||||
Object.assign(reg, { open: r.data.open, email_code: r.data.email_code,
|
||||
allow_password_reset: r.data.allow_password_reset })
|
||||
// Normalise to lowercase + strip a leading @ on each so the chips render
|
||||
// exactly what the server will compare against.
|
||||
domains.value = (r.data.allowed_email_domains || [])
|
||||
.map((d) => String(d).trim().toLowerCase().replace(/^@/, ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
async function loadSmtp() {
|
||||
const r = await api('/settings/smtp')
|
||||
if (r.ok && r.data) Object.assign(smtp, r.data)
|
||||
}
|
||||
async function loadCredits() {
|
||||
const r = await api('/settings/credits')
|
||||
if (r.ok && r.data) Object.assign(credits, r.data)
|
||||
}
|
||||
|
||||
async function saveReg() {
|
||||
regBusy.value = true; regSaved.value = false
|
||||
const r = await api('/settings/registration', jsonBody('PUT', {
|
||||
open: reg.open, email_code: reg.email_code,
|
||||
allow_password_reset: reg.allow_password_reset,
|
||||
allowed_email_domains: domains.value,
|
||||
}))
|
||||
regBusy.value = false
|
||||
if (r.ok) { regSaved.value = true; setTimeout(() => (regSaved.value = false), 2000); loadReg() }
|
||||
}
|
||||
async function saveSmtp() {
|
||||
smtpBusy.value = true; smtpSaved.value = false
|
||||
const payload = { host: smtp.host, port: Number(smtp.port) || 587, username: smtp.username,
|
||||
from_addr: smtp.from_addr, use_tls: smtp.use_tls }
|
||||
if (smtp.password && smtp.password !== '***') payload.password = smtp.password
|
||||
const r = await api('/settings/smtp', jsonBody('PUT', payload))
|
||||
smtpBusy.value = false
|
||||
if (r.ok) { smtpSaved.value = true; setTimeout(() => (smtpSaved.value = false), 2000); loadSmtp() }
|
||||
}
|
||||
|
||||
// ---- SMTP test send ----
|
||||
const testEmail = ref('')
|
||||
const testBusy = ref(false)
|
||||
const testMsg = ref('') // result message (success or error)
|
||||
const testOk = ref(false)
|
||||
async function sendTest() {
|
||||
const to = (testEmail.value || '').trim()
|
||||
if (!to || !to.includes('@')) { testMsg.value = '请填写有效的收件邮箱'; testOk.value = false; return }
|
||||
testBusy.value = true; testMsg.value = ''
|
||||
// Tests the SAVED config, so save first if you just edited the fields.
|
||||
const r = await api('/settings/smtp/test', jsonBody('POST', { email: to }))
|
||||
testBusy.value = false
|
||||
testOk.value = r.ok
|
||||
testMsg.value = r.ok ? (r.data?.detail || `测试邮件已发送至 ${to}`) : (r.data?.detail || `发送失败 (${r.status})`)
|
||||
}
|
||||
async function saveCredits() {
|
||||
credBusy.value = true; credSaved.value = false
|
||||
const r = await api('/settings/credits', jsonBody('PUT', {
|
||||
checkin_enabled: credits.checkin_enabled,
|
||||
checkin_reward: Number(credits.checkin_reward) || 0,
|
||||
invite_enabled: credits.invite_enabled,
|
||||
invite_reward: Number(credits.invite_reward) || 0,
|
||||
}))
|
||||
credBusy.value = false
|
||||
if (r.ok) { credSaved.value = true; setTimeout(() => (credSaved.value = false), 2000) }
|
||||
}
|
||||
|
||||
onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadProxy(); loadLogs(); loadMedia() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<!-- site -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">网站</h2>
|
||||
<span v-if="siteSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="row">
|
||||
<span><span class="lbl">网页主标题</span><span class="hint">显示在浏览器标签、首页 Logo、侧栏和登录卡上。未设置时默认显示 "Vivid"。</span></span>
|
||||
<input v-model="siteForm.title" placeholder="Vivid" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">联系 QQ</span><span class="hint">QQ 号(显示用)。留空则不显示该项。</span></span>
|
||||
<input v-model="siteForm.qq" placeholder="1114639355" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">QQ 链接</span><span class="hint">加好友链接(qm.qq.com/...)。填了则「关于」里的 QQ 可点击、新标签打开。</span></span>
|
||||
<input v-model="siteForm.qq_link" placeholder="https://qm.qq.com/q/ItgCcNA7ac" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">QQ 群</span><span class="hint">交流群号(显示用)。留空则不显示。</span></span>
|
||||
<input v-model="siteForm.qq_group" placeholder="1106849765" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">QQ 群链接</span><span class="hint">加群链接(qm.qq.com/...)。填了则「关于」里的 QQ 群可点击、新标签打开。</span></span>
|
||||
<input v-model="siteForm.qq_group_link" placeholder="https://qm.qq.com/q/976LeMFoHu" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">联系邮箱</span><span class="hint">首页「联系我们」里可点击发邮件。留空则不显示。</span></span>
|
||||
<input v-model="siteForm.email" placeholder="vividairun@gmail.com" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">商店地址</span><span class="hint">充值/购买页链接,首页「联系我们」里展示为"前往充值商店"。留空则不显示。</span></span>
|
||||
<input v-model="siteForm.shop" placeholder="https://pay.ldxp.cn/shop/chiyi" class="txt" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button @click="saveSite" :disabled="siteBusy || !siteForm.title.trim()" class="btn-primary">{{ siteBusy ? '保存中…' : '保存设置' }}</button>
|
||||
<span v-if="!siteForm.title.trim()" class="text-xs text-slate-400">请输入主标题</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- registration -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">注册与登录</h2>
|
||||
<span v-if="regSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="row">
|
||||
<span><span class="lbl">开放注册</span><span class="hint">关闭后只能由管理员手动创建账号(首个账号不受限)。</span></span>
|
||||
<input type="checkbox" v-model="reg.open" class="sw" />
|
||||
</label>
|
||||
<label class="row" :class="!smtpConfigured && 'opacity-50'">
|
||||
<span><span class="lbl">注册/找回需要邮箱验证码</span><span class="hint">启用后注册、找回密码需输入邮件发送的验证码。<b v-if="!smtpConfigured">需先在下方配置并保存 SMTP 才能开启。</b></span></span>
|
||||
<input type="checkbox" v-model="reg.email_code" :disabled="!smtpConfigured" class="sw" />
|
||||
</label>
|
||||
<label class="row" :class="!reg.email_code && 'opacity-50'">
|
||||
<span><span class="lbl">支持找回密码</span><span class="hint">凭邮箱+邮件验证码重置。<b v-if="!reg.email_code">需先开启「邮箱验证码」才能启用。</b></span></span>
|
||||
<!-- When email_code is off, password reset is effectively disabled
|
||||
regardless of the stored flag (the auth endpoint gates it the
|
||||
same way). Reflect that by showing the switch off — but keep
|
||||
the stored value untouched so re-enabling email_code restores
|
||||
the user's prior preference. -->
|
||||
<input type="checkbox"
|
||||
:checked="reg.email_code && reg.allow_password_reset"
|
||||
:disabled="!reg.email_code"
|
||||
@change="reg.allow_password_reset = $event.target.checked"
|
||||
class="sw" />
|
||||
</label>
|
||||
<!-- Stack the tag input under the label so the chips have room -->
|
||||
<div class="row !block !border-b-0">
|
||||
<div class="mb-2">
|
||||
<span class="lbl">允许的邮箱后缀</span><br />
|
||||
<span class="hint">输入后缀按回车添加(如 gmail.com),点 × 删除。<b>留空 = 不限制</b>,允许任意域名注册。</span>
|
||||
</div>
|
||||
<TagInput v-model="domains" placeholder="留空 = 不限制,或输入后缀回车添加" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button @click="saveReg" :disabled="regBusy" class="btn-primary">{{ regBusy ? '保存中…' : '保存设置' }}</button>
|
||||
<span v-if="!domains.length" class="text-xs text-slate-400">未设置后缀 = 不限制邮箱域名</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMTP -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">邮件服务 (SMTP)</h2>
|
||||
<span v-if="smtpSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-4">用于发送注册 / 找回密码的验证码邮件。465 端口自动用 SSL,其余端口可选 STARTTLS。</p>
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div><label class="flbl">SMTP 主机</label><input v-model="smtp.host" placeholder="smtp.gmail.com" class="field" /></div>
|
||||
<div><label class="flbl">端口</label><input type="number" v-model.number="smtp.port" placeholder="587" class="field" /></div>
|
||||
<div><label class="flbl">用户名</label><input v-model="smtp.username" placeholder="you@gmail.com" class="field" /></div>
|
||||
<div><label class="flbl">密码 / 授权码</label><input v-model="smtp.password" type="password" placeholder="留空表示不修改" class="field" /></div>
|
||||
<div><label class="flbl">发件地址 (From)</label><input v-model="smtp.from_addr" placeholder="no-reply@yourdomain.com" class="field" /></div>
|
||||
</div>
|
||||
<!-- STARTTLS sits below the grid as its own labelled row, matching the
|
||||
layout of the toggle-style settings in 注册与登录 / 积分奖励 -->
|
||||
<label class="row mt-2">
|
||||
<span><span class="lbl">使用 STARTTLS</span><span class="hint">端口 587 等明文端口加密会话;465 端口已自动用 SSL,无需开启。</span></span>
|
||||
<input type="checkbox" v-model="smtp.use_tls" class="sw" />
|
||||
</label>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button @click="saveSmtp" :disabled="smtpBusy || !smtpReady" class="btn-primary">{{ smtpBusy ? '保存中…' : '保存设置' }}</button>
|
||||
<span v-if="!smtpReady" class="text-xs text-slate-400">请填写 主机 / 端口 / 用户名 / 发件地址</span>
|
||||
</div>
|
||||
|
||||
<!-- Test send: verifies the SAVED config actually delivers mail -->
|
||||
<div class="mt-4 pt-4 border-t border-white/[0.06]">
|
||||
<label class="flbl">测试发送 <span class="text-slate-400 font-normal">(用已保存的配置发一封测试邮件验证)</span></label>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<input v-model="testEmail" type="email" placeholder="收件邮箱,如 you@example.com" class="field flex-1" @keyup.enter="sendTest" />
|
||||
<button @click="sendTest" :disabled="testBusy || !smtpConfigured" class="btn-soft whitespace-nowrap">
|
||||
{{ testBusy ? '发送中…' : '发送测试' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!smtpConfigured" class="text-xs text-slate-400 mt-1.5">请先保存 SMTP 配置后再测试。</p>
|
||||
<p v-else-if="testMsg" class="text-xs mt-1.5" :class="testOk ? 'text-emerald-300' : 'text-rose-300'">{{ testMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- proxy -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">代理 (生图请求)</h2>
|
||||
<span v-if="proxySaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-4">调用上游生成图片/视频时统一使用的 HTTP 代理,留空 = 直连。格式如 <code class="px-1 bg-slate-100 rounded">http://127.0.0.1:7890</code>。修改即时生效,无需重启。</p>
|
||||
<input v-model="proxy.proxy" placeholder="留空 = 直连,如 http://127.0.0.1:7890" class="field" />
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button @click="saveProxy" :disabled="proxyBusy" class="btn-primary">{{ proxyBusy ? '保存中…' : '保存设置' }}</button>
|
||||
<button @click="testProxy" :disabled="proxyTestBusy || !proxy.proxy.trim()" class="btn-ghost">{{ proxyTestBusy ? '测试中…' : '代理测试' }}</button>
|
||||
</div>
|
||||
<p v-if="proxyTest.msg" class="text-xs mt-2" :class="proxyTest.ok ? 'text-emerald-300' : 'text-rose-300'">{{ proxyTest.msg }}</p>
|
||||
</div>
|
||||
|
||||
<!-- rewards -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">积分奖励</h2>
|
||||
<span v-if="credSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="row">
|
||||
<span><span class="lbl">开启每日签到</span><span class="hint">关闭后用户无法签到领取积分。</span></span>
|
||||
<input type="checkbox" v-model="credits.checkin_enabled" class="sw" />
|
||||
</label>
|
||||
<label class="row" :class="!credits.checkin_enabled && 'opacity-50'">
|
||||
<span><span class="lbl">每日签到奖励</span><span class="hint">用户每天签到获得的积分。</span></span>
|
||||
<input type="number" min="0" v-model.number="credits.checkin_reward" :disabled="!credits.checkin_enabled" class="num" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">开启邀请奖励</span><span class="hint">关闭后邀请好友不再发放积分奖励。</span></span>
|
||||
<input type="checkbox" v-model="credits.invite_enabled" class="sw" />
|
||||
</label>
|
||||
<label class="row" :class="!credits.invite_enabled && 'opacity-50'">
|
||||
<span><span class="lbl">邀请奖励</span><span class="hint">被邀请好友首次生图后,邀请人获得的积分。</span></span>
|
||||
<input type="number" min="0" v-model.number="credits.invite_reward" :disabled="!credits.invite_enabled" class="num" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4"><button @click="saveCredits" :disabled="credBusy" class="btn-primary">{{ credBusy ? '保存中…' : '保存设置' }}</button></div>
|
||||
</div>
|
||||
|
||||
<!-- logs retention -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">日志</h2>
|
||||
<span v-if="logsSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="row">
|
||||
<span>
|
||||
<span class="lbl">最大留存时间</span>
|
||||
<span class="hint">超过这个天数的日志会被自动清除,内存里同时还有 500 条的硬上限。范围 1–365 天,默认 30。</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="number" min="1" max="365" v-model.number="logsCfg.retention_days" class="num" />
|
||||
<span class="text-xs text-white/45">天</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4"><button @click="saveLogs" :disabled="logsBusy || !logsCfg.retention_days" class="btn-primary">{{ logsBusy ? '保存中…' : '保存设置' }}</button></div>
|
||||
</div>
|
||||
|
||||
<!-- media (生成文件) -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">生成文件 (图片 / 视频)</h2>
|
||||
<span v-if="mediaSaved" class="text-xs text-emerald-300">
|
||||
已保存 ✓<span v-if="mediaRemoved" class="text-white/45"> · 立即清理 {{ mediaRemoved }} 个文件</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="row">
|
||||
<span>
|
||||
<span class="lbl">最大留存时间</span>
|
||||
<span class="hint">超过该天数的生成文件(包括用户生图和后台测试图)会被自动删除。范围 1–365 天,默认 30。每 5 分钟最多扫一次,保存设置时会立刻清理一次。</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="number" min="1" max="365" v-model.number="mediaCfg.retention_days" class="num" />
|
||||
<span class="text-xs text-white/45">天</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4"><button @click="saveMedia" :disabled="mediaBusy || !mediaCfg.retention_days" class="btn-primary">{{ mediaBusy ? '保存中…' : '保存设置' }}</button></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Colors here pair with the dark admin shell (`.public-dark` wraps <main>).
|
||||
We use white-alpha instead of slate-* so they hold up against the glass
|
||||
card background; the older slate values were authored for the white
|
||||
light-mode admin and washed out almost completely. */
|
||||
.row { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; padding: 0.75rem 0; border-bottom: 1px solid var(--hairline); }
|
||||
.row:last-child { border-bottom: none; }
|
||||
.row > span:first-child { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.lbl { font-weight: 500; color: var(--fg); font-size: 0.875rem; }
|
||||
.hint { font-size: 0.72rem; color: var(--fg-3); line-height: 1.5; }
|
||||
.hint b { color: rgb(225 29 72); font-weight: 500; }
|
||||
html.dark .hint b { color: rgb(253 164 175); } /* rose — visible warning, not pure red */
|
||||
.flbl { display: block; font-size: 0.72rem; color: var(--fg-3); margin-bottom: 0.35rem; }
|
||||
/* Pill toggle switch — applied to <input type="checkbox" class="sw">. Keeps
|
||||
the markup as-is so the existing v-model bindings keep working, but the
|
||||
control now reads as an on/off slider instead of a tick box. The "locked
|
||||
but currently on" state (e.g. allow_password_reset when email_code is off)
|
||||
now reads as a disabled-but-on switch, which matches user intuition. */
|
||||
.sw {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 2.25rem;
|
||||
height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(203 213 225); /* slate-300 */
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease;
|
||||
outline: none;
|
||||
}
|
||||
.sw::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: calc(1.3rem - 4px);
|
||||
height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.2);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw:checked { background: #4f46e5; } /* indigo-600 — matches btn-primary */
|
||||
.sw:checked::after { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
.sw:focus-visible { box-shadow: 0 0 0 3px rgb(99 102 241 / 0.25); }
|
||||
.sw:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
/* All three input variants now share the same dark-glass surface as the rest
|
||||
of the admin shell. Background + border use white-alpha so they read
|
||||
against the card; placeholders are tuned for legibility, not noise. */
|
||||
.num, .txt, .field {
|
||||
background: rgb(15 23 42 / 0.03);
|
||||
border: 1px solid var(--hairline);
|
||||
color: var(--fg);
|
||||
border-radius: 0.55rem;
|
||||
outline: none;
|
||||
transition: border-color 0.18s, background 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
html.dark .num, html.dark .txt, html.dark .field {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
color: white;
|
||||
}
|
||||
.num::placeholder, .txt::placeholder, .field::placeholder { color: var(--fg-faint); }
|
||||
.num:focus, .txt:focus, .field:focus {
|
||||
border-color: rgb(167 139 250 / 0.65);
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
box-shadow: 0 0 0 3px rgb(167 139 250 / 0.15);
|
||||
}
|
||||
.num:disabled, .txt:disabled, .field:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.num { width: 6rem; padding: 0.4rem 0.55rem; font-size: 0.8rem; text-align: right; }
|
||||
.txt { width: 16rem; max-width: 60%; padding: 0.4rem 0.65rem; font-size: 0.8rem; }
|
||||
.field { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; }
|
||||
|
||||
/* Section save buttons — solid violet to match brand. The global .btn-primary
|
||||
under .public-dark goes to white; we override here so the save action stays
|
||||
visually distinct as the primary action on a form. */
|
||||
.btn-primary {
|
||||
padding: 0.55rem 1.15rem; border-radius: 0.6rem;
|
||||
font-size: 0.8rem; font-weight: 600; color: white;
|
||||
background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%);
|
||||
box-shadow: 0 8px 20px -8px rgb(168 85 247 / 0.55);
|
||||
transition: filter 0.15s, transform 0.12s, box-shadow 0.18s, opacity 0.15s;
|
||||
}
|
||||
/* Re-assert the gradient on hover for ALL states: the global
|
||||
`.public-dark .btn-primary:hover` in style.css repaints the background white
|
||||
with no `:not(:disabled)` guard, so even disabled save buttons (注册与登录 /
|
||||
邮件服务 default to disabled) flashed white on hover. Keep this rule
|
||||
unconditional; only the brightness/lift below is gated on :not(:disabled). */
|
||||
.btn-primary:hover { background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%); }
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); box-shadow: 0 10px 24px -8px rgb(168 85 247 / 0.7); }
|
||||
.btn-primary:active:not(:disabled) { transform: translateY(1px); }
|
||||
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup>
|
||||
// API 对接文档 — OpenAI-compatible. Lists live models and shows ready-to-run
|
||||
// curl / Python(openai SDK) examples for image + video, wired to this
|
||||
// deployment's base URL and the caller's model ids.
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { points } from '../credits'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
|
||||
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'YOUR_API_KEY')
|
||||
|
||||
const models = ref([])
|
||||
onMounted(async () => {
|
||||
const r = await api('/managed-models')
|
||||
if (r.ok) models.value = (r.data?.data || []).filter((m) => m.enabled !== false)
|
||||
})
|
||||
|
||||
const imageModels = computed(() => models.value.filter((m) => m.type === 'image'))
|
||||
const videoModels = computed(() => models.value.filter((m) => m.type === 'video'))
|
||||
const sampleImage = computed(() => imageModels.value[0]?.id || 'firefly-image-4')
|
||||
const sampleVideo = computed(() => videoModels.value[0]?.id || 'firefly-kling3')
|
||||
const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, ''))
|
||||
|
||||
function priceOf(m) {
|
||||
if (m.type === 'video') {
|
||||
// Video charge = resolution price + duration price; show the combined range.
|
||||
const rv = Object.values(m.prices || {}).filter((v) => v != null).map(Number)
|
||||
const dv = Object.values(m.duration_prices || {}).filter((v) => v != null).map(Number)
|
||||
if (!rv.length || !dv.length) return '—'
|
||||
const lo = Math.min(...rv) + Math.min(...dv)
|
||||
const hi = Math.max(...rv) + Math.max(...dv)
|
||||
return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分`
|
||||
}
|
||||
const vals = Object.values(m.prices || {}).filter((v) => v != null).map(Number)
|
||||
if (!vals.length) return '—'
|
||||
const lo = Math.min(...vals), hi = Math.max(...vals)
|
||||
return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分`
|
||||
}
|
||||
|
||||
// ---- request parameter tables ----
|
||||
const imageParams = [
|
||||
['model', 'string', '必填', '模型 id,见上表(图像)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['size', 'string', '可选', '"1024x1024" / "1536x1024" / "1024x1536" / "auto" → 决定比例'],
|
||||
['quality', 'string', '可选', '"low"|"medium"|"high"|"auto" → 画质档 1K/2K/4K(钳到模型支持档)'],
|
||||
]
|
||||
const editParams = [
|
||||
['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'],
|
||||
['prompt', 'string', '必填', '编辑/参考描述'],
|
||||
['model', 'string', '必填', '模型 id(需支持图生图)'],
|
||||
['size', 'string', '可选', '同图像:决定比例'],
|
||||
['quality', 'string', '可选', '同图像:决定画质档'],
|
||||
]
|
||||
const videoParams = [
|
||||
['model', 'string', '必填', '模型 id,见上表(视频)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['seconds', 'string|int', '必填', '时长秒数,如 "5" "8"(取决于模型支持)'],
|
||||
['size', 'string', '可选', '如 "1280x720" / "720x1280" → 决定比例与分辨率'],
|
||||
['input_reference', 'file', '可选', '首帧/参考图(multipart 文件;runway 图生视频必填 1 张)'],
|
||||
]
|
||||
|
||||
// ---- examples (built in script so refs resolve correctly) ----
|
||||
const examples = computed(() => [
|
||||
{
|
||||
title: '文生图 · curl',
|
||||
code:
|
||||
`curl ${base.value}/v1/images/generations \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "${sampleImage.value}",
|
||||
"prompt": "a corgi running in a golden wheat field, cinematic",
|
||||
"size": "1024x1024",
|
||||
"quality": "high"
|
||||
}'`,
|
||||
},
|
||||
{
|
||||
title: '文生图 · Python (openai SDK)',
|
||||
code:
|
||||
`import base64
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||
|
||||
resp = client.images.generate(
|
||||
model="${sampleImage.value}",
|
||||
prompt="a corgi running in a golden wheat field, cinematic",
|
||||
size="1024x1024",
|
||||
quality="high",
|
||||
)
|
||||
# 结果是 base64(无 URL)
|
||||
with open("out.png", "wb") as f:
|
||||
f.write(base64.b64decode(resp.data[0].b64_json))`,
|
||||
},
|
||||
{
|
||||
title: '图生图 / 参考图 · curl (multipart)',
|
||||
code:
|
||||
`curl ${base.value}/v1/images/edits \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-F model="${sampleImage.value}" \\
|
||||
-F prompt="把这张图改成赛博朋克风格" \\
|
||||
-F quality="high" \\
|
||||
-F image=@input.png
|
||||
# 多张参考图:重复 -F image=@a.png -F image=@b.png`,
|
||||
},
|
||||
{
|
||||
title: '图生图 · Python (openai SDK)',
|
||||
code:
|
||||
`import base64
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||
|
||||
resp = client.images.edit(
|
||||
model="${sampleImage.value}",
|
||||
image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")]
|
||||
prompt="把这张图改成赛博朋克风格",
|
||||
)
|
||||
with open("out.png", "wb") as f:
|
||||
f.write(base64.b64decode(resp.data[0].b64_json))`,
|
||||
},
|
||||
{
|
||||
title: '视频 · curl(创建 → 轮询 → 下载)',
|
||||
code:
|
||||
`# 1) 创建任务 → 立即返回 {"id": "...", "status": "queued"}
|
||||
curl ${base.value}/v1/videos \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "${sampleVideo.value}",
|
||||
"prompt": "a paper boat sailing down a rainy street, cinematic",
|
||||
"seconds": "${sampleSeconds.value}",
|
||||
"size": "1280x720"
|
||||
}'
|
||||
|
||||
# 2) 轮询状态,直到 status=completed
|
||||
curl ${base.value}/v1/videos/<VIDEO_ID> \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"
|
||||
|
||||
# 3) 下载 mp4(完成后)
|
||||
curl ${base.value}/v1/videos/<VIDEO_ID>/content \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" -o out.mp4`,
|
||||
},
|
||||
{
|
||||
title: '视频 · Python (requests, 轮询)',
|
||||
code:
|
||||
`import time, requests
|
||||
|
||||
base = "${base.value}/v1"
|
||||
h = {"Authorization": "Bearer ${keyHint.value}"}
|
||||
|
||||
# 1) 创建
|
||||
job = requests.post(f"{base}/videos", headers=h, json={
|
||||
"model": "${sampleVideo.value}",
|
||||
"prompt": "a paper boat sailing down a rainy street",
|
||||
"seconds": "${sampleSeconds.value}",
|
||||
"size": "1280x720",
|
||||
}).json()
|
||||
vid = job["id"]
|
||||
|
||||
# 2) 轮询
|
||||
while True:
|
||||
s = requests.get(f"{base}/videos/{vid}", headers=h).json()
|
||||
if s["status"] in ("completed", "failed"):
|
||||
break
|
||||
time.sleep(5)
|
||||
|
||||
# 3) 下载
|
||||
if s["status"] == "completed":
|
||||
mp4 = requests.get(f"{base}/videos/{vid}/content", headers=h).content
|
||||
open("out.mp4", "wb").write(mp4)`,
|
||||
},
|
||||
{
|
||||
title: '列出模型 · curl',
|
||||
code:
|
||||
`curl ${base.value}/v1/models \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"`,
|
||||
},
|
||||
])
|
||||
|
||||
// ---- copy + toast ----
|
||||
const toastMsg = ref('')
|
||||
let t = null
|
||||
function toast(m) { toastMsg.value = m; clearTimeout(t); t = setTimeout(() => (toastMsg.value = ''), 1800) }
|
||||
async function copy(text) {
|
||||
try { await navigator.clipboard.writeText(text); toast('已复制') } catch { toast('复制失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-text space-y-10">
|
||||
<header>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">开发者</div>
|
||||
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1>
|
||||
<p class="text-white/45 mt-2">完全兼容 OpenAI 接口规范 — 改个 <code class="text-white/70">base_url</code> 和 <code class="text-white/70">api_key</code> 即可直接调用。图像 / 视频 / 图生图全支持。</p>
|
||||
</header>
|
||||
|
||||
<!-- quickstart -->
|
||||
<section class="grid md:grid-cols-2 gap-4">
|
||||
<div class="card p-6">
|
||||
<h2 class="text-sm font-semibold text-white/80">基础信息</h2>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">Base URL</dt><dd class="font-mono text-white/90">{{ base }}/v1</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">鉴权</dt><dd class="font-mono text-white/90">Authorization: Bearer <key></dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">你的 Key</dt><dd class="font-mono text-white/70">{{ keyHint }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="text-[11px] text-white/40 mt-4">还没有 Key?去 <router-link to="/settings" class="text-violet-300 underline">设置 → API Key</router-link> 生成。</p>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<h2 class="text-sm font-semibold text-white/80">端点</h2>
|
||||
<ul class="mt-4 space-y-2.5 text-sm font-mono">
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/generations</span><span class="text-white/35 font-sans text-xs">文生图</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/edits</span><span class="text-white/35 font-sans text-xs">图生图(multipart)</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/videos</span><span class="text-white/35 font-sans text-xs">建视频任务</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}</span><span class="text-white/35 font-sans text-xs">查状态</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}/content</span><span class="text-white/35 font-sans text-xs">下载 mp4</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- models -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">可用模型</h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-3 font-medium">model</th>
|
||||
<th class="px-4 py-3 font-medium">类型</th>
|
||||
<th class="px-4 py-3 font-medium">分辨率 / 时长</th>
|
||||
<th class="px-4 py-3 font-medium text-right">价格</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in models" :key="m.id" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-3 font-mono text-white/90">{{ m.id }}</td>
|
||||
<td class="px-4 py-3 text-white/60">{{ m.type === 'video' ? '视频' : '图像' }}</td>
|
||||
<td class="px-4 py-3 text-white/60">{{ (m.type === 'video' ? m.durations : m.resolutions || [])?.join(' · ') || '—' }}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-white/80">{{ priceOf(m) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!models.length"><td colspan="4" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- parameters -->
|
||||
<section class="grid lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">文生图参数 <span class="text-xs font-normal text-white/40">/v1/images/generations</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in imageParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">图生图参数 <span class="text-xs font-normal text-white/40">/v1/images/edits · multipart</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in editParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-lg font-semibold mb-3">视频参数 <span class="text-xs font-normal text-white/40">/v1/videos · 异步</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in videoParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- examples -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-lg font-semibold">调用示例</h2>
|
||||
<div v-for="ex in examples" :key="ex.title" class="card overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-white/[0.06]">
|
||||
<span class="text-xs text-white/55">{{ ex.title }}</span>
|
||||
<button @click="copy(ex.code)" class="text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors">
|
||||
<Icon name="copy" class="w-3.5 h-3.5" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<pre class="p-4 text-[12px] leading-relaxed text-white/80 overflow-auto"><code>{{ ex.code }}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- responses -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
|
||||
<div class="card p-6 space-y-3 text-sm text-white/70">
|
||||
<p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "b64_json": "..." }] }' }}</code> —— 产物以 <strong class="text-white/90">base64</strong> 直接放在 <code class="text-white/85 font-mono">data[0].b64_json</code>(原始 base64、无 <code class="text-white/70">data:</code> 前缀),自行解码保存为图片。<strong class="text-white/90">不返回 URL、服务端不留存</strong>。</p>
|
||||
<p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1">
|
||||
<li><code class="text-white/85 font-mono">POST /v1/videos</code> 立即返回任务对象 <code class="text-white/85 font-mono">{{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}</code></li>
|
||||
<li>轮询 <code class="text-white/85 font-mono">GET /v1/videos/{id}</code>,<code class="text-white/70">status</code> 从 <code class="text-white/70">queued → in_progress → completed</code>(或 <code class="text-white/70">failed</code>)</li>
|
||||
<li>完成后 <code class="text-white/85 font-mono">GET /v1/videos/{id}/content</code> 返回 <strong class="text-white/90">mp4 原始二进制</strong>(非 base64、非 URL)</li>
|
||||
</ol>
|
||||
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;图像或视频上游失败会自动退回 —— 失败不扣费。</p>
|
||||
<p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>→比例,<code class="text-white/70">quality</code>(low/medium/high)→画质档(1K/2K/4K,钳到模型支持档),<code class="text-white/70">seconds</code>→视频时长。参数须落在该模型定价表内,否则 400;余额不足 402。</p>
|
||||
<div class="pt-2 grid sm:grid-cols-2 gap-2 text-xs">
|
||||
<div class="flex items-center gap-2"><span class="badge-err">401</span> Key 无效 / 上游需重新授权</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">404</span> 未知 model / 视频任务不存在</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">400</span> 参数缺失 / 不支持或未定价</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">402</span> 积分不足</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">409</span> 视频尚未完成(content 未就绪)</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">429</span> 账号并发已满,请重试</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">503</span> 上游繁忙,请重试</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="toastMsg" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">{{ toastMsg }}</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge-get, .badge-post, .badge-err {
|
||||
border-radius: 4px; padding: 2px 6px; font-size: 10px; line-height: 1;
|
||||
}
|
||||
.badge-get { background: rgb(16 185 129 / 0.14); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.35); }
|
||||
.badge-post { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.35); }
|
||||
.badge-err { background: rgb(244 63 94 / 0.12); color: rgb(190 18 60); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); font-family: ui-monospace, monospace; }
|
||||
html.dark .badge-get { background: rgb(16 185 129 / 0.15); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.3); }
|
||||
html.dark .badge-post { background: rgb(14 165 233 / 0.15); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.3); }
|
||||
html.dark .badge-err { background: rgb(244 63 94 / 0.15); color: rgb(253 164 175); box-shadow: inset 0 0 0 1px rgb(251 113 133 / 0.3); }
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { site } from '../site'
|
||||
import { isAuthed, openLogin } from '../auth'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Navigate to a signed-in page, or pop the login modal (remembering the
|
||||
// destination) when the visitor isn't logged in yet.
|
||||
function go(path, query) {
|
||||
const target = query ? { path, query } : { path }
|
||||
if (isAuthed()) { router.push(target); return }
|
||||
openLogin(typeof target === 'string' ? target : router.resolve(target).fullPath)
|
||||
}
|
||||
|
||||
const stats = ref({ generated_count: 0, recent: [] })
|
||||
const managed = ref([]) // managed model records (provider, type, ...)
|
||||
const showcase = ref({ hero: [], bento: [] })
|
||||
// heroDeck holds the top-3 hero cards in a RANDOMIZED order, so a different card
|
||||
// fronts the deck on each page load. It's reshuffled only when the hero set first
|
||||
// loads or its members change — not on every 30s poll, so the deck stays put.
|
||||
const heroDeck = ref([])
|
||||
function shuffleArr(arr) {
|
||||
const a = [...arr]
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[a[i], a[j]] = [a[j], a[i]]
|
||||
}
|
||||
return a
|
||||
}
|
||||
let timer = null
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
// No /logs here — that endpoint now requires a login and only returns the
|
||||
// caller's own entries. The avg-latency KPI comes from /stats (aggregate,
|
||||
// prompt-free) so the public home page exposes nothing per-user.
|
||||
const [s, m, sc] = await Promise.all([
|
||||
api('/stats'),
|
||||
api('/managed-models'),
|
||||
api('/showcase'),
|
||||
])
|
||||
stats.value = s.data || { generated_count: 0, recent: [] }
|
||||
managed.value = (m.data?.data || []).filter((x) => x.enabled !== false)
|
||||
showcase.value = sc.data?.data || { hero: [], bento: [] }
|
||||
// Randomize which hero card fronts the deck: shuffle the top-3 once when the
|
||||
// set first loads / changes; keep the order stable across refreshes so the
|
||||
// deck doesn't reshuffle every 30s while the visitor is looking at it.
|
||||
const top3 = (showcase.value.hero || []).slice(0, 3)
|
||||
const sameSet = heroDeck.value.length === top3.length
|
||||
&& top3.every((c) => heroDeck.value.some((d) => d.id === c.id))
|
||||
if (!sameSet) heroDeck.value = shuffleArr(top3)
|
||||
} catch {}
|
||||
}
|
||||
onMounted(() => { refresh(); timer = setInterval(refresh, 30000) })
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
|
||||
// ---- KPI strip — three signals derived from real data ----
|
||||
const modelCount = computed(() => managed.value.length)
|
||||
|
||||
// Show the 24h average (matches the admin overview); fall back to the all-time
|
||||
// average on a quiet day so the KPI isn't blank.
|
||||
const avgElapsed = computed(() => stats.value?.avg_elapsed_ms_24h ?? stats.value?.avg_elapsed_ms ?? null)
|
||||
const avgLabel = computed(() => {
|
||||
if (avgElapsed.value == null) return '—'
|
||||
if (avgElapsed.value < 1000) return avgElapsed.value + 'ms'
|
||||
return (avgElapsed.value / 1000).toFixed(1) + 's'
|
||||
})
|
||||
|
||||
// ---- “已接入” provider strip, derived from managed models ----
|
||||
const PROVIDER_TINT = {
|
||||
adobe: 'from-rose-400 to-orange-400',
|
||||
chatgpt: 'from-violet-500 to-fuchsia-500',
|
||||
google: 'from-sky-400 to-indigo-500',
|
||||
default: 'from-emerald-400 to-teal-500',
|
||||
}
|
||||
const providerGroups = computed(() => {
|
||||
const map = new Map()
|
||||
for (const m of managed.value) {
|
||||
const key = (m.provider || 'unknown').toLowerCase()
|
||||
const g = map.get(key) || { name: m.provider || 'unknown', image: 0, video: 0 }
|
||||
if (m.type === 'video') g.video++
|
||||
else g.image++
|
||||
map.set(key, g)
|
||||
}
|
||||
return [...map.entries()].map(([key, g]) => ({
|
||||
...g,
|
||||
grad: PROVIDER_TINT[key] || PROVIDER_TINT.default,
|
||||
})).sort((a, b) => (b.image + b.video) - (a.image + a.video))
|
||||
})
|
||||
|
||||
// Admin-curated "我们的作品" entries from /admin/api/showcase (kind=work),
|
||||
// already sorted by weight on the server.
|
||||
const works = computed(() => showcase.value.work || [])
|
||||
|
||||
// Hero deck = a random ordering of the top-3 hero entries (see refresh).
|
||||
const bento = computed(() => showcase.value.bento || [])
|
||||
|
||||
// Resolve an image reference: external URLs pass through, relative paths
|
||||
// (like "user/abc.png") are served from /generated by the backend.
|
||||
function imgSrc(image) {
|
||||
if (!image) return ''
|
||||
return /^https?:\/\//i.test(image) ? image : generatedUrl(image)
|
||||
}
|
||||
// Background style for a showcase card. Prefers a real image (the new shape);
|
||||
// falls back to the legacy CSS gradient so seed entries still render.
|
||||
function cardBg(card) {
|
||||
if (card.image) {
|
||||
return {
|
||||
backgroundImage: `url("${imgSrc(card.image)}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
}
|
||||
return { background: card.gradient }
|
||||
}
|
||||
|
||||
function useExample(ex) {
|
||||
go('/user', { prompt: ex.prompt })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-28">
|
||||
<!-- ============ HERO ============ -->
|
||||
<section class="relative pt-8 md:pt-16 grid lg:grid-cols-[1.15fr_1fr] gap-10 lg:gap-16 items-center min-h-[640px]">
|
||||
<!-- LEFT: copy -->
|
||||
<div>
|
||||
<h1 class="font-bold tracking-tight leading-[0.9] text-[clamp(2.5rem,6.5vw,6.5rem)] text-[color:var(--fg)]">
|
||||
<span class="block text-[color:var(--fg-3)] font-light italic">Imagine</span>
|
||||
<span class="block">it,
|
||||
<span class="bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent italic">type</span>
|
||||
it,
|
||||
</span>
|
||||
<span class="block">own it.</span>
|
||||
</h1>
|
||||
|
||||
<p class="mt-8 text-base md:text-lg text-[color:var(--fg-2)] max-w-md leading-relaxed">
|
||||
把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex items-center gap-4">
|
||||
<button @click="go('/user')"
|
||||
class="group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
|
||||
开始画图
|
||||
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
<a href="#bento" class="text-sm text-[color:var(--fg-3)] hover:text-[color:var(--fg)] transition-colors">浏览灵感 ↓</a>
|
||||
</div>
|
||||
|
||||
<!-- counter strip — three real signals: models we support, total
|
||||
outputs ever generated, average wall-clock to produce one. -->
|
||||
<div class="mt-14 grid grid-cols-3 gap-px rounded-2xl overflow-hidden ring-1 ring-[color:var(--hairline)] max-w-xl" style="background: var(--hairline)">
|
||||
<div class="bg-[var(--surface)] px-5 py-4">
|
||||
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ modelCount }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已接入模型</div>
|
||||
</div>
|
||||
<div class="bg-[var(--surface)] px-5 py-4">
|
||||
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ stats.generated_count || 0 }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已生成作品</div>
|
||||
</div>
|
||||
<div class="bg-[var(--surface)] px-5 py-4">
|
||||
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ avgLabel }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">平均出片</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: stacked card deck — driven by /admin/api/showcase (kind=hero),
|
||||
top 3 by weight. Position classes are picked by index so existing CSS
|
||||
transforms in <style> still apply. -->
|
||||
<div class="relative h-[480px] lg:h-[560px] hero-deck">
|
||||
<template v-for="(card, i) in heroDeck" :key="card.id">
|
||||
<!-- back -->
|
||||
<div v-if="i === 2"
|
||||
class="deck-card deck-card-3 absolute inset-y-8 right-12 lg:right-20 w-[68%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl"
|
||||
:style="cardBg(card)">
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-25"
|
||||
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%222%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div>
|
||||
<div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- middle -->
|
||||
<div v-if="i === 1"
|
||||
class="deck-card deck-card-2 absolute inset-y-4 right-4 lg:right-8 w-[72%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl"
|
||||
:style="cardBg(card)">
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-25"
|
||||
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%223%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div>
|
||||
<div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- front -->
|
||||
<div v-if="i === 0"
|
||||
class="deck-card deck-card-1 absolute inset-y-0 right-0 w-[78%] rounded-3xl overflow-hidden ring-1 ring-white/15 shadow-[0_30px_80px_-20px_rgba(168,85,247,0.45)]"
|
||||
:style="cardBg(card)">
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-30"
|
||||
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<div class="absolute top-0 inset-x-0 flex items-center justify-between px-5 py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
|
||||
</div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-white/60 font-mono">live</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-6 bg-gradient-to-t from-black/90 via-black/40 to-transparent">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/65">{{ card.subtitle }}</div>
|
||||
<div class="text-2xl font-bold text-white mt-2">{{ card.title }}</div>
|
||||
<p class="text-xs text-white/70 mt-2 line-clamp-2 leading-relaxed">{{ card.prompt }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ WORKS STRIP ============ -->
|
||||
<section v-if="works.length" class="-mx-8 md:-mx-14">
|
||||
<div class="px-8 md:px-14 mb-6">
|
||||
<h2 class="text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">我们的作品</h2>
|
||||
</div>
|
||||
<div class="marquee-wrap">
|
||||
<div class="marquee-track">
|
||||
<div v-for="(w, i) in [...works, ...works]" :key="w.id + '-' + i"
|
||||
class="shrink-0 w-56 h-56 rounded-2xl overflow-hidden ring-1 ring-white/[0.08] hover:ring-white/30 hover:scale-[1.02] transition-all cursor-pointer relative"
|
||||
@click="go('/logs')">
|
||||
<img :src="imgSrc(w.image)" loading="lazy"
|
||||
class="w-full h-full object-cover" />
|
||||
<div v-if="w.title" class="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
|
||||
<div class="text-xs font-medium text-white line-clamp-1">{{ w.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ BENTO EXAMPLES ============ -->
|
||||
<section id="bento">
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-8">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-violet-300/70 font-medium">灵感</div>
|
||||
<h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">从一个起点开始</h2>
|
||||
<p class="text-[color:var(--fg-3)] mt-2 max-w-md">点任意一张,自动进入画图工作台并预填提示词。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 grid-flow-row-dense gap-4 auto-rows-[200px]">
|
||||
<button v-for="ex in bento" :key="ex.id" @click="useExample(ex)"
|
||||
class="group relative text-left overflow-hidden rounded-3xl ring-1 ring-white/[0.06] hover:ring-white/20 transition-all"
|
||||
:class="ex.span"
|
||||
:style="cardBg(ex)">
|
||||
<!-- grain -->
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-30"
|
||||
style="background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<!-- veil -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
|
||||
|
||||
<div class="absolute top-5 right-5 w-9 h-9 rounded-xl bg-white/15 backdrop-blur ring-1 ring-white/20 grid place-items-center opacity-0 group-hover:opacity-100 group-hover:scale-100 scale-95 transition-all">
|
||||
<Icon name="open" class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 md:p-6">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ ex.subtitle }}</div>
|
||||
<div class="text-xl md:text-2xl font-bold mt-2 text-white">{{ ex.title }}</div>
|
||||
<div class="text-xs text-white/65 mt-2 line-clamp-2 leading-relaxed">{{ ex.prompt }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ MODELS GRID ============ -->
|
||||
<section>
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-8">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">模型</div>
|
||||
<h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">已接入</h2>
|
||||
<p class="text-[color:var(--fg-3)] mt-2 max-w-md">由管理员在后台注册;以下为已对接的上游 family。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div v-for="p in providerGroups" :key="p.name"
|
||||
class="group relative overflow-hidden rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div class="absolute -right-8 -top-8 w-32 h-32 rounded-full bg-gradient-to-br opacity-30 blur-xl group-hover:opacity-50 transition-opacity"
|
||||
:class="p.grad"></div>
|
||||
<div class="relative flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-base font-semibold capitalize text-[color:var(--fg)]">{{ p.name }}</div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)] mt-1 flex gap-2">
|
||||
<span v-if="p.image">{{ p.image }} 图像</span>
|
||||
<span v-if="p.video">{{ p.video }} 视频</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-gradient-to-br" :class="p.grad"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!providerGroups.length" class="text-sm text-[color:var(--fg-3)] col-span-full text-center py-10">
|
||||
管理员尚未接入任何模型
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ FOOTER CTA ============ -->
|
||||
<section class="cta-band relative overflow-hidden rounded-[2rem] ring-1 ring-[color:var(--hairline)] p-12 md:p-20">
|
||||
<div class="relative max-w-2xl">
|
||||
<h3 class="text-4xl md:text-6xl font-bold tracking-tight leading-[1.05] text-[color:var(--fg)]">
|
||||
停下来思考,<br />
|
||||
<span class="italic font-light text-[color:var(--fg-3)]">不如</span> 直接开始。
|
||||
</h3>
|
||||
<p class="text-base md:text-lg text-[color:var(--fg-2)] mt-6 max-w-lg leading-relaxed">
|
||||
登录即可开始。打开画图工作台,写一句话,看 AI 替你完成。
|
||||
</p>
|
||||
<button @click="go('/user')"
|
||||
class="mt-10 group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
|
||||
开始画图
|
||||
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- thin footer line -->
|
||||
<footer class="pt-12 border-t border-[color:var(--hairline)] flex flex-wrap items-center justify-between gap-4 text-xs text-[color:var(--fg-3)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono">{{ site.title }}</span>
|
||||
<span>·</span>
|
||||
<span>AI 生图与生视频平台</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
/* ----- Hero stacked card deck ----- */
|
||||
.hero-deck { perspective: 1200px; }
|
||||
.deck-card { transform-origin: center bottom; will-change: transform, opacity; }
|
||||
/* staggered fade-in-up on load, then a livelier continuous float with a gentle
|
||||
rotation wobble. The :hover rules below still take over to fan the deck out. */
|
||||
.deck-card-1 { animation: deckIn1 0.8s cubic-bezier(0.2,0.7,0.2,1) backwards, deckFloat1 7s ease-in-out 0.8s infinite; }
|
||||
.deck-card-2 { animation: deckIn2 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.12s backwards, deckFloat2 9s ease-in-out 0.92s infinite; }
|
||||
.deck-card-3 { animation: deckIn3 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.24s backwards, deckFloat3 11s ease-in-out 1.04s infinite; opacity: 0.85; }
|
||||
@keyframes deckFloat1 {
|
||||
0%,100% { transform: rotate(2deg) translateY(0); }
|
||||
35% { transform: rotate(0.5deg) translateY(-16px); }
|
||||
70% { transform: rotate(3deg) translateY(-7px); }
|
||||
}
|
||||
@keyframes deckFloat2 {
|
||||
0%,100% { transform: rotate(-3deg) translateY(0); }
|
||||
50% { transform: rotate(-1.5deg) translateY(-13px); }
|
||||
}
|
||||
@keyframes deckFloat3 {
|
||||
0%,100% { transform: rotate(5deg) translateY(0); }
|
||||
50% { transform: rotate(3.5deg) translateY(-10px); }
|
||||
}
|
||||
@keyframes deckIn1 {
|
||||
from { opacity: 0; transform: rotate(2deg) translateY(48px) scale(0.92); }
|
||||
to { opacity: 1; transform: rotate(2deg) translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes deckIn2 {
|
||||
from { opacity: 0; transform: rotate(-3deg) translateY(48px) scale(0.92); }
|
||||
to { opacity: 1; transform: rotate(-3deg) translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes deckIn3 {
|
||||
from { opacity: 0; transform: rotate(5deg) translateY(48px) scale(0.92); }
|
||||
to { opacity: 0.85; transform: rotate(5deg) translateY(0) scale(1); }
|
||||
}
|
||||
.hero-deck:hover .deck-card-1 { transform: rotate(0deg) translateY(-12px); transition: transform 0.4s ease; animation: none; }
|
||||
.hero-deck:hover .deck-card-2 { transform: rotate(-6deg) translate(-30px, 10px); transition: transform 0.4s ease; animation: none; }
|
||||
.hero-deck:hover .deck-card-3 { transform: rotate(8deg) translate(-50px, 20px); transition: transform 0.4s ease; animation: none; opacity: 0.7; }
|
||||
|
||||
.marquee-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%);
|
||||
}
|
||||
.marquee-track {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: max-content;
|
||||
padding: 0 2rem;
|
||||
animation: marquee 55s linear infinite;
|
||||
}
|
||||
.marquee-wrap:hover .marquee-track { animation-play-state: paused; }
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { fmtTs, fmtSize } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const stats = ref({ total: 0, image: 0, video: 0, size_bytes: 0 })
|
||||
const loading = ref(false)
|
||||
const kind = ref('') // '' | 'image' | 'video'
|
||||
const selected = ref(null)
|
||||
const toast = ref('')
|
||||
|
||||
const page = ref(1)
|
||||
// 20 per page so a 4-col (lg) or 5-col (xl) grid lays out as clean rows of
|
||||
// 5×4 or 4×5 instead of a half-empty trailing row.
|
||||
const pageSize = ref(20)
|
||||
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(pageSize.value),
|
||||
offset: String((page.value - 1) * pageSize.value),
|
||||
})
|
||||
if (kind.value) qs.set('kind', kind.value)
|
||||
const r = await api('/images?' + qs.toString())
|
||||
items.value = r.data?.data || []
|
||||
total.value = Number(r.data?.total ?? items.value.length)
|
||||
// Stats arrive with the same payload so the KPI strip stays cheap.
|
||||
stats.value = r.data?.stats || { total: 0, image: 0, video: 0, size_bytes: 0 }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function absUrl(name) {
|
||||
const u = generatedUrl(name)
|
||||
return u.startsWith('http') ? u : location.origin + u
|
||||
}
|
||||
|
||||
async function copyLink(name) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(absUrl(name))
|
||||
flash('链接已复制')
|
||||
} catch {
|
||||
flash('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
let toastTimer = null
|
||||
function flash(msg) {
|
||||
toast.value = msg
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toast.value = ''), 1800)
|
||||
}
|
||||
|
||||
function setKind(v) { kind.value = v; page.value = 1; load() }
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target === page.value) return
|
||||
page.value = target
|
||||
load()
|
||||
}
|
||||
|
||||
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])
|
||||
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 onKey(e) {
|
||||
if (e.key === 'Escape') selected.value = null
|
||||
}
|
||||
onMounted(() => { load(); window.addEventListener('keydown', onKey) })
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — same shape as the LogsView so the admin shell stays
|
||||
consistent. /images returns all four numbers in one payload. -->
|
||||
<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-white/45">总计</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-indigo-300/80">图像</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-indigo-300">{{ stats.image }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">视频</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.video }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">存储</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ fmtSize(stats.size_bytes) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- toolbar -->
|
||||
<div class="card p-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setKind('')" class="fp" :class="kind === '' && 'fp-on'">全部</button>
|
||||
<button @click="setKind('image')" class="fp" :class="kind === 'image' && 'fp-on'">图像</button>
|
||||
<button @click="setKind('video')" class="fp" :class="kind === 'video' && 'fp-on'">视频</button>
|
||||
</div>
|
||||
<button @click="load" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- grid -->
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!items.length" class="card flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||
<Icon name="files" class="w-6 h-6" />
|
||||
</span>
|
||||
<span class="text-sm">还没有生成过任何图片</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
<div v-for="f in items" :key="f.name"
|
||||
class="media-card group relative rounded-xl overflow-hidden ring-1 ring-white/[0.06] bg-white/[0.03] aspect-[4/5] cursor-zoom-in"
|
||||
@click="selected = f">
|
||||
<!-- media -->
|
||||
<template v-if="f.kind === 'video'">
|
||||
<video :src="generatedUrl(f.name)" muted loop preload="metadata"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
@mouseenter="$event.target.play && $event.target.play()"
|
||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||
</template>
|
||||
<img v-else :src="generatedUrl(f.name)" loading="lazy"
|
||||
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" />
|
||||
|
||||
<!-- gradient veil (always visible so the prompt overlay reads) -->
|
||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div>
|
||||
|
||||
<!-- kind chip -->
|
||||
<span class="absolute top-3 left-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
|
||||
:class="f.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
|
||||
{{ f.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
|
||||
<!-- quick actions, hover-revealed; same style as 首页内容 -->
|
||||
<div class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a :href="generatedUrl(f.name)" target="_blank" @click.stop title="新标签打开"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="open" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<button @click.stop="copyLink(f.name)" title="复制链接"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="copy" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<a :href="generatedUrl(f.name)" :download="f.name.split('/').pop()" @click.stop title="下载"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="download" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- caption: prompt (truncated 2 lines) + meta line -->
|
||||
<div class="absolute inset-x-0 bottom-0 p-3 pointer-events-none">
|
||||
<div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1"
|
||||
:title="f.prompt || f.name">
|
||||
{{ f.prompt || f.name.split('/').pop() }}
|
||||
</div>
|
||||
<div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums">
|
||||
<span class="truncate" :title="f.model || ''">{{ f.model || '—' }}</span>
|
||||
<span class="shrink-0 flex items-center gap-1">
|
||||
<span v-if="f.resolution" class="text-emerald-300/90">{{ f.resolution }}</span>
|
||||
<span v-if="f.ratio" class="text-white/40">{{ f.ratio }}</span>
|
||||
<span v-if="f.kind === 'video' && f.duration" class="text-fuchsia-300/80">{{ f.duration }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtSize(f.size) }} · {{ fmtTs(f.mtime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- pagination — hidden when everything fits on one page -->
|
||||
<div v-if="!loading && totalPages > 1" class="card !p-3 flex items-center justify-between gap-3">
|
||||
<div class="text-xs text-white/55 tabular-nums px-2">
|
||||
<span class="text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||||
/ {{ total }} 张
|
||||
</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>
|
||||
|
||||
<!-- Lightbox (shared component) -->
|
||||
<MediaLightbox
|
||||
v-if="selected"
|
||||
:src="generatedUrl(selected.name)"
|
||||
:kind="selected.kind"
|
||||
:prompt="selected.prompt"
|
||||
:meta="[selected.model, selected.name].filter(Boolean).join(' · ')"
|
||||
:meta-sub="[selected.resolution, selected.ratio, (selected.kind === 'video' ? selected.duration : ''), fmtSize(selected.size), fmtTs(selected.mtime)].filter(Boolean).join(' · ')"
|
||||
:download-name="selected.name.split('/').pop()"
|
||||
@close="selected = null" />
|
||||
|
||||
<!-- toast -->
|
||||
<transition name="fade">
|
||||
<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>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* filter pill — same shape as LogsView so the admin shell stays consistent */
|
||||
.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-3);
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.fp:hover { background: var(--hover); color: var(--fg); }
|
||||
.fp-on { background: rgb(15 23 42); color: white; box-shadow: none; }
|
||||
html.dark .fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
|
||||
|
||||
/* numbered 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;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
|
||||
.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; }
|
||||
html.dark .pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup>
|
||||
// 邀请好友 — its own page (moved out of 设置). Invite data is real, from the
|
||||
// logged-in account (auth.user). The inviter earns INVITE_REWARD 积分 once each
|
||||
// invited friend completes their FIRST generation (生图).
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { auth, refreshMe } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { fmtIso } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
// Reward per completed invite — comes from the backend (credits.invite_reward),
|
||||
// falling back to 3 until the response lands.
|
||||
const INVITE_REWARD = ref(3)
|
||||
|
||||
const records = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
async function loadRecords() {
|
||||
loading.value = true
|
||||
const r = await api('/auth/invites')
|
||||
loading.value = false
|
||||
if (r.ok) {
|
||||
records.value = r.data?.data || []
|
||||
if (r.data?.reward != null) INVITE_REWARD.value = Number(r.data.reward)
|
||||
if ((page.value - 1) * pageSize >= records.value.length) page.value = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side numbered pagination — matches the admin 日志/图片管理 .pg strip.
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(records.value.length / pageSize)))
|
||||
const paged = computed(() => records.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
function goPage(n) { page.value = Math.max(1, Math.min(totalPages.value, n)) }
|
||||
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])
|
||||
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
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshMe() // latest invite_count / invite_earned
|
||||
loadRecords()
|
||||
})
|
||||
|
||||
const inviteCode = computed(() => auth.user?.invite_code || '')
|
||||
const inviteCount = computed(() => Number(auth.user?.invite_count || 0))
|
||||
const inviteEarned = computed(() => Number(auth.user?.invite_earned || 0))
|
||||
const inviteUrl = computed(() => `${location.origin}/?ref=${inviteCode.value}`)
|
||||
|
||||
async function copyInvite() {
|
||||
try { await navigator.clipboard.writeText(inviteUrl.value); toast('邀请链接已复制') }
|
||||
catch { toast('复制失败') }
|
||||
}
|
||||
async function copyCode() {
|
||||
try { await navigator.clipboard.writeText(inviteCode.value); toast('邀请码已复制') }
|
||||
catch { toast('复制失败') }
|
||||
}
|
||||
|
||||
// ---- Toast ----
|
||||
const toastMsg = ref('')
|
||||
let toastTimer = null
|
||||
function toast(m) {
|
||||
toastMsg.value = m
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toastMsg.value = ''), 2200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-text space-y-10">
|
||||
<!-- header -->
|
||||
<header>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-amber-300/70 font-medium">奖励</div>
|
||||
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">邀请好友</h1>
|
||||
<p class="text-white/45 mt-2">好友用你的链接注册,并完成首次生图后,你得 {{ INVITE_REWARD }} 积分。</p>
|
||||
</header>
|
||||
|
||||
<section class="relative rounded-3xl ring-1 ring-white/[0.08] p-7 md:p-8 overflow-hidden"
|
||||
style="background: radial-gradient(at 70% 30%,rgba(251,191,36,0.16) 0%, transparent 55%),linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-amber-500/15 ring-1 ring-amber-400/30 grid place-items-center text-amber-300">
|
||||
<Icon name="accounts" class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<!-- summary -->
|
||||
<div class="mt-6 grid grid-cols-2 gap-3">
|
||||
<div class="rounded-xl bg-white/[0.04] ring-1 ring-white/[0.06] px-4 py-3">
|
||||
<div class="text-2xl font-bold tabular-nums">{{ inviteCount }}</div>
|
||||
<div class="text-[10px] text-white/40 mt-1 uppercase tracking-widest">已邀请</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white/[0.04] ring-1 ring-white/[0.06] px-4 py-3">
|
||||
<div class="text-2xl font-bold tabular-nums">{{ inviteEarned.toLocaleString('en-US') }}</div>
|
||||
<div class="text-[10px] text-white/40 mt-1 uppercase tracking-widest">累计积分</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- code -->
|
||||
<div class="mt-6">
|
||||
<label class="block text-xs text-white/50 mb-2">邀请码</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyCode"
|
||||
class="flex-1 rounded-xl bg-white/[0.05] ring-1 ring-white/10 hover:ring-white/30 hover:bg-white/[0.08] px-4 py-3 text-sm font-mono text-left transition-all">
|
||||
{{ inviteCode || '—' }}
|
||||
</button>
|
||||
<button @click="copyInvite"
|
||||
class="rounded-xl bg-white text-black hover:bg-white/90 px-5 py-3 text-sm font-semibold transition-colors">
|
||||
复制链接
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-[11px] text-white/35 break-all font-mono">{{ inviteUrl }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- records -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold">邀请记录</h2>
|
||||
<button @click="loadRecords" class="text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !records.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
|
||||
<div v-else-if="!records.length" class="text-center text-sm text-white/40 py-16">还没有人通过你的链接注册</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col class="w-24" />
|
||||
<col class="w-44" />
|
||||
<col class="w-44" />
|
||||
<col class="w-28" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-left px-5 py-3 font-medium">注册用户名</th>
|
||||
<th class="text-right 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-5 py-3 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in paged" :key="i"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<td class="px-5 py-3.5 align-middle font-medium text-white/90 truncate">{{ r.name }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="r.reward ? 'text-emerald-300' : 'text-white/25'">
|
||||
{{ r.reward ? '+' + r.reward : '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">
|
||||
{{ r.registered_at ? fmtIso(r.registered_at) : '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">
|
||||
{{ r.completed_at ? fmtIso(r.completed_at) : '—' }}
|
||||
</td>
|
||||
<td class="px-5 py-3.5 align-middle text-right">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="r.status === 'completed'
|
||||
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||
: 'bg-amber-500/10 text-amber-300 ring-amber-400/30'">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
:class="r.status === 'completed' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
|
||||
{{ r.status === 'completed' ? '已完成' : '待生图' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination — numbered with ellipsis, same as the admin pages -->
|
||||
<div v-if="records.length && totalPages > 1"
|
||||
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">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(records.length, page * pageSize) }}</span>
|
||||
<span class="ml-1">/ {{ records.length }} 条</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>
|
||||
</section>
|
||||
|
||||
<!-- toast -->
|
||||
<transition name="fade">
|
||||
<div v-if="toastMsg"
|
||||
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">
|
||||
{{ toastMsg }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Numbered pagination buttons — dark base (matches the admin pages); the global
|
||||
.theme-text rules in style.css recolor these for light mode automatically. */
|
||||
.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: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
// Admin invite log — global view of every invite relationship across accounts.
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { fmtIso } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const items = ref([])
|
||||
const stats = ref({ total: 0, completed: 0, pending: 0, reward_paid: 0 })
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const r = await api('/invites')
|
||||
loading.value = false
|
||||
if (r.ok) { items.value = r.data?.data || []; stats.value = r.data?.stats || stats.value }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — same shape as the LogsView so the admin shell stays
|
||||
consistent. /invites already returns the stats payload. -->
|
||||
<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-white/45">总邀请</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-emerald-300/80">已完成</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.completed }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">待生图</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ stats.pending }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">已发奖励</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ Number(stats.reward_paid || 0).toLocaleString('en-US') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div class="text-xs text-white/55">全站邀请记录:谁邀请了谁、注册与完成时间、奖励状态。</div>
|
||||
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
|
||||
<div v-else-if="!items.length" class="text-center text-sm text-white/40 py-16">还没有邀请记录</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col />
|
||||
<col class="w-24" />
|
||||
<col class="w-44" />
|
||||
<col class="w-44" />
|
||||
<col class="w-28" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-left px-5 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-left px-3 py-3 font-medium">注册时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">完成时间</th>
|
||||
<th class="text-right px-5 py-3 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in items" :key="i"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<td class="px-5 py-3.5 align-middle font-medium text-white/90 truncate">{{ r.inviter }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-white/75 truncate">{{ r.invitee }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="r.reward ? 'text-emerald-300' : 'text-white/25'">
|
||||
{{ r.reward ? '+' + r.reward : '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">
|
||||
{{ r.registered_at ? fmtIso(r.registered_at) : '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">
|
||||
{{ r.completed_at ? fmtIso(r.completed_at) : '—' }}
|
||||
</td>
|
||||
<td class="px-5 py-3.5 align-middle text-right">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="r.status === 'completed'
|
||||
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||
: 'bg-amber-500/10 text-amber-300 ring-amber-400/30'">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
:class="r.status === 'completed' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
|
||||
{{ r.status === 'completed' ? '已完成' : '待生图' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,438 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
||||
import { generatedUrl } from '../api'
|
||||
import Icon from '../components/Icon.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)
|
||||
|
||||
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)
|
||||
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.
|
||||
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() }
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return items.value
|
||||
return items.value.filter((e) =>
|
||||
(e.model || '').toLowerCase().includes(q) ||
|
||||
(e.prompt || '').toLowerCase().includes(q) ||
|
||||
(e.error || '').toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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-300 ring-emerald-400/30',
|
||||
failed: 'bg-rose-500/10 text-rose-300 ring-rose-400/30',
|
||||
pending: 'bg-amber-500/10 text-amber-300 ring-amber-400/30',
|
||||
}[s] || 'bg-white/[0.06] text-white/65 ring-white/15')
|
||||
const statusDot = (s) => ({
|
||||
success: 'bg-emerald-400',
|
||||
failed: 'bg-rose-400',
|
||||
pending: 'bg-amber-400',
|
||||
}[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-violet-500/15 text-violet-300 ring-violet-400/30',
|
||||
admin: 'bg-amber-500/15 text-amber-300 ring-amber-400/30',
|
||||
user: 'bg-sky-500/15 text-sky-300 ring-sky-400/30',
|
||||
}[s] || 'bg-sky-500/15 text-sky-300 ring-sky-400/30')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- 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-white/45">总计</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-emerald-300/80">成功</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.success }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">失败</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.failed }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">进行中</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ stats.pending }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setKind('')" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button>
|
||||
<button @click="setKind('image')" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button>
|
||||
<button @click="setKind('video')" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setStatus('')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="setStatus('success')" class="fp" :class="statusFilter === 'success' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>成功
|
||||
</button>
|
||||
<button @click="setStatus('failed')" class="fp" :class="statusFilter === 'failed' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>失败
|
||||
</button>
|
||||
<button @click="setStatus('pending')" class="fp" :class="statusFilter === 'pending' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>进行中
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setSource('')" class="fp" :class="sourceFilter === '' && 'fp-on'">所有来源</button>
|
||||
<button @click="setSource('user')" class="fp" :class="sourceFilter === 'user' && 'fp-on'">画图台</button>
|
||||
<button @click="setSource('v1')" class="fp" :class="sourceFilter === 'v1' && 'fp-on'">API</button>
|
||||
<button @click="setSource('admin')" class="fp" :class="sourceFilter === 'admin' && 'fp-on'">测试</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 / 提示词 / 错误…" />
|
||||
</div>
|
||||
<button @click="load" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="files" class="w-6 h-6" /></span>
|
||||
<!-- Search is client-side over the CURRENT page only, so "no match" here
|
||||
doesn't mean the term is absent globally — say so to avoid confusion. -->
|
||||
<span class="text-sm">{{ 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-40" /> <!-- 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-fuchsia-400/60 transition-all">
|
||||
<img v-if="e.kind !== 'video'" :src="generatedUrl(e.file)" loading="lazy"
|
||||
class="w-full h-full object-cover" />
|
||||
<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-400/30 grid place-items-center">
|
||||
<span class="w-2 h-2 rounded-full bg-amber-400 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)">
|
||||
<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>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle min-w-0">
|
||||
<div class="font-mono text-xs text-white/90 truncate" :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-fuchsia-300/80' : 'text-indigo-300/80'">
|
||||
{{ 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" :title="e.prompt">{{ e.prompt || '—' }}</div>
|
||||
<div v-if="e.error" class="mt-1 text-[11px] text-rose-300/85 truncate flex items-center gap-1.5" :title="e.error">
|
||||
<Icon name="close" class="w-3 h-3 shrink-0" />
|
||||
{{ 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>
|
||||
</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-300 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" />
|
||||
</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: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-rose {
|
||||
background: rgb(244 63 94 / 0.22);
|
||||
color: rgb(253 164 175);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
|
||||
}
|
||||
.fp-amber {
|
||||
background: rgb(245 158 11 / 0.22);
|
||||
color: rgb(252 211 77);
|
||||
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45);
|
||||
}
|
||||
|
||||
/* --- 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 violet 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 rgb(255 255 255 / 0.04);
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.log-row:hover td { background: rgb(255 255 255 / 0.025); }
|
||||
.log-row:hover td:first-child {
|
||||
box-shadow: inset 2px 0 0 rgb(167 139 250 / 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: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,399 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import ModelFormModal from '../components/ModelFormModal.vue'
|
||||
import TestModal from '../components/TestModal.vue'
|
||||
import { points } from '../credits'
|
||||
|
||||
const models = ref([])
|
||||
const loading = ref(false)
|
||||
const showForm = ref(false)
|
||||
const editing = ref(null) // null = add, object = edit
|
||||
const testing = ref(null) // model being tested, or null
|
||||
|
||||
const kindFilter = ref('') // '' | 'image' | 'video'
|
||||
const statusFilter = ref('') // '' | 'enabled' | 'disabled'
|
||||
const search = ref('')
|
||||
|
||||
const TYPE_LABEL = { image: '生图', video: '生视频' }
|
||||
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
|
||||
|
||||
async function loadModels() {
|
||||
loading.value = true
|
||||
const r = await api('/managed-models')
|
||||
models.value = r.data?.data || []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function openAdd() { editing.value = null; showForm.value = true }
|
||||
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
||||
function onSaved() { showForm.value = false; loadModels() }
|
||||
|
||||
async function toggleEnabled(m) {
|
||||
// Optimistic: flip the switch instantly, persist in the background, revert on
|
||||
// failure. Avoids the lag of awaiting the PATCH + a full table reload before
|
||||
// the toggle visibly moves.
|
||||
const cur = m.enabled !== false
|
||||
const next = !cur
|
||||
m.enabled = next
|
||||
const r = await api(`/managed-models/${encodeURIComponent(m.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
})
|
||||
if (!r.ok) m.enabled = cur
|
||||
}
|
||||
|
||||
async function remove(m) {
|
||||
if (!confirm(`确认删除模型 ${m.id}?`)) return
|
||||
await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' })
|
||||
loadModels()
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = models.value.length
|
||||
const image = models.value.filter((m) => m.type === 'image').length
|
||||
const video = models.value.filter((m) => m.type === 'video').length
|
||||
const enabled = models.value.filter((m) => m.enabled !== false).length
|
||||
return { total, image, video, enabled, disabled: total - enabled }
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
return models.value.filter((m) => {
|
||||
if (kindFilter.value && m.type !== kindFilter.value) return false
|
||||
if (statusFilter.value === 'enabled' && m.enabled === false) return false
|
||||
if (statusFilter.value === 'disabled' && m.enabled !== false) return false
|
||||
if (q && !(m.id.toLowerCase().includes(q) || (m.provider || '').toLowerCase().includes(q))) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(loadModels)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — same shape as LogsView / InvitesAdminView -->
|
||||
<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-white/45">总数</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-indigo-300/80">图像</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-indigo-300">{{ stats.image }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">视频</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.video }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">启用</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.enabled }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="kindFilter = ''" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button>
|
||||
<button @click="kindFilter = 'image'" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button>
|
||||
<button @click="kindFilter = 'video'" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="statusFilter = ''" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="statusFilter = 'enabled'" class="fp" :class="statusFilter === 'enabled' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>启用
|
||||
</button>
|
||||
<button @click="statusFilter = 'disabled'" class="fp" :class="statusFilter === 'disabled' && 'fp-white'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>停用
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / Provider…" />
|
||||
</div>
|
||||
<button @click="loadModels" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button @click="openAdd" class="btn-primary">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 新增模型
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span>
|
||||
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,点右上角「新增模型」' }}</span>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col /> <!-- model id + provider -->
|
||||
<col class="w-20" /> <!-- type -->
|
||||
<col /> <!-- pricing -->
|
||||
<col /> <!-- capability -->
|
||||
<col class="w-20" /> <!-- weight -->
|
||||
<col class="w-24" /> <!-- generation count -->
|
||||
<col class="w-20" /> <!-- status switch -->
|
||||
<col class="w-36" /> <!-- actions -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-left px-5 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-3 py-3 font-medium">生图次数</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-right px-5 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in filtered" :key="m.id"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<!-- Model id + provider underneath -->
|
||||
<td class="px-5 py-3.5 align-middle min-w-0">
|
||||
<div class="font-mono text-xs text-white/90 truncate" :title="m.id">{{ m.id }}</div>
|
||||
<div class="mt-1 text-[10px] text-white/45 capitalize truncate">{{ m.provider || '—' }}</div>
|
||||
</td>
|
||||
|
||||
<!-- Type chip with the same shape as Logs/Provider 健康 -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="m.type === 'video'
|
||||
? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30'
|
||||
: 'bg-indigo-500/10 text-indigo-300 ring-indigo-400/30'">
|
||||
{{ TYPE_LABEL[m.type] || m.type }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Pricing block — image shows 1K/2K/4K + price, video shows
|
||||
duration + price; each as a small price-chip. -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<!-- Each tier shows a pair: 普通价 (emerald) + 代理价 (amber).
|
||||
代理价未设置时回退普通价数值。 -->
|
||||
<div v-if="m.type === 'image'" class="flex flex-wrap gap-1">
|
||||
<span v-for="r in (m.resolutions || [])" :key="r" class="price-chip">
|
||||
<span class="text-white/85">{{ r }}</span>
|
||||
<span class="text-white/30 mx-1">普通</span>
|
||||
<span class="text-emerald-300 tabular-nums">{{ points(m.prices?.[r]) }}</span>
|
||||
<span class="text-amber-300/40 ml-1.5">代理</span>
|
||||
<span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span>
|
||||
</span>
|
||||
<span v-if="!(m.resolutions || []).length" class="text-white/30 text-xs">—</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-1">
|
||||
<!-- video charge = resolution price + duration price; both show 普通/代理 -->
|
||||
<span v-for="r in (m.resolutions || [])" :key="'r'+r" class="price-chip">
|
||||
<span class="text-white/85">{{ r }}</span>
|
||||
<span class="text-white/30 mx-1">普通</span>
|
||||
<span class="text-emerald-300 tabular-nums">{{ points(m.prices?.[r]) }}</span>
|
||||
<span class="text-amber-300/40 ml-1.5">代理</span>
|
||||
<span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span>
|
||||
</span>
|
||||
<span v-for="d in (m.durations || [])" :key="'d'+d" class="price-chip">
|
||||
<span class="text-white/85">{{ d }}</span>
|
||||
<span class="text-white/30 mx-1">普通</span>
|
||||
<span class="text-sky-300 tabular-nums">+{{ points(m.duration_prices?.[d]) }}</span>
|
||||
<span class="text-amber-300/40 ml-1.5">代理</span>
|
||||
<span class="text-amber-300 tabular-nums">+{{ points(m.duration_prices_agent?.[d] ?? m.duration_prices?.[d]) }}</span>
|
||||
</span>
|
||||
<span v-if="!(m.resolutions || []).length && !(m.durations || []).length" class="text-white/30 text-xs">—</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Capability — extras that aren't a price: image-to-image,
|
||||
video frame mode, supported resolutions for video. -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<div class="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<span v-if="m.type === 'image' && m.image_to_image"
|
||||
class="cap-chip cap-emerald">图生图</span>
|
||||
<!-- 参考图 is a capability like 图生图 — same emerald style, shown first -->
|
||||
<span v-if="m.type === 'video' && m.max_reference_images > 0"
|
||||
class="cap-chip cap-emerald"
|
||||
:title="REF_MODE_LABEL[m.reference_mode]">参考图 {{ m.max_reference_images }}</span>
|
||||
<span v-if="m.type === 'video'" v-for="r in (m.resolutions || [])" :key="'vr'+r"
|
||||
class="cap-chip cap-slate">{{ r }}</span>
|
||||
<span v-if="(m.type === 'image' && !(m.ratios || []).length && !m.image_to_image) ||
|
||||
(m.type === 'video' && !(m.resolutions || []).length && !m.max_reference_images)"
|
||||
class="text-white/30 text-xs">—</span>
|
||||
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap-chip cap-mono">{{ r }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Display weight — higher floats to the top of the dropdown/list -->
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="(m.weight || 0) !== 0 ? 'text-white/85' : 'text-white/30'"
|
||||
title="展示权重(越大越靠前)">
|
||||
{{ m.weight || 0 }}
|
||||
</td>
|
||||
|
||||
<!-- Successful generations to date, from event_log via /managed-models -->
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="m.generation_count > 0 ? 'text-white/85' : 'text-white/25'">
|
||||
{{ (m.generation_count || 0).toLocaleString('en-US') }}
|
||||
</td>
|
||||
|
||||
<!-- Status as a real toggle so admins read it as on/off, not a
|
||||
button to delete or whatever. -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<button class="sw" :class="m.enabled !== false && 'sw-on'"
|
||||
:aria-pressed="m.enabled !== false" @click="toggleEnabled(m)">
|
||||
<span class="sw-thumb"></span>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<!-- Actions — small soft buttons, danger variant for delete -->
|
||||
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button @click="testing = m" class="act" title="测试生成">
|
||||
<Icon name="test" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="openEdit(m)" class="act" title="编辑">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="remove(m)" class="act danger" title="删除">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ModelFormModal v-if="showForm" :model="editing" @close="showForm = false" @saved="onSaved" />
|
||||
<TestModal v-if="testing" :model="testing" @close="testing = null" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* --- filter pills (mirrors LogsView so the admin shell stays consistent) */
|
||||
.fp {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem; font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-white {
|
||||
background: rgb(255 255 255 / 0.18);
|
||||
color: white;
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.35);
|
||||
}
|
||||
|
||||
/* --- price chips: tight pill showing a key (resolution or duration) plus
|
||||
its emerald-tinted price. */
|
||||
.price-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.18rem 0.55rem;
|
||||
font-size: 0.7rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- capability chips: smaller, monochrome variants */
|
||||
.cap-chip {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.68rem; font-weight: 500;
|
||||
border-radius: 9999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cap-emerald {
|
||||
background: rgb(16 185 129 / 0.12);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.3);
|
||||
}
|
||||
.cap-amber {
|
||||
background: rgb(245 158 11 / 0.12);
|
||||
color: rgb(252 211 77);
|
||||
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.3);
|
||||
}
|
||||
.cap-slate {
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
}
|
||||
.cap-mono {
|
||||
background: transparent;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
/* --- iOS-style toggle for 启用/停用 */
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 2.25rem; height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.sw-thumb {
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw-on { background: rgb(16 185 129 / 0.7); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5); }
|
||||
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
|
||||
/* --- danger variant for 删除 (kept for any other .btn-soft callers) */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* --- compact square icon button for the actions column. Predictable
|
||||
width (1.9rem × 3 + gaps) keeps the row inside its 9rem slot, so
|
||||
the last button never gets clipped. */
|
||||
.act {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.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);
|
||||
}
|
||||
.act.danger:hover {
|
||||
color: white;
|
||||
background: rgb(244 63 94 / 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,518 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { fmtTs, fmtSize } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const providers = ref([])
|
||||
const stats = ref({ generated_count: 0, generated_size_bytes: 0 })
|
||||
const userStats = ref({ total: 0, active: 0, disabled: 0, admins: 0, credits_total: 0, new_24h: 0, new_7d: 0, active_24h: 0 })
|
||||
// Everything time-windowed now comes pre-aggregated from /dashboard (server-side
|
||||
// SQL) instead of being recomputed in the browser from the last 200 logs — which
|
||||
// silently undercounted week / DAU / trend / top-N once volume grew past 200.
|
||||
const dash = ref(null)
|
||||
const logs = ref([]) // recent-activity feed only (small page)
|
||||
const models = ref([]) // managed models, for the model-count card
|
||||
const range = ref('day') // analytics window toggle: 'day' (24h) | 'week' (7d)
|
||||
let timer = null
|
||||
|
||||
async function refreshAll() {
|
||||
const [p, s, u, d, l, m] = await Promise.all([
|
||||
api('/providers'),
|
||||
api('/stats'),
|
||||
api('/users'),
|
||||
api('/dashboard'),
|
||||
api('/logs?limit=20'),
|
||||
api('/managed-models'),
|
||||
])
|
||||
providers.value = p.data?.data || []
|
||||
stats.value = s.data || {}
|
||||
userStats.value = u.data?.stats || {}
|
||||
dash.value = d.data || null
|
||||
logs.value = l.data?.data || []
|
||||
models.value = m.data?.data || []
|
||||
}
|
||||
|
||||
// ---- windowed event aggregates (from /dashboard) ----
|
||||
const EMPTY_WINDOW = { total: 0, success: 0, failed: 0, pending: 0, image: 0, video: 0, api: 0, web: 0, spent: 0 }
|
||||
const day = computed(() => dash.value?.day || EMPTY_WINDOW)
|
||||
const week = computed(() => dash.value?.week || EMPTY_WINDOW)
|
||||
const successRate = computed(() => (day.value.total ? Math.round((day.value.success / day.value.total) * 100) : 0))
|
||||
|
||||
// Direction vs the previous 24h (24–48h ago) — a quiet day after a busy week is
|
||||
// worth seeing. prev_day_total is computed server-side.
|
||||
const dayDelta = computed(() => {
|
||||
const cur = day.value.total
|
||||
const prev = dash.value?.prev_day_total || 0
|
||||
if (!prev) return cur ? { pct: null, dir: 'up' } : { pct: null, dir: 'flat' }
|
||||
const pct = Math.round(((cur - prev) / prev) * 100)
|
||||
return { pct, dir: cur > prev ? 'up' : cur < prev ? 'down' : 'flat' }
|
||||
})
|
||||
|
||||
const dau = computed(() => dash.value?.dau || 0)
|
||||
const avg24hMs = computed(() => stats.value?.avg_elapsed_ms_24h ?? null)
|
||||
|
||||
// ---- range-toggled top-N analytics (both windows ship in the payload, so the
|
||||
// 24h/7d switch is instant — no re-fetch) ----
|
||||
const rangeLabel = computed(() => (range.value === 'week' ? '近 7 天' : '近 24h'))
|
||||
const analytics = computed(() => dash.value?.analytics?.[range.value] || { models: [], failures: [], top_users: [] })
|
||||
const modelUsage = computed(() => analytics.value.models || [])
|
||||
const usageMax = computed(() => Math.max(1, ...modelUsage.value.map((m) => m.count)))
|
||||
const failures = computed(() => analytics.value.failures || [])
|
||||
const topUsers = computed(() => analytics.value.top_users || [])
|
||||
const topUserMax = computed(() => Math.max(1, ...topUsers.value.map((u) => u.spent)))
|
||||
|
||||
// ---- 24h trend (always 24h) ----
|
||||
const hourBuckets = computed(() => dash.value?.hourly || Array.from({ length: 24 }, () => ({ image: 0, video: 0 })))
|
||||
const hourMax = computed(() => Math.max(1, ...hourBuckets.value.map((b) => b.image + b.video)))
|
||||
|
||||
// ---- operations cards ----
|
||||
const cdk = computed(() => dash.value?.cdk || {})
|
||||
const invites = computed(() => dash.value?.invites || {})
|
||||
const checkin = computed(() => dash.value?.checkin || {})
|
||||
|
||||
// ---- token / model / provider summaries (from /providers + /managed-models) ----
|
||||
const tokens = computed(() => {
|
||||
let active = 0, total = 0
|
||||
for (const p of providers.value) {
|
||||
active += p.tokens_active || 0
|
||||
total += (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
|
||||
}
|
||||
return { active, total }
|
||||
})
|
||||
|
||||
const modelTypes = computed(() => {
|
||||
const image = models.value.filter((m) => m.type === 'image').length
|
||||
const video = models.value.filter((m) => m.type === 'video').length
|
||||
return { image, video, total: models.value.length }
|
||||
})
|
||||
|
||||
const providerHealth = computed(() =>
|
||||
providers.value.map((p) => {
|
||||
const total = (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
|
||||
let status = 'down'
|
||||
if ((p.tokens_active || 0) > 0) status = 'healthy'
|
||||
else if (total > 0) status = 'warning'
|
||||
return { ...p, status, total }
|
||||
})
|
||||
)
|
||||
|
||||
// One-glance system health badge derived from provider token availability.
|
||||
const overallHealth = computed(() => {
|
||||
const list = providerHealth.value
|
||||
if (!list.length) return { label: '未配置 Provider', tone: 'down' }
|
||||
if (list.some((p) => p.status === 'down')) return { label: 'Provider 异常', tone: 'down' }
|
||||
if (list.some((p) => p.status === 'warning')) return { label: 'Provider 告警', tone: 'warning' }
|
||||
return { label: '系统健康', tone: 'healthy' }
|
||||
})
|
||||
|
||||
const recentLogs = computed(() => logs.value.slice(0, 12))
|
||||
|
||||
// ---- formatters ----
|
||||
function statusLabel(s) { return s === 'healthy' ? '健康' : s === 'warning' ? '告警' : '未配置' }
|
||||
function statusDot(s) { return s === 'healthy' ? 'bg-emerald-400' : s === 'warning' ? 'bg-amber-400' : 'bg-rose-500' }
|
||||
function statusPill(s) {
|
||||
if (s === 'healthy') return 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||
if (s === 'warning') return 'bg-amber-500/10 text-amber-300 ring-amber-400/30'
|
||||
return 'bg-rose-500/10 text-rose-300 ring-rose-400/30'
|
||||
}
|
||||
function logDot(status) {
|
||||
if (status === 'success') return 'bg-emerald-400'
|
||||
if (status === 'pending') return 'bg-amber-400'
|
||||
return 'bg-rose-500'
|
||||
}
|
||||
function fmtMs(ms) {
|
||||
if (!ms) return '—'
|
||||
if (ms < 1000) return ms + 'ms'
|
||||
return (ms / 1000).toFixed(1) + 's'
|
||||
}
|
||||
function fmtInt(n) { return (n ?? 0).toLocaleString('zh-CN') }
|
||||
function fmtCredits(n) {
|
||||
const v = Number(n || 0)
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + ' 万'
|
||||
return fmtInt(Math.round(v))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshAll()
|
||||
timer = setInterval(refreshAll, 10000)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- ===== Toolbar: overall health + refresh ===== -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium ring-1 tabular-nums"
|
||||
:class="statusPill(overallHealth.tone)">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
|
||||
{{ overallHealth.label }}
|
||||
</span>
|
||||
<button @click="refreshAll" class="btn-ghost">刷新</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== KPI strip ===== -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<!-- 用户 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">用户</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-indigo-500/15 text-indigo-300 grid place-items-center ring-1 ring-indigo-400/20">
|
||||
<Icon name="accounts" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtInt(userStats.total) }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">{{ userStats.active }} 活跃 · {{ userStats.admins }} 管理员</div>
|
||||
<div class="text-[11px] text-emerald-300/80 mt-0.5">
|
||||
今日新增 <span class="tabular-nums font-medium">{{ fmtInt(userStats.new_24h) }}</span>
|
||||
· 7日 <span class="tabular-nums font-medium">{{ fmtInt(userStats.new_7d) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 24h -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">近 24 小时生成</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-violet-500/15 text-violet-300 grid place-items-center ring-1 ring-violet-400/20">
|
||||
<Icon name="spark" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-2 flex items-baseline gap-2">
|
||||
<span>{{ fmtInt(day.total) }}</span>
|
||||
<span v-if="dayDelta.pct != null"
|
||||
class="text-[11px] font-medium tabular-nums"
|
||||
:class="dayDelta.dir === 'up' ? 'text-emerald-300' : dayDelta.dir === 'down' ? 'text-rose-300' : 'text-white/45'">
|
||||
{{ dayDelta.dir === 'up' ? '↑' : dayDelta.dir === 'down' ? '↓' : '·' }}{{ Math.abs(dayDelta.pct) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] mt-1 flex flex-wrap gap-x-2">
|
||||
<span class="text-emerald-300 tabular-nums">{{ day.success }} 成功</span>
|
||||
<span v-if="day.failed" class="text-rose-300 tabular-nums">{{ day.failed }} 失败</span>
|
||||
<span v-if="day.pending" class="text-amber-300 tabular-nums">{{ day.pending }} 进行中</span>
|
||||
</div>
|
||||
<div v-if="day.total" class="text-[10px] text-white/40 mt-1 tabular-nums">
|
||||
Web {{ day.web }} · API {{ day.api }} · 图 {{ day.image }} · 视 {{ day.video }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 平均耗时 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">平均耗时</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-emerald-500/15 text-emerald-300 grid place-items-center ring-1 ring-emerald-400/20">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtMs(avg24hMs) }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">{{ successRate }}% 成功率 · 24h</div>
|
||||
</div>
|
||||
|
||||
<!-- 存储 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">产物存储</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-amber-500/15 text-amber-300 grid place-items-center ring-1 ring-amber-400/20">
|
||||
<Icon name="files" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtSize(stats.generated_size_bytes || 0) }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">{{ fmtInt(stats.generated_count) }} 个文件</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Secondary stat row ===== -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-white/55">系统积分总和</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2">{{ fmtCredits(userStats.credits_total) }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">
|
||||
所有用户余额累加 · <span class="text-amber-300">24h 消耗 {{ fmtCredits(day.spent) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-white/55">Token</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
|
||||
<span class="text-emerald-300">{{ tokens.active }}</span>
|
||||
<span class="text-white/30">/</span>
|
||||
<span>{{ tokens.total }}</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">活跃 / 已配置</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-white/55">模型</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2">{{ modelTypes.total }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">图像 {{ modelTypes.image }} · 视频 {{ modelTypes.video }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-white/55">活跃用户 · 24h</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2">{{ fmtInt(dau) }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">近 7 天累计生成 {{ fmtInt(week.total) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Operations row: CDK / 邀请 / 签到 ===== -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<!-- 兑换码 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">兑换码 CDK</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-sky-500/15 text-sky-300 grid place-items-center ring-1 ring-sky-400/20">
|
||||
<Icon name="spark" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
|
||||
<span class="text-emerald-300">{{ fmtInt(cdk.active) }}</span>
|
||||
<span class="text-white/30 text-sm">未用 /</span>
|
||||
<span class="text-white/70 text-base">{{ fmtInt(cdk.redeemed) }} 已兑</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">
|
||||
待兑积分 <span class="text-amber-300 tabular-nums">{{ fmtCredits(cdk.active_amount) }}</span>
|
||||
· 已发出 <span class="tabular-nums">{{ fmtCredits(cdk.redeemed_amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">邀请</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-fuchsia-500/15 text-fuchsia-300 grid place-items-center ring-1 ring-fuchsia-400/20">
|
||||
<Icon name="accounts" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
|
||||
<span>{{ fmtInt(invites.total) }}</span>
|
||||
<span class="text-white/40 text-sm">邀请注册</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">
|
||||
<span class="text-emerald-300 tabular-nums">{{ fmtInt(invites.completed) }}</span> 已达成奖励
|
||||
· 已发 <span class="text-amber-300 tabular-nums">{{ fmtCredits(invites.reward_paid) }}</span> 积分
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 签到 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">今日签到</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-teal-500/15 text-teal-300 grid place-items-center ring-1 ring-teal-400/20">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
|
||||
<span>{{ fmtInt(checkin.today) }}</span>
|
||||
<span class="text-white/40 text-sm">人</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">
|
||||
发放 <span class="text-amber-300 tabular-nums">{{ fmtCredits(checkin.awarded_today) }}</span> 积分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Provider health + 24h trend ===== -->
|
||||
<div class="grid lg:grid-cols-2 gap-3">
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">Provider 健康</h2>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="statusPill(overallHealth.tone)">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
|
||||
{{ overallHealth.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div v-if="!providerHealth.length" class="text-center text-xs text-white/40 py-8">未注册 Provider</div>
|
||||
<div v-else class="space-y-0.5">
|
||||
<div v-for="p in providerHealth" :key="p.name"
|
||||
class="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04] transition-colors">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium capitalize truncate">{{ p.name }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-0.5">
|
||||
{{ p.model_count }} 模型 · token {{ p.tokens_active }}/{{ p.total || 0 }} 活跃
|
||||
<span v-if="p.tokens_quota"> · {{ p.tokens_quota }} 限额</span>
|
||||
<span v-if="p.tokens_disabled"> · {{ p.tokens_disabled }} 停用</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 tabular-nums"
|
||||
:class="statusPill(p.status)">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(p.status)"></span>
|
||||
{{ statusLabel(p.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold">24 小时生成趋势</h2>
|
||||
<div class="text-[11px] text-white/45 flex items-center gap-3">
|
||||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-indigo-400/80"></span>图像</span>
|
||||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-fuchsia-400/80"></span>视频</span>
|
||||
<span class="tabular-nums">峰值 {{ hourMax }}/h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex items-end gap-[3px] h-32">
|
||||
<div v-for="(b, i) in hourBuckets" :key="i"
|
||||
class="group/bar relative flex-1 flex flex-col justify-end rounded-t overflow-visible"
|
||||
:style="{ height: Math.max(4, ((b.image + b.video) / hourMax) * 100) + '%' }">
|
||||
<!-- hover tooltip -->
|
||||
<div class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 z-10 hidden group-hover/bar:block
|
||||
whitespace-nowrap rounded-md bg-black/90 ring-1 ring-white/10 px-2 py-1 text-[10px] text-white/90 tabular-nums">
|
||||
{{ 23 - i }}h 前 · 图 {{ b.image }} / 视 {{ b.video }}
|
||||
</div>
|
||||
<div v-if="b.video" class="bg-fuchsia-400/80 group-hover/bar:bg-fuchsia-400" :style="{ flex: b.video }"></div>
|
||||
<div v-if="b.image" class="bg-indigo-400/80 group-hover/bar:bg-indigo-400" :style="{ flex: b.image }"></div>
|
||||
<div v-if="!b.image && !b.video" class="bg-white/[0.06] group-hover/bar:bg-white/15 flex-1 rounded-t"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-[10px] text-white/40 mt-2 tabular-nums">
|
||||
<span>-24h</span><span>-18h</span><span>-12h</span><span>-6h</span><span>现在</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Analytics (range-toggled): top models / failures / spenders ===== -->
|
||||
<div class="flex items-center justify-between gap-3 pt-1">
|
||||
<h2 class="text-sm font-semibold text-white/80">使用分析 · <span class="text-white/45 font-normal">{{ rangeLabel }}</span></h2>
|
||||
<div class="inline-flex rounded-lg bg-white/[0.04] ring-1 ring-white/[0.08] p-0.5 text-xs">
|
||||
<button @click="range = 'day'"
|
||||
class="px-3 py-1 rounded-md transition-colors"
|
||||
:class="range === 'day' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||
近 24h
|
||||
</button>
|
||||
<button @click="range = 'week'"
|
||||
class="px-3 py-1 rounded-md transition-colors"
|
||||
:class="range === 'week' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||
近 7d
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-3">
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06]">
|
||||
<h2 class="text-sm font-semibold">热门模型</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div v-if="!modelUsage.length" class="text-center text-xs text-white/40 py-6">尚无生成记录</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="m in modelUsage" :key="m.model" class="text-sm">
|
||||
<div class="flex items-baseline justify-between gap-3 mb-1">
|
||||
<span class="font-mono text-[12px] text-white/85 truncate">{{ m.model }}</span>
|
||||
<span class="flex items-baseline gap-2 shrink-0">
|
||||
<span v-if="m.avg_ms" class="text-[10px] text-white/40 tabular-nums">{{ fmtMs(m.avg_ms) }}</span>
|
||||
<span class="tabular-nums text-xs font-semibold">{{ m.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-violet-400 to-fuchsia-500"
|
||||
:style="{ width: ((m.count / usageMax) * 100) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06]">
|
||||
<h2 class="text-sm font-semibold">失败原因 Top</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div v-if="!failures.length" class="text-center text-xs text-white/40 py-6">{{ rangeLabel }}内没有失败 — 一切正常</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="f in failures" :key="f.reason"
|
||||
class="flex items-start gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04]">
|
||||
<span class="w-1.5 h-1.5 mt-1.5 rounded-full bg-rose-400 shrink-0"></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-white/85 break-all leading-snug">{{ f.reason }}</div>
|
||||
</div>
|
||||
<span class="tabular-nums text-xs font-semibold text-rose-300 shrink-0">×{{ f.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== User consumption + spend summary ===== -->
|
||||
<div class="grid lg:grid-cols-2 gap-3">
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06]">
|
||||
<h2 class="text-sm font-semibold">用户消耗 Top · {{ rangeLabel }}</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div v-if="!topUsers.length" class="text-center text-xs text-white/40 py-6">{{ rangeLabel }}内无消耗记录</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="u in topUsers" :key="u.user_id || u.name" class="text-sm">
|
||||
<div class="flex items-baseline justify-between gap-3 mb-1">
|
||||
<span class="text-[12px] text-white/85 truncate">{{ u.name }}</span>
|
||||
<span class="flex items-baseline gap-2 shrink-0">
|
||||
<span class="text-[10px] text-white/40 tabular-nums">{{ u.count }} 次</span>
|
||||
<span class="tabular-nums text-xs font-semibold text-amber-300">{{ fmtCredits(u.spent) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500"
|
||||
:style="{ width: ((u.spent / topUserMax) * 100) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06]">
|
||||
<h2 class="text-sm font-semibold">积分消耗概览</h2>
|
||||
</div>
|
||||
<div class="p-5 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-xs text-white/55">近 24 小时</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(day.spent) }}</div>
|
||||
<div class="text-[11px] text-white/40 mt-1">{{ day.success }} 次成功生成</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-white/55">近 7 天</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(week.spent) }}</div>
|
||||
<div class="text-[11px] text-white/40 mt-1">{{ week.success }} 次成功生成</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-white/55">单次均价</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-1">{{ day.success ? fmtCredits(Math.round(day.spent / day.success)) : '—' }}</div>
|
||||
<div class="text-[11px] text-white/40 mt-1">24h 平均</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-white/55">消耗用户数</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-1">{{ topUsers.length }}</div>
|
||||
<div class="text-[11px] text-white/40 mt-1">{{ rangeLabel }}有消耗</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Recent activity (full width) ===== -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold">最近活动</h2>
|
||||
<router-link to="/admin/logs" class="text-[11px] text-white/55 hover:text-white">查看全部 →</router-link>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div v-if="!recentLogs.length" class="text-center text-xs text-white/40 py-6">尚无活动</div>
|
||||
<div v-else>
|
||||
<div v-for="e in recentLogs" :key="e.id"
|
||||
class="flex items-center gap-3 px-2 py-2 text-xs rounded-lg hover:bg-white/[0.03]">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0" :class="logDot(e.status)"></span>
|
||||
<span class="text-[10px] uppercase tracking-wider font-medium w-9"
|
||||
:class="e.kind === 'video' ? 'text-fuchsia-300' : 'text-indigo-300'">
|
||||
{{ e.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
<span class="font-mono text-white/85 truncate w-40 shrink-0">{{ e.model }}</span>
|
||||
<span class="text-white/55 truncate flex-1 min-w-0">{{ e.prompt }}</span>
|
||||
<span class="text-white/40 tabular-nums whitespace-nowrap w-12 text-right">{{ fmtMs(e.elapsed_ms) }}</span>
|
||||
<span class="text-white/40 whitespace-nowrap text-right tabular-nums">{{ fmtTs(e.ts) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,648 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { auth, refreshMe } from '../auth'
|
||||
import { draft, applyJobToDraft } from '../playground'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import SelectMenu from '../components/SelectMenu.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
import { points, pointsLabel } from '../credits'
|
||||
import { sortResolutions } from '../utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// ---- credits: the logged-in user's REAL server-side balance ----
|
||||
const credits = computed(() => Number(auth.user?.credits || 0))
|
||||
|
||||
const allModels = ref([]) // managed-models list
|
||||
const presets = ref([]) // video family presets
|
||||
|
||||
// Seed every form field from the shared draft (module-level) so navigating
|
||||
// away from the page and coming back keeps the prompt + selected model +
|
||||
// params. Each local ref then syncs back into the draft on change.
|
||||
const mode = ref(draft.mode || 'image')
|
||||
const modelId = ref(draft.modelId || '')
|
||||
const prompt = ref(draft.prompt || '')
|
||||
const ratio = ref(draft.ratio || '')
|
||||
const resolution = ref(draft.resolution || '')
|
||||
const duration = ref(draft.duration || '')
|
||||
|
||||
watch(mode, (v) => { draft.mode = v })
|
||||
watch(modelId, (v) => { draft.modelId = v })
|
||||
watch(prompt, (v) => { draft.prompt = v })
|
||||
watch(ratio, (v) => { draft.ratio = v })
|
||||
watch(resolution, (v) => { draft.resolution = v })
|
||||
watch(duration, (v) => { draft.duration = v })
|
||||
|
||||
const refImages = ref([]) // [{ name, dataUrl }]
|
||||
const fileInput = ref(null)
|
||||
|
||||
const busy = ref(false)
|
||||
// submitting = run() owns the busy/current state end-to-end while a /generate
|
||||
// call is in flight. The 2s poll() must NOT touch busy or current during this
|
||||
// window, or it races run() and the controls flicker unlocked mid-generation.
|
||||
const submitting = ref(false)
|
||||
// Gateway-timeout statuses our BACKEND never emits — they mean a CDN/proxy
|
||||
// (e.g. EdgeOne 524) gave up waiting while the synchronous /generate is STILL
|
||||
// rendering server-side. Treat these as "still running", NOT a failure: keep the
|
||||
// controls locked and let poll() follow the live job to completion (出图).
|
||||
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
||||
const error = ref('')
|
||||
const statusText = ref('')
|
||||
|
||||
// Only ever show the latest generation on the right side. Each new run
|
||||
// replaces it; the persistent history lives at /logs (UserLogsView).
|
||||
// `current` is restored from the server on mount and refreshed via /jobs/mine
|
||||
// polling, so a reload, a parallel tab, or a different browser sees the same
|
||||
// in-flight job and the same final result without re-running anything.
|
||||
const current = ref(null)
|
||||
const lightbox = ref(null)
|
||||
const toast = ref('')
|
||||
let pollTimer = null
|
||||
|
||||
// ---- derived ----
|
||||
const models = computed(() =>
|
||||
allModels.value.filter((m) => m.enabled !== false && m.type === mode.value),
|
||||
)
|
||||
const modelOptions = computed(() =>
|
||||
models.value.map((m) => ({ value: m.id, label: m.name || m.id })),
|
||||
)
|
||||
const model = computed(() => allModels.value.find((m) => m.id === modelId.value) || null)
|
||||
const familyPreset = computed(() => {
|
||||
if (mode.value !== 'video' || !model.value) return null
|
||||
return presets.value.find((p) => p.key === model.value.id) || null
|
||||
})
|
||||
|
||||
const ratios = computed(() => {
|
||||
const fromModel = model.value?.ratios || []
|
||||
if (fromModel.length) return fromModel
|
||||
return mode.value === 'video' ? ['16x9'] : ['1:1']
|
||||
})
|
||||
// Firefly Image 5 instruct-edit derives the aspect ratio from the reference
|
||||
// image — hide the ratio picker (backend also omits aspectRatio) when a ref is
|
||||
// attached, otherwise the request is rejected with a validation error.
|
||||
const showRatio = computed(() => !(modelId.value === 'firefly-image-5' && refImages.value.length > 0))
|
||||
const resolutions = computed(() => {
|
||||
const fromModel = model.value?.resolutions || []
|
||||
if (fromModel.length) return sortResolutions(fromModel)
|
||||
// Legacy record with no declared tiers: fall back to the priced tiers so we
|
||||
// never offer (or default to) a resolution the server has no price for.
|
||||
const priced = Object.keys(model.value?.prices || {})
|
||||
if (priced.length) return sortResolutions(priced)
|
||||
return mode.value === 'video' ? ['720p'] : ['1K']
|
||||
})
|
||||
const durations = computed(() => {
|
||||
// duration_prices arrives as a JSON object whose keys Go sorts alphabetically
|
||||
// ("10s" before "5s"). Re-sort by the numeric seconds so the shortest is first.
|
||||
const keys = Object.keys(model.value?.duration_prices || {})
|
||||
.sort((a, b) => parseFloat(a) - parseFloat(b))
|
||||
if (keys.length) return keys
|
||||
return familyPreset.value?.durations || ['5s']
|
||||
})
|
||||
|
||||
const maxRefs = computed(() => {
|
||||
if (mode.value === 'video') {
|
||||
const a = Number(familyPreset.value?.max_reference_images || 0)
|
||||
const b = Number(model.value?.max_reference_images || 0)
|
||||
return Math.max(a, b)
|
||||
}
|
||||
// Image-to-image: honor the model's configured max (gpt-image-2=3,
|
||||
// seedream-4.5=6, flux-klein-2=4 …). Fall back to 1 when image_to_image is on
|
||||
// but no count was set.
|
||||
const m = Number(model.value?.max_reference_images || 0)
|
||||
if (m > 0) return m
|
||||
return model.value?.image_to_image ? 1 : 0
|
||||
})
|
||||
const refMode = computed(() => familyPreset.value?.reference_mode || model.value?.reference_mode || 'none')
|
||||
// Most video models (veo31, luma) support pure text2video, so refs are optional.
|
||||
// A model can opt into strict image-to-video by declaring `requires_reference`
|
||||
// in its preset (e.g. runway-gen4-turbo) — then a first-frame image is mandatory.
|
||||
const refsRequired = computed(() =>
|
||||
mode.value === 'video' && !!familyPreset.value?.requires_reference)
|
||||
|
||||
// ---- price (per generation, derived from selected model + params) ----
|
||||
// 代理用户走代理价:某档设了代理价就用它,否则回退普通价(支持的档位始终由普通价决定)。
|
||||
const isAgent = computed(() => auth.user?.role === 'agent')
|
||||
function tierPrice(normalMap, agentMap, key) {
|
||||
const n = (normalMap || {})[key]
|
||||
if (n == null) return null // 不支持该档(由普通价决定)
|
||||
if (isAgent.value) {
|
||||
const a = (agentMap || {})[key]
|
||||
if (a != null) return Number(a)
|
||||
}
|
||||
return Number(n)
|
||||
}
|
||||
const price = computed(() => {
|
||||
if (!model.value) return null
|
||||
const m = model.value
|
||||
if (mode.value === 'video') {
|
||||
const rp = tierPrice(m.prices, m.prices_agent, resolution.value)
|
||||
const dp = tierPrice(m.duration_prices, m.duration_prices_agent, duration.value)
|
||||
if (rp == null || dp == null) return null
|
||||
return rp + dp
|
||||
}
|
||||
return tierPrice(m.prices, m.prices_agent, resolution.value)
|
||||
})
|
||||
const priceLabel = computed(() => price.value == null ? '—' : pointsLabel(price.value))
|
||||
const canAfford = computed(() => price.value == null || credits.value >= price.value)
|
||||
|
||||
// ---- helpers ----
|
||||
function firstOf(arr) {
|
||||
return (arr && arr.length) ? arr[0] : ''
|
||||
}
|
||||
// Selecting a model (or switching image/video) resets each picker to that
|
||||
// model's FIRST tier — the default should always be the first option, not
|
||||
// whatever was carried over from the previously-selected model.
|
||||
function applyModelDefaults() {
|
||||
ratio.value = firstOf(ratios.value)
|
||||
resolution.value = firstOf(resolutions.value)
|
||||
duration.value = firstOf(durations.value)
|
||||
// 切换模型保留已上传的参考图,只按新模型的上限裁剪(上限为 0 则清空)。
|
||||
if (refImages.value.length > maxRefs.value) {
|
||||
refImages.value = refImages.value.slice(0, maxRefs.value)
|
||||
}
|
||||
}
|
||||
function selectModel(id) {
|
||||
modelId.value = id
|
||||
applyModelDefaults()
|
||||
}
|
||||
|
||||
function setMode(m) {
|
||||
if (mode.value === m) return
|
||||
mode.value = m
|
||||
// pick a default model of the new kind, if any
|
||||
const first = allModels.value.find((x) => x.enabled !== false && x.type === m)
|
||||
modelId.value = first?.id || ''
|
||||
applyModelDefaults()
|
||||
}
|
||||
|
||||
function openPicker() { fileInput.value && fileInput.value.click() }
|
||||
// Backend rejects reference images over 8MB (maxReferenceImageBytes). Enforce it
|
||||
// here at pick time so an oversized image fails fast with a clear message instead
|
||||
// of charging + failing upstream after the upload.
|
||||
const MAX_REF_BYTES = 8 * 1024 * 1024
|
||||
function onFiles(ev) {
|
||||
const files = Array.from(ev.target.files || [])
|
||||
const room = Math.max(0, maxRefs.value - refImages.value.length)
|
||||
const tooBig = []
|
||||
let added = 0
|
||||
for (const f of files) {
|
||||
if (added >= room) break
|
||||
if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue }
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => refImages.value.push({ name: f.name, dataUrl: reader.result })
|
||||
reader.readAsDataURL(f)
|
||||
added++
|
||||
}
|
||||
error.value = tooBig.length
|
||||
? `图片超过 8MB 已跳过:${tooBig.join('、')}(请压缩后再传)`
|
||||
: ''
|
||||
if (ev.target) ev.target.value = ''
|
||||
}
|
||||
function removeRef(i) { refImages.value.splice(i, 1) }
|
||||
|
||||
// Re-hydrate reference thumbnails from server URLs (after a reload). Fetches
|
||||
// each /images URL (same-origin, cookie-authed) and converts to a data URL so
|
||||
// the thumbnail renders AND the ref can be re-submitted unchanged. Shared by
|
||||
// image and video — both persist their refs the same way server-side.
|
||||
// Re-display refs by URL only — the thumbnail renders straight from /images/<ref>.
|
||||
// We DON'T fetch+convert here; conversion to base64 happens lazily at submit time
|
||||
// (refToBase64), so a ref that's only viewed never needs a network round-trip.
|
||||
function restoreRefs(urls) {
|
||||
if (!Array.isArray(urls) || !urls.length) return
|
||||
if (refImages.value.length) return // don't clobber refs the user already added
|
||||
refImages.value = urls.map((u) => ({ name: 'ref', url: u }))
|
||||
}
|
||||
|
||||
// refToBase64 yields the raw base64 the backend expects, from either a freshly
|
||||
// uploaded ref (dataUrl) or a restored one (url → fetch). Returns '' on failure.
|
||||
async function refToBase64(r) {
|
||||
try {
|
||||
if (r.dataUrl) return r.dataUrl.replace(/^data:[^,]*,/, '')
|
||||
if (r.url) {
|
||||
const blob = await (await fetch(r.url)).blob()
|
||||
const dataUrl = await new Promise((res, rej) => {
|
||||
const fr = new FileReader()
|
||||
fr.onload = () => res(fr.result)
|
||||
fr.onerror = rej
|
||||
fr.readAsDataURL(blob)
|
||||
})
|
||||
return dataUrl.replace(/^data:[^,]*,/, '')
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return ''
|
||||
}
|
||||
|
||||
let toastTimer = null
|
||||
function flash(msg) {
|
||||
toast.value = msg
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toast.value = ''), 1800)
|
||||
}
|
||||
|
||||
async function copyLink(url) {
|
||||
try {
|
||||
const abs = url.startsWith('http') ? url : location.origin + url
|
||||
await navigator.clipboard.writeText(abs)
|
||||
flash('链接已复制')
|
||||
} catch {
|
||||
flash('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- generate ----
|
||||
async function run() {
|
||||
if (!modelId.value) { error.value = '请选择模型'; return }
|
||||
if (!prompt.value.trim()) { error.value = '请输入提示词'; return }
|
||||
if (refsRequired.value && refImages.value.length < 1) {
|
||||
error.value = '该视频模型需要至少 1 张参考图 (首帧)'
|
||||
return
|
||||
}
|
||||
if (price.value == null) {
|
||||
error.value = '该参数组合未定价 (留空 = 不支持)'
|
||||
return
|
||||
}
|
||||
if (!canAfford.value) {
|
||||
error.value = `积分不足 — 需要 ${pointsLabel(price.value)},余额 ${pointsLabel(credits.value)}`
|
||||
return
|
||||
}
|
||||
const job = {
|
||||
id: Math.random().toString(36).slice(2, 10),
|
||||
model: modelId.value,
|
||||
kind: mode.value,
|
||||
prompt: prompt.value,
|
||||
ratio: ratio.value,
|
||||
resolution: resolution.value,
|
||||
duration: mode.value === 'video' ? duration.value : '',
|
||||
refs: refImages.value.length,
|
||||
status: 'pending',
|
||||
url: '',
|
||||
error: '',
|
||||
elapsed_ms: 0,
|
||||
charged: price.value,
|
||||
ts: Date.now(),
|
||||
}
|
||||
current.value = job
|
||||
|
||||
busy.value = true
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
statusText.value = mode.value === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
|
||||
try {
|
||||
// Optimistically deduct the price from the displayed balance right away. The
|
||||
// server debits BEFORE generating (which can take minutes for video), so
|
||||
// otherwise 余额 looks unchanged the whole time. The success response carries
|
||||
// the authoritative balance (reconciled below); a failure refunds + refreshMe.
|
||||
if (auth.user && price.value != null) {
|
||||
auth.user.credits = Math.max(0, Number(auth.user.credits || 0) - price.value)
|
||||
}
|
||||
|
||||
const payload = {
|
||||
model: modelId.value,
|
||||
prompt: prompt.value,
|
||||
ratio: ratio.value,
|
||||
resolution: resolution.value,
|
||||
}
|
||||
if (mode.value === 'video') payload.duration = duration.value
|
||||
if (refImages.value.length) {
|
||||
// Backend accepts raw base64 only — convert each ref (uploaded dataUrl or
|
||||
// restored /images URL) to base64 at submit time.
|
||||
const refs = await Promise.all(refImages.value.map(refToBase64))
|
||||
payload.reference_images = refs.filter(Boolean)
|
||||
}
|
||||
|
||||
// Single charged call: the server debits the price atomically BEFORE
|
||||
// generating and refunds on failure, so the client can't skip the charge.
|
||||
const r = await api('/generate', jsonBody('POST', payload))
|
||||
|
||||
if (r.ok && r.data?.url) {
|
||||
job.status = 'done'
|
||||
job.url = r.data.url
|
||||
job.elapsed_ms = r.data.elapsed_ms
|
||||
job.charged = r.data.charged ?? price.value
|
||||
if (auth.user && r.data.credits != null) auth.user.credits = r.data.credits
|
||||
statusText.value = `完成 · 扣费 ${pointsLabel(job.charged)} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s · 余额 ${pointsLabel(credits.value)}`
|
||||
busy.value = false // 出图 → 解锁
|
||||
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
||||
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。不当失败、不解锁:
|
||||
// 保持 busy=true,交给下面的 poll() + 2s 轮询跟到出图("不出图就不闪")。
|
||||
statusText.value = mode.value === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
} else {
|
||||
// 真失败:服务端已退款,resync 余额并解锁。
|
||||
await refreshMe()
|
||||
job.status = 'failed'
|
||||
job.error = r.data?.detail || `失败 (${r.status})`
|
||||
statusText.value = ''
|
||||
busy.value = false // 真失败 → 解锁
|
||||
}
|
||||
} finally {
|
||||
// Hand control back to poll(); busy is left as set above (locked when the
|
||||
// job is still rendering after a gateway timeout).
|
||||
submitting.value = false
|
||||
}
|
||||
// Sync real server state: poll() picks up the live pending job (replacing our
|
||||
// optimistic one with the real id) and will unlock + show the result the
|
||||
// moment it finishes — so a 524 mid-flight never leaves the UI unlocked.
|
||||
poll()
|
||||
}
|
||||
|
||||
// Recover the current generation from the server: any pending job for this
|
||||
// user lives in event_log, so reload / parallel tab / parallel browser can
|
||||
// all see the same in-flight state and the same final result.
|
||||
async function poll() {
|
||||
// While run() is mid-submit it fully owns busy/current — don't race it.
|
||||
if (submitting.value) return
|
||||
const r = await api('/jobs/mine')
|
||||
if (!r.ok) return
|
||||
const { pending, latest } = r.data || {}
|
||||
if (pending) {
|
||||
busy.value = true
|
||||
if (!statusText.value) {
|
||||
statusText.value = pending.kind === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
}
|
||||
if (!current.value || current.value.id !== pending.id) {
|
||||
current.value = { ...pending }
|
||||
// Replay the pending job's params onto the form so a fresh tab shows
|
||||
// what's cooking — and writes them into the cross-component draft.
|
||||
applyJobToDraft(pending)
|
||||
mode.value = pending.kind === 'video' ? 'video' : 'image'
|
||||
modelId.value = pending.model || modelId.value
|
||||
prompt.value = pending.prompt || prompt.value
|
||||
ratio.value = pending.ratio || ratio.value
|
||||
resolution.value = pending.resolution || resolution.value
|
||||
duration.value = pending.duration || duration.value
|
||||
// Re-display the uploaded reference image(s) after a reload. They're
|
||||
// served (cookie-authed) from /images; re-fetch into data URLs so the
|
||||
// thumbnails show AND the refs stay re-submittable if the user regenerates.
|
||||
restoreRefs(pending.reference_urls)
|
||||
}
|
||||
return
|
||||
}
|
||||
// No pending. If our locally-shown job just finished on the server (same id,
|
||||
// status flipped to success/failed), promote it to the result view — this is
|
||||
// the live "I'm watching my own generation finish" case and stays.
|
||||
if (current.value && current.value.status === 'pending' && latest && latest.id === current.value.id) {
|
||||
current.value = { ...latest, status: latest.status === 'success' ? 'done' : latest.status }
|
||||
if (latest.url) current.value.url = latest.url
|
||||
busy.value = false
|
||||
statusText.value = ''
|
||||
refreshMe()
|
||||
return
|
||||
}
|
||||
// Intentionally NO restore of an already-finished result on first paint /
|
||||
// navigation: the playground only ever shows an in-progress job (or the one
|
||||
// that just completed while watched). Past results live in /记录 (logs), not
|
||||
// re-echoed onto a freshly opened workspace.
|
||||
}
|
||||
|
||||
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
||||
|
||||
onMounted(async () => {
|
||||
refreshMe() // pull the latest real balance
|
||||
const [mm, pp] = await Promise.all([api('/managed-models'), api('/video-presets')])
|
||||
allModels.value = mm.data?.data || []
|
||||
presets.value = pp.data?.data || []
|
||||
// Pre-fill from query string (?prompt=...&model=...) — used by the home
|
||||
// page's example cards to seed the form in one click.
|
||||
const qPrompt = String(route.query.prompt || '')
|
||||
const qModel = String(route.query.model || '')
|
||||
if (qPrompt) prompt.value = qPrompt
|
||||
let selected = null
|
||||
if (qModel) {
|
||||
selected = allModels.value.find((m) => m.id === qModel && m.enabled !== false)
|
||||
}
|
||||
// If the draft already points at a still-available model AND no fresher
|
||||
// intent came in from the URL, keep the draft as-is. Otherwise fall back to
|
||||
// the first usable model.
|
||||
const draftModel = !qModel && modelId.value
|
||||
? allModels.value.find((m) => m.id === modelId.value && m.enabled !== false)
|
||||
: null
|
||||
if (!selected && !draftModel) {
|
||||
selected = allModels.value.find((m) => m.enabled !== false && m.type === 'image')
|
||||
|| allModels.value.find((m) => m.enabled !== false)
|
||||
}
|
||||
// Always re-apply defaults — even when restoring the persisted draft model —
|
||||
// so a stale ratio/resolution that the model no longer supports (e.g. a saved
|
||||
// "2K" for a model that's now 1K-only) is normalized to a valid, priced tier
|
||||
// instead of being sent as-is and rejected with "unsupported or unpriced".
|
||||
const chosen = selected || draftModel
|
||||
if (chosen) {
|
||||
mode.value = chosen.type
|
||||
modelId.value = chosen.id
|
||||
applyModelDefaults()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
// Restore any in-flight or recently-finished job for this user, then poll
|
||||
// every 2s so a parallel tab / device sees changes within one tick.
|
||||
poll()
|
||||
pollTimer = setInterval(poll, 2000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="theme-text grid lg:grid-cols-[420px_1fr] gap-6">
|
||||
<!-- LEFT: controls — every interactive element accepts :disabled="busy"
|
||||
so the form locks the moment a generation kicks off. Reload, parallel
|
||||
tab and tab-switch all see the same locked state via poll(). -->
|
||||
<div class="card p-5 space-y-5 lg:sticky lg:top-24 self-start">
|
||||
<!-- mode switch -->
|
||||
<div class="grid grid-cols-2 gap-2 p-1 bg-slate-100 rounded-xl">
|
||||
<button @click="setMode('image')" type="button" :disabled="busy"
|
||||
class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed"
|
||||
:class="mode === 'image' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
|
||||
<Icon name="files" class="w-4 h-4 inline -mt-0.5" /> 生图
|
||||
</button>
|
||||
<button @click="setMode('video')" type="button" :disabled="busy"
|
||||
class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed"
|
||||
:class="mode === 'video' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
|
||||
<Icon name="video" class="w-4 h-4 inline -mt-0.5" /> 生视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- model -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">模型</label>
|
||||
<SelectMenu v-if="models.length" :model-value="modelId" @update:model-value="selectModel"
|
||||
:options="modelOptions" placeholder="选择模型" mono :disabled="busy" />
|
||||
<div v-else class="rounded-lg border border-dashed border-slate-200 px-3 py-4 text-xs text-slate-400 text-center">
|
||||
还没有可用的{{ mode === 'video' ? '视频' : '图像' }}模型 ·
|
||||
<router-link to="/admin/models" class="text-slate-700 underline">去添加</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- prompt -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">提示词</label>
|
||||
<textarea v-model="prompt" rows="4" :disabled="busy" class="field resize-none disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
placeholder="描述想要的画面…如:黄昏时分,金色麦田里奔跑的金毛猎犬,电影感"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- ratio + res + duration. Single-option controls are hidden — the
|
||||
value is still set from the model's defaults and sent to the API,
|
||||
so the user doesn't have to acknowledge a choice they don't have. -->
|
||||
<div v-if="ratios.length > 0 && showRatio">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">比例</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="r in ratios" :key="r" type="button" @click="ratio = r" :disabled="busy"
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="ratio === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="resolutions.length > 0">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">{{ mode === 'video' ? '分辨率' : '画质' }}</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="r in resolutions" :key="r" type="button" @click="resolution = r" :disabled="busy"
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="resolution === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'video' && durations.length > 0">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">时长</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="d in durations" :key="d" type="button" @click="duration = d" :disabled="busy"
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="duration === d ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||||
{{ d }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- reference images -->
|
||||
<div v-if="maxRefs > 0">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">
|
||||
参考图
|
||||
<span class="text-slate-400 font-normal">
|
||||
(最多 {{ maxRefs }} 张{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单张 ≤8MB)
|
||||
</span>
|
||||
<span v-if="refsRequired" class="text-rose-500">*</span>
|
||||
</label>
|
||||
<div class="flex gap-2 flex-wrap items-start">
|
||||
<div v-for="(img, i) in refImages" :key="i"
|
||||
class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all"
|
||||
:class="busy ? 'opacity-60 grayscale pointer-events-none' : ''">
|
||||
<img :src="img.dataUrl || img.url" class="w-full h-full object-cover" />
|
||||
<button type="button" @click="removeRef(i)" :disabled="busy"
|
||||
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-slate-900/70 text-white hover:bg-rose-500 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<Icon name="close" class="w-3 h-3" />
|
||||
</button>
|
||||
<div v-if="refMode === 'frame' && mode === 'video' && maxRefs >= 2"
|
||||
class="absolute bottom-0 inset-x-0 text-[10px] text-white bg-slate-900/60 text-center py-0.5">
|
||||
{{ i === 0 ? '首帧' : (i === 1 ? '末帧' : '') }}
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="refImages.length < maxRefs" type="button" @click="openPicker" :disabled="busy"
|
||||
class="w-20 h-20 rounded-lg border-2 border-dashed border-slate-200 text-slate-400 hover:bg-slate-50 hover:border-slate-300 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<Icon name="plus" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" />
|
||||
</div>
|
||||
|
||||
<button @click="run" :disabled="busy || !models.length || price == null || !canAfford"
|
||||
class="btn-primary w-full !py-3 flex items-center justify-center gap-2 leading-none">
|
||||
<Icon name="spark" class="w-4 h-4 shrink-0" />
|
||||
<span class="leading-none">{{ busy ? (mode === 'video' ? '生成中…请耐心等待' : '生成中…') : '生成' }}</span>
|
||||
<span v-if="!busy && price != null" class="text-xs opacity-70 tabular-nums leading-none">· {{ priceLabel }}</span>
|
||||
<span v-if="!busy && price != null && !canAfford" class="text-xs text-rose-200 leading-none">积分不足</span>
|
||||
</button>
|
||||
|
||||
<!-- Validation / upload errors (model/prompt/ref/price/credits/oversized
|
||||
image). The `error` ref had no render target before, so these messages
|
||||
were silently swallowed. -->
|
||||
<p v-if="error" class="text-xs text-rose-500 break-all">{{ error }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: single latest result (replaces on each new generation).
|
||||
min-w-0: the 1fr grid track defaults to min-width:auto, so a long
|
||||
unbroken prompt would otherwise blow the column wider than the page
|
||||
(truncate can't shrink a track that won't shrink). -->
|
||||
<div class="space-y-4 min-w-0">
|
||||
<div v-if="!current && !busy"
|
||||
class="card p-14 grid place-items-center text-slate-400 text-center">
|
||||
<span class="w-16 h-16 rounded-2xl bg-slate-100 grid place-items-center mb-4">
|
||||
<Icon name="spark" class="w-7 h-7 text-slate-400" />
|
||||
</span>
|
||||
<p class="text-sm">还没有生成过 — 在左侧写提示词,点击「生成」</p>
|
||||
<router-link to="/logs" class="text-xs text-slate-500 hover:text-white mt-3 transition-colors">查看历史记录 →</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else-if="current" class="card overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-slate-100 flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium line-clamp-2 break-words">{{ current.prompt }}</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5 font-mono">
|
||||
{{ current.model }} · {{ current.ratio }} · {{ current.resolution }}
|
||||
<span v-if="current.kind === 'video'"> · {{ current.duration }}</span>
|
||||
<span v-if="current.elapsed_ms"> · {{ (current.elapsed_ms / 1000).toFixed(1) }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- only when a finished result exists — hidden while pending/failed -->
|
||||
<div v-if="current.url && current.status !== 'pending' && current.status !== 'failed'"
|
||||
class="flex items-center gap-1.5 shrink-0">
|
||||
<a :href="current.url" :download="''" class="btn-soft" title="下载">
|
||||
<Icon name="download" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<button @click="copyLink(current.url)" class="btn-soft" title="复制链接">
|
||||
<Icon name="copy" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 grid place-items-center min-h-[260px]">
|
||||
<div v-if="current.status === 'pending'" class="text-sm text-slate-400 py-12 flex flex-col items-center gap-2">
|
||||
<span class="w-10 h-10 rounded-xl bg-white grid place-items-center animate-pulse">
|
||||
<Icon name="spark" class="w-4 h-4 text-slate-400" />
|
||||
</span>
|
||||
{{ statusText || '生成中…' }}
|
||||
</div>
|
||||
<div v-else-if="current.status === 'failed'" class="text-sm text-rose-600 py-12 px-5 max-w-xl text-center">
|
||||
<div class="font-medium mb-1">生成失败</div>
|
||||
<div class="text-xs text-rose-500 break-all">{{ current.error }}</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<video v-if="current.kind === 'video'" :src="current.url" controls
|
||||
class="max-w-full max-h-[600px] object-contain" />
|
||||
<img v-else :src="current.url" @click="lightbox = current"
|
||||
class="max-w-full max-h-[600px] object-contain cursor-zoom-in" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox — shared component, consistent with 图片管理 / 日志 -->
|
||||
<MediaLightbox
|
||||
v-if="lightbox"
|
||||
:src="lightbox.url"
|
||||
:kind="lightbox.kind"
|
||||
:prompt="lightbox.prompt"
|
||||
:meta="[lightbox.model, lightbox.ratio, lightbox.resolution, (lightbox.kind === 'video' ? lightbox.duration : '')].filter(Boolean).join(' · ')"
|
||||
:download-name="(lightbox.url || '').split('/').pop()"
|
||||
@close="lightbox = null" />
|
||||
|
||||
<!-- Toast -->
|
||||
<transition name="fade">
|
||||
<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>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { auth, refreshMe, logout as authLogout } from '../auth'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import { points, pointsLabel } from '../credits'
|
||||
import { site } from '../site'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Pull the latest server-side credits + check-in state when the page opens
|
||||
// (so an admin adjustment, or a check-in from another tab, shows up).
|
||||
onMounted(refreshMe)
|
||||
|
||||
// Hide the check-in card when the admin has disabled the feature, and read the
|
||||
// daily reward amount from the same config (admin-configurable, not hardcoded).
|
||||
const checkinEnabled = ref(true)
|
||||
const checkinReward = ref(3) // fallback until /auth/config loads
|
||||
onMounted(async () => {
|
||||
const r = await api('/auth/config')
|
||||
if (r.ok) {
|
||||
checkinEnabled.value = r.data.checkin_enabled !== false
|
||||
checkinReward.value = Number(r.data.checkin_reward) || 0
|
||||
}
|
||||
})
|
||||
|
||||
// ---- API Key (REAL: minted + verified server-side; OpenAI-compatible) ----
|
||||
// The full plaintext is returned only once, right after minting. On reload we
|
||||
// only have the server's masked preview (the plaintext is never stored).
|
||||
const apiKey = ref('') // full plaintext — present only right after minting
|
||||
const keyPreview = ref('') // masked preview from the server (persists)
|
||||
const apiKeyRevealed = ref(false)
|
||||
const hasKey = computed(() => !!apiKey.value || !!keyPreview.value)
|
||||
|
||||
async function loadKey() {
|
||||
const r = await api('/auth/api-key')
|
||||
if (r.ok) keyPreview.value = r.data?.key?.key_preview || ''
|
||||
}
|
||||
onMounted(loadKey)
|
||||
|
||||
// Uniform mask for the hidden / persisted state — always "***" so the just-minted
|
||||
// preview and the server-stored preview read the same (no sk-xxx…yyyy mismatch).
|
||||
const maskedKey = computed(() => (apiKey.value || keyPreview.value) ? '***' : '')
|
||||
|
||||
async function generateKey() {
|
||||
if (hasKey.value && !confirm('已有 Key — 重新生成会让旧 Key 立刻失效,确认?')) return
|
||||
const r = await api('/auth/api-key', jsonBody('POST', {}))
|
||||
if (!r.ok) { toast(r.data?.detail || '生成失败'); return }
|
||||
apiKey.value = r.data.key
|
||||
keyPreview.value = r.data.preview || ''
|
||||
apiKeyRevealed.value = true
|
||||
toast('新 Key 已生成 — 请立刻复制保存(只显示这一次)')
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
if (!hasKey.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiKey.value || keyPreview.value)
|
||||
toast(apiKey.value ? '已复制完整 Key' : '完整 Key 仅生成时显示一次,这里只能复制预览')
|
||||
} catch { toast('复制失败') }
|
||||
}
|
||||
|
||||
async function clearKey() {
|
||||
if (!confirm('清除 Key? 之后用该 Key 的调用都会失败。')) return
|
||||
const r = await api('/auth/api-key', { method: 'DELETE' })
|
||||
if (!r.ok) { toast(r.data?.detail || '清除失败'); return }
|
||||
apiKey.value = ''; keyPreview.value = ''; apiKeyRevealed.value = false
|
||||
toast('已清除 API Key')
|
||||
}
|
||||
|
||||
// ---- Password (real: verifies current password server-side) ----
|
||||
const pwdForm = ref({ current: '', next: '', confirm: '' })
|
||||
const pwdSubmitting = ref(false)
|
||||
const pwdError = ref('')
|
||||
async function changePwd() {
|
||||
pwdError.value = ''
|
||||
if (!pwdForm.value.current) { pwdError.value = '请输入当前密码'; return }
|
||||
const next = pwdForm.value.next || ''
|
||||
if (next.length < 8 || next.length > 24) { pwdError.value = '新密码长度需为 8-24 位'; return }
|
||||
if (!/[A-Z]/.test(next) || !/[a-z]/.test(next) || !/\d/.test(next) || !/[()~!@#$%^&*\-_=|{}\[\]:;'<>.,?/]/.test(next)) {
|
||||
pwdError.value = '新密码必须包含大写字母、小写字母、数字和符号'
|
||||
return
|
||||
}
|
||||
if (pwdForm.value.next !== pwdForm.value.confirm) { pwdError.value = '两次输入的新密码不一致'; return }
|
||||
pwdSubmitting.value = true
|
||||
const r = await api('/auth/change-password', jsonBody('POST', {
|
||||
current_password: pwdForm.value.current,
|
||||
new_password: pwdForm.value.next,
|
||||
}))
|
||||
pwdSubmitting.value = false
|
||||
if (!r.ok) { pwdError.value = r.data?.detail || '修改失败'; return }
|
||||
pwdForm.value = { current: '', next: '', confirm: '' }
|
||||
toast('密码已更新')
|
||||
}
|
||||
|
||||
// ---- Credits: the REAL server-side balance of the logged-in user ----
|
||||
// (admin adjustments in 用户管理 write this same field). Top-up is via CDK.
|
||||
const balance = computed(() => Number(auth.user?.credits || 0))
|
||||
|
||||
const cdkCode = ref('')
|
||||
const cdkBusy = ref(false)
|
||||
const cdkError = ref('')
|
||||
async function redeemCdk() {
|
||||
cdkError.value = ''
|
||||
const code = cdkCode.value.trim()
|
||||
if (!code) { cdkError.value = '请输入兑换码'; return }
|
||||
cdkBusy.value = true
|
||||
const r = await api('/auth/redeem-cdk', jsonBody('POST', { code }))
|
||||
cdkBusy.value = false
|
||||
if (!r.ok) { cdkError.value = r.data?.detail || '兑换失败'; return }
|
||||
if (auth.user) auth.user.credits = r.data.credits
|
||||
cdkCode.value = ''
|
||||
toast(`兑换成功 +${Number(r.data.amount).toLocaleString('en-US')} 积分`)
|
||||
}
|
||||
|
||||
// ---- Daily check-in ----
|
||||
// Reward amount lives in `checkinReward` (loaded from /auth/config above).
|
||||
|
||||
// Streak + "checked today" come from the server account (date authority is
|
||||
// the backend, so timezone quirks can't desync the disabled state).
|
||||
const streak = computed(() => Number(auth.user?.checkin_streak || 0))
|
||||
const checkedToday = computed(() => !!auth.user?.checkin_today)
|
||||
const last7 = computed(() => {
|
||||
const out = []
|
||||
const today = new Date()
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(today); d.setDate(today.getDate() - i)
|
||||
const ds = d.toISOString().slice(0, 10)
|
||||
const isToday = i === 0
|
||||
// if streak >= i+1 and today checked, then day (today - i) was within streak
|
||||
const lit = checkedToday.value && i < streak.value
|
||||
out.push({ ds, day: d.getDate(), isToday, lit })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
async function checkin() {
|
||||
if (checkedToday.value) return
|
||||
const r = await api('/auth/checkin', jsonBody('POST', {}))
|
||||
if (!r.ok) { toast(r.data?.detail || '签到失败'); return }
|
||||
await refreshMe() // refresh real balance + streak + checkin_today
|
||||
const d = r.data || {}
|
||||
if (d.already) { toast('今日已签到'); return }
|
||||
toast(`签到成功 +${pointsLabel(d.awarded)} → 余额 ${pointsLabel(balance.value)}`)
|
||||
}
|
||||
|
||||
// ---- Logout (real: ends the server session, then returns home) ----
|
||||
async function logout() {
|
||||
if (!confirm('确定要退出登录?')) return
|
||||
await authLogout()
|
||||
// Clear local-only leftovers (API key + legacy demo keys). Credits/streak are
|
||||
// computed from auth.user, which authLogout() has already cleared.
|
||||
const stale = ['gw_api_key', 'gw_credits', 'gw_checkin_last', 'gw_checkin_streak',
|
||||
'gw_invite_code', 'gw_invite_count', 'gw_invite_earned']
|
||||
stale.forEach((k) => localStorage.removeItem(k))
|
||||
apiKey.value = ''
|
||||
toast('已退出登录')
|
||||
setTimeout(() => router.push('/'), 600)
|
||||
}
|
||||
|
||||
// ---- Toast ----
|
||||
const toastMsg = ref('')
|
||||
let toastTimer = null
|
||||
function toast(m) {
|
||||
toastMsg.value = m
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toastMsg.value = ''), 2200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-text space-y-12">
|
||||
<!-- header -->
|
||||
<header class="flex items-end justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-violet-300/70 font-medium">账户</div>
|
||||
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">设置</h1>
|
||||
<p class="text-white/45 mt-2">API Key、密码、积分、登录状态 — 都在这里。</p>
|
||||
</div>
|
||||
<button @click="logout"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-rose-500/15 text-rose-300 hover:bg-rose-500/25 hover:text-rose-200 ring-1 ring-rose-500/30 px-4 py-2 text-sm font-medium transition-all">
|
||||
<Icon name="close" class="w-3.5 h-3.5" /> 退出登录
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- ===== Two-column grid ===== -->
|
||||
<div class="grid lg:grid-cols-2 gap-5">
|
||||
|
||||
<!-- API KEY (full width) -->
|
||||
<section class="lg:col-span-2 card overflow-hidden">
|
||||
<div class="p-7 md:p-8 relative">
|
||||
<div class="absolute -top-16 -right-16 w-48 h-48 rounded-full opacity-30 blur-3xl"
|
||||
style="background: radial-gradient(circle,#a855f7,transparent 60%)"></div>
|
||||
<div class="relative grid md:grid-cols-[260px_1fr] gap-8 items-start">
|
||||
<div>
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-violet-500/15 ring-1 ring-violet-400/30 grid place-items-center text-violet-300">
|
||||
<Icon name="plug" class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold mt-4">API Key</h2>
|
||||
<p class="text-sm text-white/50 mt-2 leading-relaxed">
|
||||
调用 <code class="bg-white/10 text-white/90 px-1 py-0.5 rounded text-xs">/v1/*</code> 接口需要的访问密钥。会保存在浏览器本地。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/50 mb-2">当前密钥</label>
|
||||
|
||||
<!-- has a key -->
|
||||
<div v-if="hasKey" class="rounded-xl bg-white/[0.05] ring-1 ring-white/10 px-4 py-3 flex items-center gap-3">
|
||||
<code class="flex-1 font-mono text-sm text-white/90 break-all">{{ apiKeyRevealed && apiKey ? apiKey : maskedKey }}</code>
|
||||
<button v-if="apiKey" @click="apiKeyRevealed = !apiKeyRevealed"
|
||||
class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 hover:bg-white/[0.06] hover:ring-white/20 transition-all whitespace-nowrap">
|
||||
{{ apiKeyRevealed ? '隐藏' : '显示' }}
|
||||
</button>
|
||||
<button @click="copyKey"
|
||||
class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 hover:bg-white/[0.06] hover:ring-white/20 transition-all">
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- no key -->
|
||||
<div v-else class="rounded-xl border border-dashed border-white/15 px-4 py-5 text-center text-xs text-white/40">
|
||||
还没有 Key — 点下面的「生成」按钮自动生成
|
||||
</div>
|
||||
|
||||
<p v-if="apiKey" class="text-[11px] text-amber-300/80 mt-2">⚠ 完整 Key 仅显示这一次,请立刻复制保存。</p>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button @click="generateKey"
|
||||
class="rounded-xl bg-white text-black hover:bg-white/90 px-5 py-2.5 text-sm font-semibold transition-colors">
|
||||
{{ hasKey ? '重新生成' : '生成 Key' }}
|
||||
</button>
|
||||
<button v-if="hasKey" @click="clearKey"
|
||||
class="rounded-xl ring-1 ring-rose-500/30 text-rose-300 hover:bg-rose-500/15 px-4 py-2.5 text-sm transition-colors">
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-white/40 mt-3">
|
||||
Key 由系统随机生成,不能手动填写。重新生成会让旧 Key 立刻失效。完整调用示例见
|
||||
<router-link to="/docs" class="text-violet-300 underline">接口文档</router-link>。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CHECK-IN -->
|
||||
<section v-if="checkinEnabled" class="relative card p-7 md:p-8 overflow-hidden">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-sky-500/15 ring-1 ring-sky-400/30 grid place-items-center text-sky-300">
|
||||
<Icon name="refresh" class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold mt-4">每日签到</h2>
|
||||
<p class="text-sm text-white/50 mt-2">每天签到 +{{ checkinReward }} 积分。</p>
|
||||
|
||||
<!-- streak -->
|
||||
<div class="mt-5 flex items-baseline gap-2">
|
||||
<span class="text-3xl font-bold tabular-nums">{{ streak }}</span>
|
||||
<span class="text-xs text-white/40 uppercase tracking-widest">天连续</span>
|
||||
</div>
|
||||
|
||||
<!-- 7-day dots -->
|
||||
<div class="mt-5 flex items-center gap-1.5">
|
||||
<div v-for="(d, i) in last7" :key="d.ds"
|
||||
class="flex-1 h-12 rounded-xl ring-1 transition-all flex flex-col items-center justify-center"
|
||||
:class="d.lit
|
||||
? 'bg-sky-400/25 ring-sky-300/50 text-sky-100'
|
||||
: d.isToday
|
||||
? (checkedToday ? 'bg-sky-400/25 ring-sky-300/50 text-sky-100' : 'bg-white/[0.05] ring-white/15 text-white/60')
|
||||
: 'bg-white/[0.02] ring-white/[0.06] text-white/30'">
|
||||
<Icon v-if="d.lit || (d.isToday && checkedToday)" name="spark" class="w-3 h-3" />
|
||||
<span v-else class="text-[10px] uppercase">{{ d.isToday ? '今' : i + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="checkin" :disabled="checkedToday"
|
||||
class="mt-5 w-full rounded-xl bg-white text-black hover:bg-white/90 disabled:bg-white/10 disabled:text-white/40 disabled:cursor-not-allowed py-3 text-sm font-semibold transition-colors">
|
||||
{{ checkedToday ? `今日已签到 · 明天再来` : `立即签到 +${checkinReward} 积分` }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- CDK REDEEM -->
|
||||
<section class="relative card p-7 md:p-8 overflow-hidden">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 grid place-items-center text-emerald-300">
|
||||
<Icon name="spark" class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold mt-4">兑换码充值</h2>
|
||||
<div class="mt-2 flex items-baseline gap-2">
|
||||
<span class="text-3xl font-bold tabular-nums">{{ points(balance).toLocaleString('en-US') }}</span>
|
||||
<span class="text-xs text-white/40 uppercase tracking-widest">积分余额</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-2">
|
||||
<input v-model="cdkCode" @keyup.enter="redeemCdk" placeholder="输入兑换码 (CDK)"
|
||||
class="flex-1 rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm font-mono uppercase tracking-wider outline-none transition-colors placeholder:text-white/30 placeholder:normal-case placeholder:tracking-normal" />
|
||||
<button @click="redeemCdk" :disabled="cdkBusy"
|
||||
class="rounded-xl bg-white text-black hover:bg-white/90 disabled:opacity-40 px-5 py-3 text-sm font-semibold transition-colors">
|
||||
{{ cdkBusy ? '兑换中…' : '兑换' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="cdkError" class="text-xs text-rose-400 mt-2">{{ cdkError }}</p>
|
||||
<p class="text-[11px] text-white/35 mt-3">兑换码每个仅可使用一次。还没有兑换码?到商店购买后回此处充值。</p>
|
||||
<a v-if="site.contact?.shop" :href="site.contact.shop" target="_blank" rel="noopener"
|
||||
class="mt-3 group inline-flex items-center gap-2 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 text-emerald-300 hover:bg-emerald-500/25 px-4 py-2.5 text-sm font-semibold transition-colors">
|
||||
<Icon name="spark" class="w-4 h-4" />
|
||||
前往商店购买兑换码
|
||||
<span class="group-hover:translate-x-0.5 transition-transform">→</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- PASSWORD -->
|
||||
<section class="card p-7 md:p-8">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-amber-500/15 ring-1 ring-amber-400/30 grid place-items-center text-amber-300">
|
||||
<Icon name="refresh" class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold mt-4">修改密码</h2>
|
||||
<p class="text-sm text-white/50 mt-2">8-24 位,必须包含大写字母、小写字母、数字和符号。</p>
|
||||
|
||||
<div class="mt-6 space-y-3">
|
||||
<input v-model="pwdForm.current" type="password" placeholder="当前密码" autocomplete="current-password"
|
||||
class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" />
|
||||
<input v-model="pwdForm.next" type="password" placeholder="新密码(8-24位,含大小写/数字/符号)" autocomplete="new-password"
|
||||
class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" />
|
||||
<input v-model="pwdForm.confirm" type="password" placeholder="再次输入新密码" autocomplete="new-password"
|
||||
class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" />
|
||||
<p v-if="pwdError" class="text-xs text-rose-400">{{ pwdError }}</p>
|
||||
<button @click="changePwd" :disabled="pwdSubmitting"
|
||||
class="w-full rounded-xl bg-white text-black hover:bg-white/90 disabled:opacity-40 py-3 text-sm font-semibold transition-colors">
|
||||
{{ pwdSubmitting ? '提交中…' : '更新密码' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SIGN OUT (full width) -->
|
||||
<section class="lg:col-span-2 card p-6 md:p-7 flex items-center gap-5 flex-wrap">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-rose-500/15 ring-1 ring-rose-400/30 grid place-items-center text-rose-300">
|
||||
<Icon name="close" class="w-4 h-4" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-base font-semibold">退出登录</div>
|
||||
<p class="text-sm text-white/50 mt-1">会清除本地 API Key、余额缓存。下次进来要重新设置。</p>
|
||||
</div>
|
||||
<button @click="logout"
|
||||
class="rounded-xl bg-rose-500/20 text-rose-200 hover:bg-rose-500/30 ring-1 ring-rose-500/30 px-5 py-2.5 text-sm font-semibold transition-colors">
|
||||
退出
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- toast -->
|
||||
<transition name="fade">
|
||||
<div v-if="toastMsg"
|
||||
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">
|
||||
{{ toastMsg }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(8px); }
|
||||
</style>
|
||||
@@ -0,0 +1,417 @@
|
||||
<script setup>
|
||||
// Admin → 首页内容
|
||||
// CRUD for the cards rendered on the public home page:
|
||||
// - hero : top-3-by-weight stacked deck in the hero
|
||||
// - bento : "从一个起点开始" grid
|
||||
// - work : "我们的作品" marquee — admin-curated featured outputs
|
||||
// All three kinds use a real image as the background; admins pick one from
|
||||
// the already-generated files or paste an external URL.
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { api, jsonBody, generatedUrl } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const items = ref([])
|
||||
const filter = ref('all') // 'all' | 'hero' | 'bento' | 'work'
|
||||
const loading = ref(false)
|
||||
const editing = ref(null) // truthy when the form modal is open
|
||||
const picking = ref(false) // truthy when the image-picker modal is open
|
||||
const recentFiles = ref([]) // populated from /stats.recent for the picker
|
||||
const page = ref(1)
|
||||
const pageSize = ref(12)
|
||||
const form = reactive({
|
||||
id: '', kind: 'hero', title: '', subtitle: '', prompt: '',
|
||||
image: '', weight: 100, span: '',
|
||||
})
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
const r = await api('/showcase')
|
||||
const grouped = r.data?.data || {}
|
||||
// Guard every group — a payload missing hero/bento would throw on spread of
|
||||
// undefined and freeze the page on "加载中…".
|
||||
items.value = [...(grouped.hero || []), ...(grouped.bento || []), ...(grouped.work || [])]
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (filter.value === 'all') return items.value
|
||||
return items.value.filter((x) => x.kind === filter.value)
|
||||
})
|
||||
|
||||
// Client-side pagination over the filtered set. The showcase store is small
|
||||
// (admin curates manually) so paging client-side is fine — no extra API calls
|
||||
// when the admin flips pages.
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filtered.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
function setFilter(v) { filter.value = v; page.value = 1 }
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target !== page.value) page.value = target
|
||||
}
|
||||
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])
|
||||
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
|
||||
})
|
||||
|
||||
const KIND_DEFAULT_WEIGHT = { hero: 200, bento: 300, work: 100 }
|
||||
|
||||
function openNew(kind) {
|
||||
editing.value = { kind }
|
||||
Object.assign(form, {
|
||||
id: '', kind, title: '', subtitle: '', prompt: '',
|
||||
image: '', weight: KIND_DEFAULT_WEIGHT[kind], span: '',
|
||||
})
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function openEdit(rec) {
|
||||
editing.value = rec
|
||||
Object.assign(form, {
|
||||
id: rec.id, kind: rec.kind, title: rec.title || '', subtitle: rec.subtitle || '',
|
||||
prompt: rec.prompt || '', image: rec.image || '',
|
||||
weight: rec.weight ?? 100, span: rec.span || '',
|
||||
})
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function closeForm() { editing.value = null }
|
||||
|
||||
async function save() {
|
||||
if (!form.image.trim()) { error.value = '请选择底图'; return }
|
||||
if (form.kind !== 'work') {
|
||||
if (!form.title.trim()) { error.value = '请输入标题'; return }
|
||||
if (!form.prompt.trim()) { error.value = '请输入提示词'; return }
|
||||
}
|
||||
saving.value = true; error.value = ''
|
||||
const payload = {
|
||||
kind: form.kind,
|
||||
title: form.title.trim(),
|
||||
subtitle: form.subtitle.trim(),
|
||||
prompt: form.prompt.trim(),
|
||||
image: form.image.trim(),
|
||||
weight: Number(form.weight) || 0,
|
||||
span: form.span.trim(),
|
||||
}
|
||||
const r = form.id
|
||||
? await api(`/showcase/${form.id}`, jsonBody('PATCH', payload))
|
||||
: await api('/showcase', jsonBody('POST', payload))
|
||||
saving.value = false
|
||||
if (r.ok) { closeForm(); refresh() }
|
||||
else error.value = r.data?.detail || `保存失败 (${r.status})`
|
||||
}
|
||||
|
||||
async function remove(rec) {
|
||||
if (!confirm(`删除「${rec.title || rec.image}」?`)) return
|
||||
const r = await api(`/showcase/${rec.id}`, { method: 'DELETE' })
|
||||
if (r.ok) refresh()
|
||||
}
|
||||
|
||||
// Image picker — show the admin's OWN recently generated images (scoped to their
|
||||
// owner directory, not everyone's). The admin clicks one to fill `form.image`,
|
||||
// or pastes a URL into the text field.
|
||||
async function openPicker() {
|
||||
picking.value = true
|
||||
if (!recentFiles.value.length) {
|
||||
const r = await api('/my-images')
|
||||
const files = r.data?.data || []
|
||||
recentFiles.value = files.filter((f) =>
|
||||
/\.(png|jpe?g|webp|gif)$/i.test(f.name)
|
||||
)
|
||||
}
|
||||
}
|
||||
function closePicker() { picking.value = false }
|
||||
function pickImage(file) {
|
||||
form.image = file.name
|
||||
picking.value = false
|
||||
}
|
||||
|
||||
function bgFor(image) {
|
||||
if (!image) return {}
|
||||
const src = /^https?:\/\//i.test(image) ? image : generatedUrl(image)
|
||||
return {
|
||||
backgroundImage: `url("${src}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
}
|
||||
|
||||
const SPAN_PRESETS = ['', 'md:col-span-2', 'md:row-span-2', 'md:col-span-2 md:row-span-2']
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- header / filter / new -->
|
||||
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button @click="setFilter('all')" class="filter-pill" :class="filter === 'all' && 'on'">全部</button>
|
||||
<button @click="setFilter('hero')" class="filter-pill" :class="filter === 'hero' && 'on'">Hero 卡片</button>
|
||||
<button @click="setFilter('bento')" class="filter-pill" :class="filter === 'bento' && 'on'">Bento 灵感</button>
|
||||
<button @click="setFilter('work')" class="filter-pill" :class="filter === 'work' && 'on'">我们的作品</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openNew('hero')" class="btn-soft">+ Hero</button>
|
||||
<button @click="openNew('bento')" class="btn-soft">+ Bento</button>
|
||||
<button @click="openNew('work')" class="btn-soft">+ Work</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- grid -->
|
||||
<div v-if="loading" class="text-center text-xs text-white/40 py-12">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="text-center text-xs text-white/40 py-12">没有条目</div>
|
||||
<div v-else class="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<div v-for="rec in pagedItems" :key="rec.id"
|
||||
class="media-card relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[4/3] group bg-white/[0.04]"
|
||||
:style="bgFor(rec.image)">
|
||||
<!-- Fallback gradient for legacy entries that haven't been migrated yet. -->
|
||||
<div v-if="!rec.image" class="absolute inset-0" :style="{ background: rec.gradient }"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
|
||||
<div class="absolute top-3 left-3 flex items-center gap-1.5">
|
||||
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded ring-1"
|
||||
:class="rec.kind === 'hero' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30'
|
||||
: rec.kind === 'bento' ? 'bg-violet-500/20 text-violet-200 ring-violet-400/30'
|
||||
: 'bg-sky-500/20 text-sky-200 ring-sky-400/30'">
|
||||
{{ rec.kind }}
|
||||
</span>
|
||||
<span class="text-[10px] text-white/65 tabular-nums px-1.5 py-0.5 rounded bg-black/40 ring-1 ring-white/10">w={{ rec.weight }}</span>
|
||||
</div>
|
||||
<div class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button @click="openEdit(rec)" class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center" title="编辑">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="remove(rec)" class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-rose-500/80 text-white grid place-items-center" title="删除">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-4">
|
||||
<div v-if="rec.subtitle" class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ rec.subtitle }}</div>
|
||||
<div v-if="rec.title" class="text-base font-bold text-white mt-1">{{ rec.title }}</div>
|
||||
<div v-if="rec.prompt" class="text-[11px] text-white/65 mt-1 line-clamp-2">{{ rec.prompt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- pagination — shown when there's more than one page worth of entries -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
class="card !p-3 flex items-center justify-between gap-3">
|
||||
<div class="text-xs text-white/55 tabular-nums px-2">
|
||||
<span class="text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
||||
/ {{ filtered.length }} 条
|
||||
</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>
|
||||
|
||||
<!-- ======= form modal ======= -->
|
||||
<transition name="fade">
|
||||
<div v-if="editing"
|
||||
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm grid place-items-center p-4"
|
||||
@click.self="closeForm">
|
||||
<div class="card w-full max-w-2xl !shadow-2xl">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ form.id ? '编辑' : '新增' }} ·
|
||||
{{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }}
|
||||
</h2>
|
||||
<button @click="closeForm" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<!-- live preview -->
|
||||
<div class="relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[5/2] bg-white/[0.04]"
|
||||
:style="bgFor(form.image)">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
|
||||
<div v-if="!form.image" class="absolute inset-0 grid place-items-center text-xs text-white/40">
|
||||
未选择底图
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-5">
|
||||
<div v-if="form.subtitle" class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ form.subtitle }}</div>
|
||||
<div v-if="form.title" class="text-xl font-bold text-white mt-1">{{ form.title }}</div>
|
||||
<div v-if="form.prompt" class="text-xs text-white/65 mt-1 line-clamp-2">{{ form.prompt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">类型</label>
|
||||
<div class="flex gap-1.5">
|
||||
<button type="button" @click="form.kind = 'hero'" class="kind-btn" :class="form.kind === 'hero' && 'on'">Hero</button>
|
||||
<button type="button" @click="form.kind = 'bento'" class="kind-btn" :class="form.kind === 'bento' && 'on'">Bento</button>
|
||||
<button type="button" @click="form.kind = 'work'" class="kind-btn" :class="form.kind === 'work' && 'on'">Work</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">权重 <span class="text-white/35">(越大越靠前)</span></label>
|
||||
<input v-model.number="form.weight" type="number" class="field" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- image picker (the central change — admins pick a real image) -->
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">底图</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="form.image" class="field font-mono text-[11px]"
|
||||
placeholder="user/abc.png 或 https://…" />
|
||||
<button type="button" @click="openPicker" class="btn-soft shrink-0">选择已生成</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-white/35 mt-1">填写 /generated 下的相对路径,或粘贴一个外链 URL。</p>
|
||||
</div>
|
||||
|
||||
<template v-if="form.kind !== 'work'">
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">标题</label>
|
||||
<input v-model="form.title" class="field" placeholder="电影感人物" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">副标题</label>
|
||||
<input v-model="form.subtitle" class="field" placeholder="CINEMATIC PORTRAIT" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">提示词 <span class="text-white/35">(点 Bento 后会预填到画图)</span></label>
|
||||
<textarea v-model="form.prompt" rows="3" class="field resize-none"
|
||||
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">作品标题 <span class="text-white/35">(可选)</span></label>
|
||||
<input v-model="form.title" class="field" placeholder="留空则只展示图片" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="form.kind === 'bento'">
|
||||
<label class="block text-xs text-white/55 mb-1.5">网格跨度 <span class="text-white/35">(Tailwind class)</span></label>
|
||||
<div class="flex gap-1.5 flex-wrap mb-2">
|
||||
<button v-for="s in SPAN_PRESETS" :key="s" type="button" @click="form.span = s"
|
||||
class="px-2.5 py-1 text-[11px] rounded-lg ring-1 ring-white/10 hover:bg-white/[0.08]"
|
||||
:class="form.span === s ? 'bg-white text-slate-900' : 'bg-white/[0.04] text-white/70'">
|
||||
{{ s || '默认 1×1' }}
|
||||
</button>
|
||||
</div>
|
||||
<input v-model="form.span" class="field font-mono text-[11px]" placeholder="md:col-span-2" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-3 border-t border-white/[0.06] flex items-center justify-end gap-2">
|
||||
<button @click="closeForm" class="btn-ghost">取消</button>
|
||||
<button @click="save" :disabled="saving" class="btn-primary">
|
||||
{{ saving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- ======= image picker modal ======= -->
|
||||
<transition name="fade">
|
||||
<div v-if="picking"
|
||||
class="fixed inset-0 z-[60] bg-black/80 backdrop-blur-sm grid place-items-center p-4"
|
||||
@click.self="closePicker">
|
||||
<div class="card w-full max-w-4xl !shadow-2xl">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">选择底图 · 最近生成</h2>
|
||||
<button @click="closePicker" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 max-h-[70vh] overflow-y-auto">
|
||||
<div v-if="!recentFiles.length" class="text-center text-xs text-white/40 py-10">尚未有生成过的图片</div>
|
||||
<div v-else class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
<button v-for="f in recentFiles" :key="f.name" type="button" @click="pickImage(f)"
|
||||
class="relative aspect-square rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all">
|
||||
<img :src="generatedUrl(f.name)" loading="lazy" class="w-full h-full object-cover" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-pill {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.filter-pill:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.filter-pill.on { background: white; color: rgb(15 23 42); }
|
||||
|
||||
.kind-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.kind-btn:hover { background: rgb(255 255 255 / 0.1); }
|
||||
.kind-btn.on { background: white; color: rgb(15 23 42); }
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
color: white;
|
||||
transition: border-color 0.18s, background 0.18s;
|
||||
}
|
||||
.field:focus { border-color: rgb(167 139 250 / 0.65); background: rgb(255 255 255 / 0.06); }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* numbered pagination — matches LogsView / ImagesView */
|
||||
.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: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup>
|
||||
// Front-end "日志" page — a row-per-event log of the signed-in user's OWN
|
||||
// generations (success / failed / pending), surfacing failure reasons that the
|
||||
// image-only 记录 gallery hides. Uses the same /logs endpoint (auto-scoped to
|
||||
// the caller), just without the success-only filter.
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { fmtDate, fmtClock, fmtTs } from '../utils/format'
|
||||
import { points } from '../credits'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const items = ref([]) // current server page
|
||||
const total = ref(0) // server-side total (matches current filters)
|
||||
const stats = ref({ total: 0, success: 0, failed: 0, pending: 0 }) // 本人统计
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('') // '' | success | failed | pending
|
||||
const sourceFilter = ref('') // '' | api | web (api = key 调用, web = 画图台)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const lightbox = ref(null)
|
||||
|
||||
// 来源筛选走服务端:画图台 = source "user",API = source "v1"。
|
||||
const SOURCE_PARAM = { web: 'user', api: 'v1' }
|
||||
|
||||
// 服务端分页 —— 不再只拉前 200 条;按页向后端取,可翻到全部历史。
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(pageSize),
|
||||
offset: String((page.value - 1) * pageSize),
|
||||
})
|
||||
if (statusFilter.value) qs.set('status', statusFilter.value)
|
||||
if (SOURCE_PARAM[sourceFilter.value]) qs.set('source', SOURCE_PARAM[sourceFilter.value])
|
||||
const r = await api('/logs?' + qs.toString())
|
||||
loading.value = false
|
||||
if (r.ok) {
|
||||
items.value = r.data?.data || []
|
||||
total.value = Number(r.data?.total ?? items.value.length)
|
||||
if (r.data?.stats) stats.value = r.data.stats
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
// Source: backend stamps "v1" for API-key calls, "user"/"admin" for the
|
||||
// playground/test page. Collapse to two buckets the user cares about.
|
||||
const isApi = (e) => e.source === 'v1'
|
||||
const sourceLabel = (e) => (isApi(e) ? 'API' : '画图台')
|
||||
const sourcePill = (e) => (isApi(e)
|
||||
? 'bg-violet-50 text-violet-700 ring-violet-200'
|
||||
: 'bg-sky-50 text-sky-700 ring-sky-200')
|
||||
|
||||
// 搜索只在当前页内过滤(状态/来源已由服务端筛选并分页)。
|
||||
const displayed = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return items.value
|
||||
return items.value.filter((e) =>
|
||||
(e.model || '').toLowerCase().includes(q) ||
|
||||
(e.prompt || '').toLowerCase().includes(q) ||
|
||||
(e.error || '').toLowerCase().includes(q))
|
||||
})
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
const pageStart = computed(() => total.value === 0 ? 0 : (page.value - 1) * pageSize + 1)
|
||||
const pageEnd = computed(() => Math.min(total.value, page.value * pageSize))
|
||||
function setStatus(v) { statusFilter.value = v; page.value = 1; load() }
|
||||
function setSource(v) { sourceFilter.value = v; page.value = 1; load() }
|
||||
|
||||
// Numbered pagination strip — first + last + a window around current; gaps
|
||||
// collapse to null ("…"). Mirrors the admin 日志 page so both look the same.
|
||||
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])
|
||||
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 t = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (t === page.value) return
|
||||
page.value = t
|
||||
load()
|
||||
}
|
||||
|
||||
const statusLabel = (s) => ({ success: '成功', failed: '失败', pending: '进行中' }[s] || s)
|
||||
const statusPill = (s) => ({
|
||||
success: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
|
||||
failed: 'bg-rose-50 text-rose-700 ring-rose-200',
|
||||
pending: 'bg-amber-50 text-amber-700 ring-amber-200',
|
||||
}[s] || 'bg-slate-100 text-slate-500 ring-slate-200')
|
||||
const statusDot = (s) => ({
|
||||
success: 'bg-emerald-500', failed: 'bg-rose-500', pending: 'bg-amber-500',
|
||||
}[s] || 'bg-slate-400')
|
||||
// Match the admin 日志 params exactly: 比例 · 画质 · [时长] · [参考 N].
|
||||
const params = (e) => {
|
||||
const parts = [e.ratio || '—', e.resolution || '—']
|
||||
if (e.duration) parts.push(e.duration)
|
||||
if (e.refs > 0) parts.push(`参考 ${e.refs}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5 log-page">
|
||||
<!-- Header -->
|
||||
<div class="flex items-end justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">生成日志</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">{{ total }} 条记录 · 含失败原因</p>
|
||||
</div>
|
||||
<button @click="router.push('/user')" class="btn-primary">
|
||||
<Icon name="spark" class="w-4 h-4" /> 去画图
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI 统计(本人累计) -->
|
||||
<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-slate-400">总计</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-slate-900">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-600/80">成功</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-600">{{ stats.success }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-rose-600/80">失败</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-600">{{ stats.failed }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-600/80">进行中</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-600">{{ stats.pending }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button v-for="s in [['','全部'],['success','成功'],['failed','失败'],['pending','进行中']]" :key="s[0]"
|
||||
@click="setStatus(s[0])"
|
||||
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||||
:class="statusFilter === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-slate-200"></div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button v-for="s in [['','全部来源'],['web','画图台'],['api','API']]" :key="s[0]"
|
||||
@click="setSource(s[0])"
|
||||
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||||
:class="sourceFilter === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[180px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 提示词 / 模型 / 错误…" />
|
||||
</div>
|
||||
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
|
||||
</div>
|
||||
|
||||
<!-- States -->
|
||||
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中…</div>
|
||||
<div v-else-if="!total" class="card flex flex-col items-center gap-3 text-slate-400 py-24">
|
||||
<span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="log" class="w-6 h-6" /></span>
|
||||
<span class="text-sm">还没有生成日志</span>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="card overflow-hidden !p-0">
|
||||
<table class="w-full text-sm table-fixed log-table">
|
||||
<colgroup>
|
||||
<col class="w-16" /> <!-- preview -->
|
||||
<col class="w-28" /> <!-- time -->
|
||||
<col class="w-24" /> <!-- status -->
|
||||
<col class="w-36" /> <!-- model -->
|
||||
<col /> <!-- prompt/error -->
|
||||
<col class="w-40" /> <!-- params -->
|
||||
<col class="w-14" /> <!-- cost -->
|
||||
<col class="w-16" /> <!-- elapsed -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.18em] text-slate-400 border-b border-slate-200">
|
||||
<th class="text-center 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-left px-3 py-3 font-medium">参数</th>
|
||||
<th class="text-right px-3 py-3 font-medium">积分</th>
|
||||
<th class="text-right px-3 py-3 font-medium">耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in displayed" :key="e.id" class="log-row">
|
||||
<td class="px-3 py-3 align-middle text-center">
|
||||
<button v-if="e.status === 'success' && e.file" @click="lightbox = e"
|
||||
class="block w-11 h-11 mx-auto rounded-lg overflow-hidden ring-1 ring-slate-200 hover:ring-fuchsia-300 transition-all">
|
||||
<img v-if="e.kind !== 'video'" :src="generatedUrl(e.file)" loading="lazy" class="w-full h-full object-cover" />
|
||||
<video v-else :src="generatedUrl(e.file)" muted preload="metadata" class="w-full h-full object-cover" />
|
||||
</button>
|
||||
<span v-else class="text-slate-300">—</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle text-xs whitespace-nowrap" :title="fmtTs(e.ts)">
|
||||
<div v-if="e.ts" class="leading-tight">
|
||||
<div class="text-slate-600 tabular-nums">{{ fmtDate(e.ts) }}</div>
|
||||
<div class="text-slate-400 tabular-nums">{{ fmtClock(e.ts) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-slate-300">—</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 whitespace-nowrap" :class="statusPill(e.status)">
|
||||
<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 align-middle min-w-0">
|
||||
<div class="font-mono text-xs text-slate-800 truncate" :title="e.model">{{ e.model }}</div>
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<span class="text-[10px] uppercase tracking-wider font-medium"
|
||||
:class="e.kind === 'video' ? 'text-fuchsia-600' : 'text-indigo-600'">
|
||||
{{ e.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded px-1.5 py-px text-[10px] font-medium ring-1 whitespace-nowrap"
|
||||
:class="sourcePill(e)">{{ sourceLabel(e) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle min-w-0">
|
||||
<div class="text-xs text-slate-700 truncate" :title="e.prompt">{{ e.prompt || '—' }}</div>
|
||||
<div v-if="e.error" class="mt-1 text-[11px] text-rose-600 truncate" :title="e.error">⚠ {{ e.error }}</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle text-xs text-slate-500 tabular-nums">{{ params(e) || '—' }}</td>
|
||||
<td class="px-3 py-3 align-middle text-right text-xs text-slate-700 tabular-nums">{{ e.cost ? points(e.cost) : '—' }}</td>
|
||||
<td class="px-3 py-3 align-middle text-right text-xs text-slate-500 tabular-nums">{{ e.elapsed_ms ? (e.elapsed_ms / 1000).toFixed(1) + 's' : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination — numbered with ellipsis, inside the card footer exactly
|
||||
like the admin 日志 page (top border + px-5 py-3). -->
|
||||
<div v-if="total && totalPages > 1"
|
||||
class="flex items-center justify-between gap-3 border-t border-slate-200 px-5 py-3 text-xs text-slate-500">
|
||||
<div>
|
||||
<span class="tabular-nums text-slate-700">{{ 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-slate-300">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaLightbox
|
||||
v-if="lightbox"
|
||||
:src="generatedUrl(lightbox.file)"
|
||||
:kind="lightbox.kind"
|
||||
:prompt="lightbox.prompt"
|
||||
:meta="[lightbox.model, lightbox.ratio, lightbox.resolution, lightbox.duration].filter(Boolean).join(' · ')"
|
||||
:download-name="lightbox.file"
|
||||
@close="lightbox = null" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Row hover — light-theme twin of the admin 日志 page's .log-row: a subtle
|
||||
tint plus a violet accent bar on the left edge of the hovered row. */
|
||||
.log-table { border-collapse: separate; border-spacing: 0; }
|
||||
.log-row td {
|
||||
border-bottom: 1px solid rgb(15 23 42 / 0.06);
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.log-row:hover td { background: rgb(15 23 42 / 0.025); }
|
||||
.log-row:hover td:first-child { box-shadow: inset 2px 0 0 rgb(124 58 237 / 0.6); }
|
||||
.log-row:last-child td { border-bottom: none; }
|
||||
|
||||
/* Numbered pagination buttons — light-theme twin of the admin 日志 page's .pg */
|
||||
.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: rgb(71 85 105);
|
||||
background: rgb(241 245 249);
|
||||
box-shadow: inset 0 0 0 1px rgb(15 23 42 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(226 232 240); color: rgb(15 23 42); }
|
||||
.pg-on {
|
||||
background: rgb(15 23 42);
|
||||
color: white;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { fmtTs } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const kindFilter = ref('') // '', 'image', 'video'
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
// 20 per page so a 4-col (lg) / 5-col (xl) grid lays out as clean rows,
|
||||
// matching the admin 图片管理 (ImagesView) page.
|
||||
const pageSize = 20
|
||||
let timer = null
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
// Server-side pagination over real media only: status=success + has_file=1
|
||||
// makes the row count == displayable count, so the numbered pager is accurate.
|
||||
// Failed/pending/file-pruned rows live in admin /admin/logs, never here.
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(pageSize),
|
||||
offset: String((page.value - 1) * pageSize),
|
||||
status: 'success',
|
||||
has_file: '1',
|
||||
})
|
||||
if (kindFilter.value) qs.set('kind', kindFilter.value)
|
||||
const r = await api('/logs?' + qs.toString())
|
||||
items.value = (r.data?.data || []).filter((e) => e.status === 'success' && e.file)
|
||||
total.value = Number(r.data?.total ?? items.value.length)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// Search narrows the CURRENT page (same as the admin 日志 page); the numbered
|
||||
// pager still reflects the full server-side total.
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return items.value
|
||||
return items.value.filter((e) =>
|
||||
(e.model || '').toLowerCase().includes(q) ||
|
||||
(e.prompt || '').toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
function setKind(v) { kindFilter.value = v; page.value = 1; load() }
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target === page.value) return
|
||||
page.value = target
|
||||
load()
|
||||
}
|
||||
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])
|
||||
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 fmtMs(ms) {
|
||||
if (!ms) return ''
|
||||
if (ms < 1000) return ms + 'ms'
|
||||
return (ms / 1000).toFixed(1) + 's'
|
||||
}
|
||||
|
||||
|
||||
async function copyLink(name) {
|
||||
try {
|
||||
const u = generatedUrl(name)
|
||||
await navigator.clipboard.writeText(u.startsWith('http') ? u : location.origin + u)
|
||||
toast.value = '链接已复制'
|
||||
setTimeout(() => (toast.value = ''), 1500)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const toast = ref('')
|
||||
const lightbox = ref(null)
|
||||
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
timer = setInterval(load, 3000)
|
||||
window.addEventListener('keydown', onKey)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-end justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">我的创作记录</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">
|
||||
{{ total }} 条作品
|
||||
</p>
|
||||
</div>
|
||||
<button @click="router.push('/user')" class="btn-primary">
|
||||
<Icon name="spark" class="w-4 h-4" /> 去画图
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button @click="setKind('')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||||
:class="kindFilter === '' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button>
|
||||
<button @click="setKind('image')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||||
:class="kindFilter === 'image' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">图像</button>
|
||||
<button @click="setKind('video')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||||
:class="kindFilter === 'video' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">视频</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[180px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索提示词或模型…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中…</div>
|
||||
<div v-else-if="!filtered.length"
|
||||
class="card flex flex-col items-center gap-3 text-slate-400 py-24">
|
||||
<span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="spark" class="w-6 h-6" /></span>
|
||||
<span class="text-sm">还没有创作记录</span>
|
||||
<button @click="router.push('/user')" class="btn-soft mt-2">开始第一张</button>
|
||||
</div>
|
||||
|
||||
<!-- Cards — gallery layout, matching 图片管理 (ImagesView) -->
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
<div v-for="e in filtered" :key="e.id"
|
||||
class="group relative rounded-xl overflow-hidden ring-1 ring-slate-200 bg-slate-100 aspect-[4/5]"
|
||||
:class="(e.status === 'success' && e.file) && 'cursor-zoom-in'"
|
||||
@click="(e.status === 'success' && e.file) && (lightbox = e)">
|
||||
<!-- media -->
|
||||
<template v-if="e.status === 'success' && e.file">
|
||||
<video v-if="e.kind === 'video'" :src="generatedUrl(e.file)" muted loop preload="metadata"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
@mouseenter="$event.target.play && $event.target.play()"
|
||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||
<img v-else :src="generatedUrl(e.file)" loading="lazy"
|
||||
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" />
|
||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div>
|
||||
</template>
|
||||
<!-- pending / failed placeholders -->
|
||||
<div v-else-if="e.status === 'pending'" class="absolute inset-0 grid place-items-center text-slate-400 text-xs">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="w-10 h-10 rounded-xl bg-white grid place-items-center animate-pulse"><Icon name="spark" class="w-4 h-4" /></span>
|
||||
生成中…
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute inset-0 grid place-items-center text-rose-500 text-xs px-4 text-center">
|
||||
<div>
|
||||
<Icon name="close" class="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||||
<div>生成失败</div>
|
||||
<div v-if="e.error" class="text-[10px] text-rose-400 line-clamp-2 mt-1">{{ e.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- kind chip -->
|
||||
<span class="absolute top-3 left-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
|
||||
:class="e.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
|
||||
{{ e.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
|
||||
<!-- hover actions (only when there's a file) -->
|
||||
<div v-if="e.status === 'success' && e.file"
|
||||
class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a :href="generatedUrl(e.file)" target="_blank" @click.stop title="新标签打开"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="open" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<button @click.stop="copyLink(e.file)" title="复制链接"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="copy" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<a :href="generatedUrl(e.file)" :download="e.file.split('/').pop()" @click.stop title="下载"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="download" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- caption (over a real image) -->
|
||||
<div v-if="e.status === 'success' && e.file" class="absolute inset-x-0 bottom-0 p-3 pointer-events-none">
|
||||
<div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1" :title="e.prompt">{{ e.prompt }}</div>
|
||||
<div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums">
|
||||
<span class="truncate" :title="e.model || ''">{{ e.model || '—' }}</span>
|
||||
<span class="shrink-0 flex items-center gap-1">
|
||||
<span v-if="e.resolution" class="text-emerald-300/90">{{ e.resolution }}</span>
|
||||
<span v-if="e.ratio" class="text-white/40">{{ e.ratio }}</span>
|
||||
<span v-if="e.kind === 'video' && e.duration" class="text-fuchsia-300/80">{{ e.duration }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtTs(e.ts) }}<span v-if="e.elapsed_ms"> · {{ fmtMs(e.elapsed_ms) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination — its own card, exactly like 图片管理 (ImagesView) -->
|
||||
<div v-if="total && totalPages > 1" class="card !p-3 flex items-center justify-between gap-3">
|
||||
<div class="text-xs text-slate-500 tabular-nums px-2">
|
||||
<span class="text-slate-700">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||||
/ {{ total }} 张
|
||||
</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-slate-300">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox (shared component) -->
|
||||
<MediaLightbox
|
||||
v-if="lightbox"
|
||||
:src="generatedUrl(lightbox.file)"
|
||||
:kind="lightbox.kind"
|
||||
:prompt="lightbox.prompt"
|
||||
:meta="[lightbox.model, lightbox.ratio, lightbox.resolution, lightbox.duration, fmtMs(lightbox.elapsed_ms)].filter(Boolean).join(' · ')"
|
||||
:download-name="lightbox.file"
|
||||
@close="lightbox = null" />
|
||||
|
||||
<!-- Toast -->
|
||||
<transition name="fade">
|
||||
<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>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* Numbered pagination buttons — light-theme twin of the admin .pg */
|
||||
.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: rgb(71 85 105);
|
||||
background: rgb(241 245 249);
|
||||
box-shadow: inset 0 0 0 1px rgb(15 23 42 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(226 232 240); color: rgb(15 23 42); }
|
||||
.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,569 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import SelectMenu from '../components/SelectMenu.vue'
|
||||
import { points } from '../credits'
|
||||
|
||||
const items = ref([])
|
||||
const stats = ref({ total: 0, active: 0, disabled: 0, admins: 0, credits_total: 0 })
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const roleFilter = ref('') // '' | 'admin' | 'user'
|
||||
const statusFilter = ref('') // '' | 'active' | 'disabled'
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const showAdd = ref(false)
|
||||
const editing = ref(null)
|
||||
const toast = ref('')
|
||||
|
||||
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0 })
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: '正常' },
|
||||
{ value: 'disabled', label: '禁用' },
|
||||
]
|
||||
|
||||
// 代理 = 走代理价的客户(不享管理权限)。
|
||||
// 管理员唯一:不能通过用户管理创建/改成管理员,所以选项只给 普通用户 / 代理。
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'user', label: '普通用户' },
|
||||
{ value: 'agent', label: '代理' },
|
||||
]
|
||||
const roleLabel = (r) => ({ user: '用户', agent: '代理', admin: '管理员' }[r] || '用户')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const r = await api('/users')
|
||||
items.value = r.data?.data || []
|
||||
stats.value = r.data?.stats || stats.value
|
||||
loading.value = false
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
// Newest first — created_at desc, falling back to id so users without a
|
||||
// timestamp still get a stable order.
|
||||
const sorted = [...items.value].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
return sorted.filter((u) => {
|
||||
if (roleFilter.value && u.role !== roleFilter.value) return false
|
||||
if (statusFilter.value && u.status !== statusFilter.value) return false
|
||||
if (q && !(
|
||||
(u.email || '').toLowerCase().includes(q) ||
|
||||
(u.name || '').toLowerCase().includes(q) ||
|
||||
(u.id || '').toLowerCase().includes(q)
|
||||
)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// Client-side pagination — user list is bounded.
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filtered.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target !== page.value) page.value = target
|
||||
}
|
||||
function setFilter(fn) { fn(); page.value = 1 }
|
||||
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])
|
||||
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
|
||||
})
|
||||
|
||||
let toastTimer = null
|
||||
function flash(m) {
|
||||
toast.value = m
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
if (!addForm.value.email.trim()) { flash('请输入邮箱'); return }
|
||||
const r = await api('/users', jsonBody('POST', addForm.value))
|
||||
if (r.ok) {
|
||||
showAdd.value = false
|
||||
addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0 }
|
||||
flash('用户已创建')
|
||||
load()
|
||||
} else flash(r.data?.detail || '创建失败')
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
const u = editing.value
|
||||
// Email + 用户名 are intentionally NOT in the patch — they're displayed
|
||||
// read-only in the form, and the admin shouldn't be in the habit of
|
||||
// rewriting a user's identity from this page.
|
||||
const patch = {
|
||||
status: u.status,
|
||||
credits: u.credits,
|
||||
role: u.role,
|
||||
}
|
||||
if (u._newPassword) patch.password = u._newPassword
|
||||
const r = await api(`/users/${u.id}`, jsonBody('PATCH', patch))
|
||||
if (r.ok) { editing.value = null; flash('已保存'); load() }
|
||||
else flash(r.data?.detail || '保存失败')
|
||||
}
|
||||
|
||||
async function toggleStatus(u) {
|
||||
// Optimistic: flip instantly so the switch moves the moment it's clicked;
|
||||
// persist in the background and revert on failure (no full table reload).
|
||||
const prev = u.status
|
||||
const next = u.status === 'active' ? 'disabled' : 'active'
|
||||
u.status = next
|
||||
const r = await api(`/users/${u.id}`, jsonBody('PATCH', { status: next }))
|
||||
if (r.ok) flash(next === 'active' ? '已启用' : '已禁用')
|
||||
else { u.status = prev; flash(r.data?.detail || '操作失败') }
|
||||
}
|
||||
|
||||
async function delUser(u) {
|
||||
if (!confirm(`删除用户 ${u.email}? 此操作不可恢复`)) return
|
||||
const r = await api(`/users/${u.id}`, { method: 'DELETE' })
|
||||
if (r.ok) { flash('已删除'); load() } else flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
|
||||
// ===== 多选删除 =====
|
||||
const selected = ref(new Set())
|
||||
function toggleSelect(id) {
|
||||
const s = new Set(selected.value)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
selected.value = s
|
||||
}
|
||||
const allSelected = computed(() =>
|
||||
filtered.value.length > 0 && filtered.value.every((u) => selected.value.has(u.id)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) filtered.value.forEach((u) => s.delete(u.id))
|
||||
else filtered.value.forEach((u) => s.add(u.id))
|
||||
selected.value = s
|
||||
}
|
||||
async function delSelected() {
|
||||
const ids = [...selected.value]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`确认删除选中的 ${ids.length} 个用户?此操作不可恢复。`)) return
|
||||
const r = await api('/users/delete-bulk', jsonBody('POST', { ids }))
|
||||
if (r.ok) { flash(`已删除 ${r.data?.deleted ?? ids.length} 个`); selected.value = new Set(); load() }
|
||||
else flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
|
||||
async function quickCredits(u, delta) {
|
||||
const r = await api(`/users/${u.id}/credits`, jsonBody('POST', { delta }))
|
||||
if (r.ok) { flash(`已${delta > 0 ? '增加' : '扣除'} ${Math.abs(delta).toLocaleString('en-US')} 积分`); load() }
|
||||
else flash(r.data?.detail || '调整失败')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — same shape as LogsView / ModelsView -->
|
||||
<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-white/45">用户总数</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-emerald-300/80">正常</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.active }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">管理员</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.admins }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">总积分</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ points(stats.credits_total).toLocaleString('en-US') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => roleFilter = '')" class="fp" :class="roleFilter === '' && 'fp-on'">全部角色</button>
|
||||
<button @click="setFilter(() => roleFilter = 'admin')" class="fp" :class="roleFilter === 'admin' && 'fp-fuchsia'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span>管理员
|
||||
</button>
|
||||
<button @click="setFilter(() => roleFilter = 'agent')" class="fp" :class="roleFilter === 'agent' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>代理
|
||||
</button>
|
||||
<button @click="setFilter(() => roleFilter = 'user')" class="fp" :class="roleFilter === 'user' && 'fp-on'">用户</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>禁用
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / 用户名 / ID…" />
|
||||
</div>
|
||||
<button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的用户">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
|
||||
</button>
|
||||
<button @click="load" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button @click="showAdd = true" class="btn-primary">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 新建用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||
<Icon name="accounts" class="w-6 h-6" />
|
||||
</span>
|
||||
<span class="text-sm">{{ items.length ? '没有匹配的用户' : '还没有用户' }}</span>
|
||||
<button v-if="!items.length" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-9" /> <!-- select -->
|
||||
<col class="w-40" /> <!-- username -->
|
||||
<col /> <!-- email (flex) -->
|
||||
<col class="w-20" /> <!-- role -->
|
||||
<col class="w-16" /> <!-- status switch -->
|
||||
<col class="w-24" /> <!-- credits -->
|
||||
<col class="w-20" /> <!-- generation count -->
|
||||
<col class="w-28" /> <!-- registered -->
|
||||
<col class="w-28" /> <!-- last login -->
|
||||
<col class="w-32" /> <!-- login IP -->
|
||||
<col class="w-24" /> <!-- actions -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-center px-3 py-3 font-medium">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
||||
class="chk" title="全选" />
|
||||
</th>
|
||||
<th class="text-left px-5 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-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">登录 IP</th>
|
||||
<th class="text-right px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in pagedItems" :key="u.id"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<td class="px-3 py-3.5 align-middle text-center">
|
||||
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" @click.stop
|
||||
class="chk" />
|
||||
</td>
|
||||
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'">
|
||||
{{ u.name || '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email">
|
||||
{{ u.email || '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
|
||||
:class="u.role === 'admin'
|
||||
? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30'
|
||||
: u.role === 'agent'
|
||||
? 'bg-amber-500/10 text-amber-300 ring-amber-400/30'
|
||||
: 'bg-white/[0.06] text-white/70 ring-white/15'">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
:class="u.role === 'admin' ? 'bg-fuchsia-400' : u.role === 'agent' ? 'bg-amber-400' : 'bg-slate-400'"></span>
|
||||
{{ roleLabel(u.role) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<button class="sw" :class="u.status === 'active' && 'sw-on'"
|
||||
:aria-pressed="u.status === 'active'"
|
||||
:title="u.status === 'active' ? '点击禁用账号' : '点击启用账号'"
|
||||
@click="toggleStatus(u)">
|
||||
<span class="sw-thumb"></span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85 whitespace-nowrap">
|
||||
{{ points(u.credits).toLocaleString('en-US') }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="u.generation_count > 0 ? 'text-white/85' : 'text-white/25'">
|
||||
{{ (u.generation_count || 0).toLocaleString('en-US') }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="u.created_at" class="leading-tight" :title="fmtTs(u.created_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(u.created_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(u.created_at) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">—</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="u.last_login_at" class="leading-tight" :title="fmtTs(u.last_login_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(u.last_login_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(u.last_login_at) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">从未登录</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs font-mono text-white/55 truncate" :title="u.last_login_ip || ''">
|
||||
{{ u.last_login_ip || '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button @click="editing = JSON.parse(JSON.stringify(u))" class="act" title="编辑">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="delUser(u)" class="act danger" title="删除">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
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">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
||||
<span class="ml-1">/ {{ filtered.length }} 条</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>
|
||||
|
||||
<!-- Add modal -->
|
||||
<div v-if="showAdd"
|
||||
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
|
||||
@click.self="showAdd = false">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-md">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">新建用户</h2>
|
||||
<button @click="showAdd = false" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<div>
|
||||
<label class="lbl">邮箱 <span class="text-rose-300">*</span></label>
|
||||
<input v-model="addForm.email" class="field" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">用户名</label>
|
||||
<input v-model="addForm.name" class="field" placeholder="6-24位,仅字母数字" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">初始密码</label>
|
||||
<input v-model="addForm.password" type="password" class="field" placeholder="留空表示不设密码;否则需满足8-24位且含大小写/数字/符号" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">初始积分</label>
|
||||
<input v-model.number="addForm.credits" type="number" min="0" step="1" class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">角色</label>
|
||||
<SelectMenu v-model="addForm.role" :options="ROLE_OPTIONS" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button @click="showAdd = false" class="btn-soft">取消</button>
|
||||
<button @click="createUser" class="btn-primary">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<div v-if="editing"
|
||||
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
|
||||
@click.self="editing = null">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-md">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">编辑用户</h2>
|
||||
<button @click="editing = null" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<!-- Email + 用户名 are show-only — identity edits go through register
|
||||
or a future support flow, not from this maintenance screen. -->
|
||||
<div>
|
||||
<label class="lbl">邮箱</label>
|
||||
<input :value="editing.email" disabled class="field font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">用户名</label>
|
||||
<input :value="editing.name" disabled class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">状态</label>
|
||||
<SelectMenu v-model="editing.status" :options="STATUS_OPTIONS" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">角色</label>
|
||||
<!-- 管理员唯一:管理员账号角色锁定,不可改;其他人只能在 普通用户/代理 间切换 -->
|
||||
<input v-if="editing.role === 'admin'" value="管理员(唯一,不可更改)" disabled class="field" />
|
||||
<SelectMenu v-else v-model="editing.role" :options="ROLE_OPTIONS" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">积分</label>
|
||||
<input v-model.number="editing.credits" type="number" min="0" step="1" class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">重置密码 <span class="text-white/35">(留空保持不变)</span></label>
|
||||
<input v-model="editing._newPassword" type="password" class="field" placeholder="新密码(8-24位,含大小写/数字/符号)" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button @click="editing = null" class="btn-soft">取消</button>
|
||||
<button @click="saveEdit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<transition name="fade">
|
||||
<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>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* --- filter pills (mirrors LogsView/ModelsView) */
|
||||
.fp {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem; font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-rose {
|
||||
background: rgb(244 63 94 / 0.22);
|
||||
color: rgb(253 164 175);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
|
||||
}
|
||||
.fp-fuchsia {
|
||||
background: rgb(217 70 239 / 0.22);
|
||||
color: rgb(245 208 254);
|
||||
box-shadow: inset 0 0 0 1px rgb(245 208 254 / 0.45);
|
||||
}
|
||||
.fp-amber {
|
||||
background: rgb(245 158 11 / 0.22);
|
||||
color: rgb(252 211 77);
|
||||
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45);
|
||||
}
|
||||
|
||||
/* --- icon-only action buttons */
|
||||
.act {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.9rem; height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.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);
|
||||
}
|
||||
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
/* disabled inputs in the edit modal — readable, but visually 'cool' so the
|
||||
admin knows they can't change them. */
|
||||
.field:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
}
|
||||
|
||||
/* iOS-style switch for the 状态 column — mirrors the one in ModelsView. */
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 2.25rem; height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.sw-thumb {
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw-on {
|
||||
background: rgb(16 185 129 / 0.7);
|
||||
box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5);
|
||||
}
|
||||
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
|
||||
/* --- numbered 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: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user