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:
2026-06-25 22:59:04 +08:00
co-authored by Claude Opus 4.8
commit 606caaf047
142 changed files with 33648 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
<script setup>
// App shell is layout-driven: each top-level route renders its own layout
// (PublicLayout for /, /user; AdminLayout for /admin/*). The login modal is
// mounted here so it can overlay any page instead of being a separate route.
import { onMounted } from 'vue'
import LoginModal from './components/LoginModal.vue'
import { auth, refreshMe, openRegister } from './auth'
// An invite link (/?ref=CODE) should drop a guest straight into registration
// with the code attached. Logged-in users just ignore the ref.
onMounted(async () => {
const code = new URLSearchParams(location.search).get('ref')
if (!code) return
if (!auth.ready) await refreshMe()
if (!auth.token || !auth.user) openRegister(code)
})
</script>
<template>
<router-view />
<LoginModal />
</template>
+53
View File
@@ -0,0 +1,53 @@
// Thin fetch wrapper mirroring the old admin.html `API()` helper.
// In dev, requests use relative paths and are proxied by Vite to the backend.
// For a separately-hosted frontend, set VITE_API_BASE (e.g. http://host:6060).
import { auth, clearSession } from './auth'
const BASE = import.meta.env.VITE_API_BASE || ''
/** Call an /admin/api endpoint. Returns { ok, status, data }. Automatically
* attaches the bearer token and clears the session on a 401 so admin pages
* fall back to the login screen via the router guard. */
export async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const r = await fetch(`${BASE}/admin/api${path}`, { ...opts, headers })
let data = null
try {
data = await r.json()
} catch {
data = null
}
// A 401 only means "log out" when it's the *caller's* session that's invalid.
// Business/upstream failures (a dead provider account, etc.) must NOT clear
// the session — they used to surface as 401 and kick the user out mid-action.
// The backend now flags genuine session expiry with detail "未登录或会话已过期";
// treat only those (or a token-less 401) as a real logout signal.
if (r.status === 401) {
const detail = data?.detail || ''
if (!auth.token || detail.includes('未登录') || detail.includes('会话')) {
clearSession()
}
}
return { ok: r.ok, status: r.status, data }
}
/** Shorthand for a JSON POST/PATCH body. */
export function jsonBody(method, payload) {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}
}
/** Health check hitting the plain /health endpoint. */
export async function fetchHealth() {
const r = await fetch(`${BASE}/health`)
return r.json()
}
/** Absolute URL for a generated artifact (works in dev via proxy too). */
export function generatedUrl(name) {
return `${BASE}/images/${name}`
}
+85
View File
@@ -0,0 +1,85 @@
// Client-side auth state. The session token lives in localStorage and is sent
// as `Authorization: Bearer <token>` on every admin API call (see api.js).
// The server slides the 24h session whenever it's used with <22h left, so an
// active admin never gets logged out; we also re-validate via /me on a timer.
import { reactive } from 'vue'
const TOKEN_KEY = 'gw_token'
const BASE = import.meta.env.VITE_API_BASE || ''
export const auth = reactive({
token: localStorage.getItem(TOKEN_KEY) || '',
user: null, // { id, email, name, role, status, credits, invite_code, ... }
ready: false, // true once an initial /me check has resolved
loginOpen: false, // is the login modal showing?
loginIntent: '', // where to go after a successful login
startMode: 'login',// which tab the modal opens on: 'login' | 'register'
pendingInvite: '', // invite code from a /?ref=CODE link, sent with register
})
export function isAuthed() { return !!auth.token && !!auth.user }
export function isAdmin() { return isAuthed() && auth.user.role === 'admin' }
export function isAgent() { return isAuthed() && auth.user.role === 'agent' }
export function getToken() { return auth.token }
/** Open the login modal, remembering where the user wanted to go. */
export function openLogin(intent = '') { auth.loginIntent = intent || ''; auth.startMode = 'login'; auth.loginOpen = true }
/** Open straight to the register tab, carrying an optional invite code.
* Default landing is the home page — an invitee clicking a /?ref=CODE
* link should NOT be punted into the画图 flow before they've explored. */
export function openRegister(inviteCode = '', intent = '/') {
auth.pendingInvite = inviteCode || ''
auth.loginIntent = intent || ''
auth.startMode = 'register'
auth.loginOpen = true
}
export function closeLogin() { auth.loginOpen = false }
export function setSession(token, user) {
auth.token = token || ''
auth.user = user || null
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
export function clearSession() {
auth.token = ''
auth.user = null
localStorage.removeItem(TOKEN_KEY)
}
/** Validate the stored token against /me. Refreshes auth.user; clears on 401.
* Returns the user (or null). Hitting /me also slides the server session. */
export async function refreshMe() {
if (!auth.token) { auth.user = null; auth.ready = true; return null }
try {
const r = await fetch(`${BASE}/admin/api/auth/me`, {
headers: { Authorization: `Bearer ${auth.token}` },
})
if (r.ok) {
const d = await r.json()
auth.user = d.user
} else {
clearSession()
}
} catch {
// network error — keep the token, don't force a logout
}
auth.ready = true
return auth.user
}
export async function logout() {
if (auth.token) {
try {
await fetch(`${BASE}/admin/api/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${auth.token}` },
})
} catch { /* ignore */ }
}
clearSession()
}
// Keep the session warm: re-validate every 10 minutes while a tab is open.
setInterval(() => { if (auth.token) refreshMe() }, 10 * 60 * 1000)
+32
View File
@@ -0,0 +1,32 @@
<script setup>
// Lightweight inline icon set (Lucide path data) — no extra dependency.
const PATHS = {
overview: '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
models: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
accounts: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
test: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0Z"/><path d="M20 3v4"/><path d="M22 5h-4"/>',
files: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
config: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/>',
spark: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0Z"/>',
plug: '<path d="M9 2v6"/><path d="M15 2v6"/><path d="M12 17v5"/><path d="M5 8h14"/><path d="M6 11V8h12v3a6 6 0 0 1-12 0Z"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
open: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
video: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18"/><path d="M3 7.5h4"/><path d="M3 12h18"/><path d="M3 16.5h4"/><path d="M17 3v18"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/>',
log: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
chevron: '<path d="m6 9 6 6 6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
}
defineProps({ name: { type: String, required: true } })
</script>
<template>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" v-html="PATHS[name] || ''" />
</template>
+134
View File
@@ -0,0 +1,134 @@
<script setup>
import { ref, computed } from 'vue'
import { api, jsonBody } from '../api'
import { parseImportInput } from '../utils/import'
import Icon from './Icon.vue'
const emit = defineEmits(['close', 'imported'])
const input = ref('')
const status = ref('')
const isError = ref(false)
const submitting = ref(false)
// Live preview of what the parser would extract — updates as the user types
// so they can see whether their paste was understood before clicking import.
const detected = computed(() => {
const items = parseImportInput(input.value)
const openai = items.filter((x) => x.type === 'openai').length
const adobe = items.filter((x) => x.type === 'adobe').length
const runway = items.filter((x) => x.type === 'runway').length
const leonardo = items.filter((x) => x.type === 'leonardo').length
const krea = items.filter((x) => x.type === 'krea').length
const imagine = items.filter((x) => x.type === 'imagine').length
return { total: items.length, openai, adobe, runway, leonardo, krea, imagine }
})
function setStatus(text, err = false) {
status.value = text || ''
isError.value = err
}
async function doSmartImport() {
const items = parseImportInput(input.value)
if (!items.length) {
setStatus('未识别到任何 Cookie 或 JWT', true)
return
}
submitting.value = true
let ok = 0, fail = 0
const errs = []
for (let i = 0; i < items.length; i++) {
const it = items[i]
setStatus(`正在导入 ${i + 1}/${items.length} (${it.type})…`)
try {
const r = it.type === 'openai'
? await api('/tokens/import-chatgpt-token', jsonBody('POST', { access_token: it.value }))
: it.type === 'runway'
? await api('/tokens/import-runway-token', jsonBody('POST', { access_token: it.value }))
: it.type === 'leonardo'
? await api('/tokens/import-leonardo-cookie', jsonBody('POST', { cookie: it.value }))
: it.type === 'krea'
? await api('/tokens/import-krea-cookie', jsonBody('POST', { cookie: it.value }))
: it.type === 'imagine'
? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value }))
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
if (r.ok) ok++
else { fail++; errs.push(`${it.type}: ${r.data?.detail || r.status}`) }
} catch (e) {
fail++; errs.push(`${it.type}: ${e}`)
}
}
submitting.value = false
// Quota isn't checked here — the server probes each token off-thread and the
// account list flips pending → active/dead on its own.
if (fail === 0) {
setStatus(`✓ 导入 ${ok} 项 · 正在后台检测额度…`)
emit('imported')
setTimeout(() => emit('close'), 1000)
} else {
setStatus(`成功 ${ok} · 失败 ${fail} · ${errs.slice(0, 3).join(' | ')}`, true)
emit('imported')
}
}
</script>
<template>
<div class="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
@click.self="emit('close')">
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-2xl">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 class="text-sm font-semibold">导入账号</h2>
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
<Icon name="close" class="w-5 h-5" />
</button>
</div>
<div class="p-5">
<p class="text-xs text-slate-500 mb-3 leading-relaxed">自动识别:
<strong class="text-slate-700">Adobe Cookie 字符串</strong>(<code class="px-1 bg-slate-100 rounded">k=v; k=v; ...</code>)、
<strong class="text-slate-700">Cookie JSON 对象</strong>、
<strong class="text-slate-700">Cookie 数组</strong>(多 Adobe 批量)、
<strong class="text-slate-700">ChatGPT JWT</strong>(<code class="px-1 bg-slate-100 rounded">eyJhbGciOi...</code>)、
<strong class="text-slate-700">Runway JWT</strong>(自动与 ChatGPT 区分)、
<strong class="text-slate-700">Leonardo Cookie</strong>(含 better-auth)、
<strong class="text-slate-700">Krea Cookie</strong>(含 sb-superb-auth)、
<strong class="text-slate-700">Imagine Token</strong>(<code class="px-1 bg-slate-100 rounded">{"token","refreshToken","email","parentId"}</code>)、
<strong class="text-slate-700">多个 JWT</strong>(换行分隔)。
全粘进来即可,无需任何前缀。
</p>
<textarea v-model="input" rows="10"
class="field font-mono text-xs resize-none"
placeholder="直接粘 Cookie 字符串 / JWT / JSON,自动识别"></textarea>
<div v-if="input.trim()" class="mt-2 flex items-center gap-2 text-xs">
<template v-if="detected.total">
<span class="text-emerald-600">✓ 识别到 <strong class="tabular-nums">{{ detected.total }}</strong> 个账号</span>
<span v-if="detected.openai" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200">
OpenAI · <span class="tabular-nums">{{ detected.openai }}</span>
</span>
<span v-if="detected.adobe" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-rose-700 bg-rose-50 ring-1 ring-rose-200">
Adobe · <span class="tabular-nums">{{ detected.adobe }}</span>
</span>
<span v-if="detected.runway" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-violet-700 bg-violet-50 ring-1 ring-violet-200">
Runway · <span class="tabular-nums">{{ detected.runway }}</span>
</span>
<span v-if="detected.leonardo" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-amber-700 bg-amber-50 ring-1 ring-amber-200">
Leonardo · <span class="tabular-nums">{{ detected.leonardo }}</span>
</span>
<span v-if="detected.krea" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-sky-700 bg-sky-50 ring-1 ring-sky-200">
Krea · <span class="tabular-nums">{{ detected.krea }}</span>
</span>
<span v-if="detected.imagine" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-teal-700 bg-teal-50 ring-1 ring-teal-200">
Imagine · <span class="tabular-nums">{{ detected.imagine }}</span>
</span>
</template>
<span v-else class="text-rose-600">未识别到任何 Cookie 或 JWT</span>
</div>
<button @click="doSmartImport" :disabled="submitting || !detected.total" class="btn-primary w-full mt-3">
{{ submitting ? '导入中…' : (detected.total ? `识别并导入 (${detected.total})` : '识别并导入') }}
</button>
<p v-if="status" class="text-xs mt-2" :class="isError ? 'text-rose-600' : 'text-emerald-600'">{{ status }}</p>
</div>
</div>
</div>
</template>
+404
View File
@@ -0,0 +1,404 @@
<script setup>
// Global login/register/forgot modal. Controlled by shared auth.loginOpen.
// Mounted once in App.vue so it overlays any page (home, user, redirect).
import { ref, reactive, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { auth, setSession, closeLogin } from '../auth'
import Logo from './Logo.vue'
const BASE = import.meta.env.VITE_API_BASE || ''
const router = useRouter()
const mode = ref('login') // login | register | forgot
const cfg = reactive({ open: true, email_code: false, allow_password_reset: true, has_admin: true })
// `identifier` is the login field (email OR username); `email` is only used by
// register/forgot, where an actual email address is required.
const form = reactive({ identifier: '', username: '', email: '', password: '', code: '' })
const busy = ref(false)
const error = ref('')
const notice = ref('')
const showPw = ref(false) // password visibility toggle
const codeCooldown = ref(0) // seconds left before re-sending
const sendingCode = ref(false) // request in flight → spinner on the button
let cooldownTimer = null
// Per-mode copy for the header so each tab reads as its own little page.
const heading = computed(() => ({
login: { title: '欢迎回来', sub: '登录以继续你的创作' },
register: { title: '创建账号', sub: '加入 Vivid,开始 AI 生图' },
forgot: { title: '找回密码', sub: '通过邮箱验证重置你的密码' },
}[mode.value]))
// Register/reset need an email code only once an admin exists (the very first
// account bootstraps the admin and skips it).
const needsCode = computed(() =>
cfg.email_code && cfg.has_admin && (mode.value === 'register' || mode.value === 'forgot'))
// Hide the tab bar when 登录 is the only available tab (注册 + 找回密码 both
// hidden) — a lone tab reads as a stray button. Conditions mirror the v-if on
// each tab below.
const showTabs = computed(() =>
(cfg.open || !cfg.has_admin) || cfg.allow_password_reset)
async function loadConfig() {
try {
const r = await fetch(`${BASE}/admin/api/auth/config`)
if (r.ok) Object.assign(cfg, await r.json())
} catch { /* offline — keep defaults */ }
}
async function sendCode() {
error.value = ''; notice.value = ''
if (!form.email) { error.value = '请先输入邮箱'; return }
if (codeCooldown.value > 0 || sendingCode.value) return
// Spin while the request is in flight; only start the countdown once the
// server actually accepts the send (so a failure lets the user retry at once).
sendingCode.value = true
try {
const r = await post('/auth/send-code', { email: form.email, purpose: mode.value === 'forgot' ? 'reset' : 'register' })
if (!r.ok) { error.value = r.detail || '验证码发送失败'; return }
notice.value = '验证码已发送,请查收邮箱'
codeCooldown.value = 60
clearInterval(cooldownTimer)
cooldownTimer = setInterval(() => { if (--codeCooldown.value <= 0) clearInterval(cooldownTimer) }, 1000)
} catch {
error.value = '验证码发送失败'
} finally {
sendingCode.value = false
}
}
function switchMode(m) {
mode.value = m
error.value = ''; notice.value = ''; showPw.value = false
// Clear all inputs on tab switch so a password/email typed under one tab
// doesn't carry over (and read as an auto-filled value) into another.
form.identifier = ''; form.username = ''; form.email = ''; form.password = ''; form.code = ''
codeCooldown.value = 0; clearInterval(cooldownTimer)
}
// Reset + reload config every time the modal opens. Honour startMode so an
// invite link can open straight on the register tab.
watch(() => auth.loginOpen, (open) => {
if (!open) return
error.value = ''; notice.value = ''; showPw.value = false
form.identifier = ''; form.username = ''; form.email = ''; form.password = ''; form.code = ''
loadConfig()
if (auth.startMode === 'register') switchMode('register')
else mode.value = 'login'
})
async function post(path, body) {
const r = await fetch(`${BASE}/admin/api${path}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
let data = {}
try { data = await r.json() } catch { /* ignore */ }
return { ok: r.ok, ...data }
}
async function submit() {
error.value = ''; notice.value = ''
if (mode.value === 'login') {
if (!form.identifier || !form.password) { error.value = '请输入账号和密码'; return }
} else {
if (!form.email || !form.password) { error.value = '请输入邮箱和密码'; return }
if (mode.value === 'register' && !form.username.trim()) { error.value = '请输入用户名'; return }
if (mode.value === 'register') {
const u = form.username.trim()
if (!/^[A-Za-z0-9]{6,24}$/.test(u)) {
error.value = '用户名需为 6-24 位字母或数字'
return
}
}
if (!/^\d{6}$/.test((form.code || '').trim()) && needsCode.value) {
error.value = '邮箱验证码必须是 6 位纯数字'
return
}
const pw = form.password || ''
if (pw.length < 8 || pw.length > 24) {
error.value = '密码长度需为 8-24 位'
return
}
}
busy.value = true
try {
if (needsCode.value && !form.code.trim()) { error.value = '请输入邮箱验证码'; busy.value = false; return }
if (mode.value === 'forgot') {
const r = await post('/auth/reset-password', {
email: form.email, password: form.password, email_code: form.code.trim(),
})
if (!r.ok) throw new Error(r.detail || '重置失败')
notice.value = '密码已重置,请用新密码登录'
switchMode('login')
return
}
const path = mode.value === 'register' ? '/auth/register' : '/auth/login'
const payload = mode.value === 'login'
? { identifier: form.identifier.trim(), password: form.password }
: { email: form.email, password: form.password }
if (mode.value === 'register') {
payload.username = form.username.trim()
payload.email_code = form.code.trim()
if (auth.pendingInvite) payload.invite_code = auth.pendingInvite
}
const r = await post(path, payload)
if (!r.ok) throw new Error(r.detail || '操作失败')
auth.pendingInvite = '' // consumed
setSession(r.token, r.user)
const intent = auth.loginIntent
closeLogin()
const dest = intent || (r.user.role === 'admin' ? '/admin/overview' : '/user')
router.push(dest)
} catch (e) {
error.value = e.message || String(e)
} finally {
busy.value = false
}
}
</script>
<template>
<transition name="modal">
<div v-if="auth.loginOpen" class="fixed inset-0 z-50 grid place-items-center px-4">
<div class="absolute inset-0 bg-black/70 backdrop-blur-md" @click="closeLogin"></div>
<div class="card relative z-10 w-full max-w-[26rem]">
<!-- ambient brand glow -->
<div class="pointer-events-none absolute -top-24 -right-16 h-56 w-56 rounded-full bg-fuchsia-500/20 blur-3xl"></div>
<div class="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-violet-500/20 blur-3xl"></div>
<div class="relative p-7">
<button class="absolute top-4 right-4 grid h-7 w-7 place-items-center rounded-lg text-[color:var(--fg-3)] hover:text-[color:var(--fg)] hover:bg-[var(--hover)] transition-colors"
@click="closeLogin" aria-label="关闭">
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<!-- header -->
<div class="flex flex-col items-center text-center mb-6">
<div class="logo-halo mb-3"><Logo :size="44" class="rounded-[13px]" /></div>
<transition name="swap" mode="out-in">
<div :key="mode">
<h2 class="text-lg font-semibold tracking-tight text-[color:var(--fg)]">{{ heading.title }}</h2>
<p class="mt-1 text-[13px] text-[color:var(--fg-3)]">{{ heading.sub }}</p>
</div>
</transition>
</div>
<!-- segmented tabs — hidden when 登录 is the only tab -->
<div v-if="showTabs" class="tabs mb-5">
<button class="tab" :class="mode === 'login' && 'tab-on'" @click="switchMode('login')">登录</button>
<button v-if="cfg.open || !cfg.has_admin" class="tab" :class="mode === 'register' && 'tab-on'" @click="switchMode('register')">注册</button>
<button v-if="cfg.allow_password_reset" class="tab" :class="mode === 'forgot' && 'tab-on'" @click="switchMode('forgot')">找回密码</button>
</div>
<transition name="swap" mode="out-in">
<p v-if="mode === 'register' && auth.pendingInvite" class="invite-banner">
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12v10H4V12"/><path d="M2 7h20v5H2z"/><path d="M12 22V7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>
<span>已应用邀请码 <span class="font-mono font-semibold text-emerald-200">{{ auth.pendingInvite }}</span>,完成首次生图后邀请人得 3 积分</span>
</p>
</transition>
<form @submit.prevent="submit" class="space-y-3">
<div v-if="mode === 'register'" class="lm-field">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<input v-model="form.username" type="text" autocomplete="username" placeholder="用户名(6-24位,仅字母数字)" class="fld" />
</div>
<div v-if="mode === 'login'" class="lm-field">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<input v-model="form.identifier" type="text" autocomplete="username" placeholder="邮箱或用户名" class="fld" />
</div>
<div v-else class="lm-field">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
<input v-model="form.email" type="email" autocomplete="email" placeholder="邮箱" class="fld" />
</div>
<div class="lm-field">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input v-model="form.password" :type="showPw ? 'text' : 'password'" autocomplete="current-password"
:placeholder="mode === 'forgot' ? '新密码(8-24位,含大小写/数字/符号)' : '密码'" class="fld pr-10" />
<button type="button" class="eye" @click="showPw = !showPw" :aria-label="showPw ? '隐藏密码' : '显示密码'">
<svg v-if="showPw" viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
<svg v-else viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><path d="m2 2 20 20"/></svg>
</button>
</div>
<div v-if="needsCode" class="flex items-center gap-2">
<div class="lm-field flex-1">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 11 3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<input v-model="form.code" type="text" maxlength="6" inputmode="numeric" placeholder="邮箱验证码(6位数字)" class="fld tracking-[0.3em]" />
</div>
<button type="button" @click="sendCode" :disabled="codeCooldown > 0 || sendingCode" class="code-btn">
<svg v-if="sendingCode" class="code-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<template v-else>{{ codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码' }}</template>
</button>
</div>
<p v-if="mode !== 'login'" class="text-[11px] leading-5 text-[color:var(--fg-3)]">
用户名:6-24 位,仅字母数字。密码:8-24 位,必须包含大写字母、小写字母、数字和符号。
</p>
<button type="submit" :disabled="busy" class="btn-primary w-full">
<span v-if="busy" class="spinner"></span>
{{ busy ? '处理中…' : mode === 'login' ? '登 录' : mode === 'register' ? '注 册' : '重置密码' }}
</button>
</form>
<transition name="swap" mode="out-in">
<p v-if="error" class="msg msg-err"><svg viewBox="0 0 24 24" class="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>{{ error }}</p>
<p v-else-if="notice" class="msg msg-ok"><svg viewBox="0 0 24 24" class="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>{{ notice }}</p>
</transition>
<p v-if="!cfg.open && cfg.has_admin" class="text-xs text-[color:var(--fg-3)] mt-4 text-center">当前未开放注册,请联系管理员开通账号。</p>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.card {
border-radius: 1.25rem;
background: linear-gradient(165deg, #ffffff 0%, #f6f6fb 100%);
color: var(--fg);
border: 1px solid var(--hairline);
box-shadow: 0 30px 70px -22px rgb(15 23 42 / 0.25);
overflow: hidden;
}
html.dark .card {
background: linear-gradient(165deg, #14161f 0%, #0d0f15 100%);
color: rgb(255 255 255 / 0.9);
border: none;
box-shadow: 0 30px 70px -20px rgb(0 0 0 / 0.7);
}
.logo-halo {
position: relative;
filter: drop-shadow(0 8px 18px rgb(168 85 247 / 0.45));
}
/* segmented tab control */
.tabs {
display: flex; gap: 0.25rem; padding: 0.25rem;
border-radius: 0.75rem;
background: var(--surface-2);
border: 1px solid var(--hairline);
}
.tab {
flex: 1; padding: 0.45rem 0; border-radius: 0.55rem;
font-size: 0.8125rem; color: var(--fg-3);
transition: background 0.18s, color 0.18s, box-shadow 0.18s;
}
.tab:hover { color: var(--fg-2); }
.tab-on {
color: rgb(124 58 237);
background: linear-gradient(135deg, rgb(167 139 250 / 0.22), rgb(236 72 153 / 0.18));
box-shadow: 0 1px 0 rgb(255 255 255 / 0.08) inset, 0 4px 12px -4px rgb(168 85 247 / 0.5);
}
html.dark .tab-on { color: white; }
/* icon-prefixed input */
.lm-field { position: relative; }
.lm-field .ic {
position: absolute; left: 0.8rem; top: 50%; transform: translateY(-50%);
width: 1.05rem; height: 1.05rem; color: var(--fg-3);
pointer-events: none; transition: color 0.18s;
}
.lm-field:focus-within .ic { color: rgb(124 58 237 / 0.95); }
html.dark .lm-field:focus-within .ic { color: rgb(196 181 253 / 0.95); }
.fld {
width: 100%; padding: 0.7rem 0.85rem 0.7rem 2.4rem; border-radius: 0.7rem;
background: rgb(15 23 42 / 0.03); border: 1px solid var(--hairline);
color: var(--fg); font-size: 0.875rem; outline: none;
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
}
html.dark .fld { background: rgb(255 255 255 / 0.04); }
.fld:focus {
border-color: rgb(167 139 250 / 0.65);
background: rgb(124 58 237 / 0.04);
box-shadow: 0 0 0 3px rgb(167 139 250 / 0.18);
}
html.dark .fld:focus { background: rgb(255 255 255 / 0.06); }
.fld::placeholder { color: var(--fg-faint); }
/* Keep autofill on-theme in dark (otherwise it paints white/yellow). */
html.dark .fld:-webkit-autofill,
html.dark .fld:-webkit-autofill:hover,
html.dark .fld:-webkit-autofill:focus {
-webkit-text-fill-color: white;
caret-color: white;
-webkit-box-shadow: 0 0 0 1000px #1a1c26 inset;
box-shadow: 0 0 0 1000px #1a1c26 inset;
transition: background-color 9999s ease-in-out 0s;
}
.eye {
position: absolute; right: 0.55rem; top: 50%; transform: translateY(-50%);
display: grid; place-items: center; width: 1.9rem; height: 1.9rem;
border-radius: 0.45rem; color: var(--fg-3);
transition: color 0.15s, background 0.15s;
}
.eye:hover { color: var(--fg); background: var(--hover); }
.code-btn {
flex-shrink: 0; height: 2.7rem; padding: 0 0.85rem; border-radius: 0.7rem;
min-width: 5.5rem; /* keep width stable between text / spinner */
display: inline-flex; align-items: center; justify-content: center;
font-size: 0.75rem; white-space: nowrap;
color: rgb(196 181 253 / 0.95);
background: rgb(167 139 250 / 0.1); border: 1px solid rgb(167 139 250 / 0.3);
transition: background 0.15s, opacity 0.15s;
}
.code-btn:hover:not(:disabled) { background: rgb(167 139 250 / 0.2); }
.code-btn:disabled { opacity: 0.45; cursor: not-allowed; color: rgb(255 255 255 / 0.5); border-color: rgb(255 255 255 / 0.12); }
.code-spin { width: 1.05rem; height: 1.05rem; animation: code-spin 0.7s linear infinite; }
@keyframes code-spin { to { transform: rotate(360deg); } }
.btn-primary {
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.72rem 0; border-radius: 0.7rem; font-size: 0.9rem; font-weight: 600;
letter-spacing: 0.02em; color: white; margin-top: 0.35rem;
background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%);
box-shadow: 0 10px 24px -8px rgb(168 85 247 / 0.6);
transition: transform 0.12s, box-shadow 0.18s, filter 0.18s;
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); box-shadow: 0 12px 28px -8px rgb(168 85 247 / 0.75); }
.btn-primary:active:not(:disabled) { transform: translateY(1px); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.spinner {
width: 0.95rem; height: 0.95rem; border-radius: 9999px;
border: 2px solid rgb(255 255 255 / 0.35); border-top-color: white;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.invite-banner {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.85rem;
padding: 0.6rem 0.75rem; border-radius: 0.7rem; font-size: 0.75rem; line-height: 1.4;
color: rgb(167 243 208);
background: rgb(16 185 129 / 0.1); border: 1px solid rgb(16 185 129 / 0.25);
}
.invite-banner svg { color: rgb(110 231 183); }
.msg {
display: flex; align-items: center; gap: 0.4rem; margin-top: 0.85rem;
font-size: 0.78rem; line-height: 1.4;
}
.msg-err { color: rgb(253 164 175); }
.msg-ok { color: rgb(110 231 183); }
/* modal in/out */
.modal-enter-active { transition: opacity 0.2s ease; }
.modal-leave-active { transition: opacity 0.16s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-active .card { transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.26s; }
.modal-enter-from .card { transform: translateY(12px) scale(0.97); opacity: 0; }
/* per-mode content swap */
.swap-enter-active, .swap-leave-active { transition: opacity 0.16s ease, transform 0.16s ease; }
.swap-enter-from { opacity: 0; transform: translateY(4px); }
.swap-leave-to { opacity: 0; transform: translateY(-4px); }
</style>
+39
View File
@@ -0,0 +1,39 @@
<script setup>
// Vivid mark: a violet→fuchsia rounded badge with a faceted, folded "V".
// Self-contained SVG so it renders identically on dark and light shells.
// Each instance gets unique gradient ids (useId) so multiple logos on one
// page don't share/clobber defs.
import { useId } from 'vue'
defineProps({ size: { type: [Number, String], default: 40 } })
const uid = useId()
const gBg = `vbg-${uid}`
const gGloss = `vgl-${uid}`
</script>
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none"
xmlns="http://www.w3.org/2000/svg" class="shrink-0">
<defs>
<linearGradient :id="gBg" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#a855f7" />
<stop offset="0.55" stop-color="#7c3aed" />
<stop offset="1" stop-color="#ec4899" />
</linearGradient>
<linearGradient :id="gGloss" x1="24" y1="2" x2="24" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffffff" stop-opacity="0.28" />
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
</linearGradient>
</defs>
<!-- badge -->
<rect x="2" y="2" width="44" height="44" rx="13" :fill="`url(#${gBg})`" />
<!-- top gloss for depth -->
<rect x="2" y="2" width="44" height="22" rx="13" :fill="`url(#${gGloss})`" />
<!-- faceted V: left arm brighter, right arm dimmer -> folded look -->
<path d="M12 15 H19 L24 26 L24 37 Z" fill="#ffffff" fill-opacity="0.95" />
<path d="M36 15 H29 L24 26 L24 37 Z" fill="#ffffff" fill-opacity="0.72" />
</svg>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup>
// Shared full-screen preview for a generated image/video. One look across the
// admin (图片管理 / 日志) and the user-facing (画图记录) surfaces. Parent controls
// mount via v-if and passes the resolved media URL + meta; the component owns the
// overlay shell, image/video element, prompt + meta block, and action buttons.
import Icon from './Icon.vue'
defineProps({
src: { type: String, required: true }, // resolved media URL (generatedUrl)
kind: { type: String, default: 'image' }, // 'image' | 'video'
prompt: { type: String, default: '' },
meta: { type: String, default: '' }, // primary meta line (mono)
metaSub: { type: String, default: '' }, // optional second meta line
downloadName: { type: String, default: '' },
})
const emit = defineEmits(['close'])
</script>
<template>
<transition name="lb-fade" appear>
<div class="media-card fixed inset-0 z-50 bg-slate-950/85 backdrop-blur-sm flex items-center justify-center p-6"
@click.self="emit('close')">
<!-- Wrapper shrinks to the media's rendered width, so the info row below
lines up flush with the image's left & right edges (one clean column). -->
<div class="flex flex-col max-h-full max-w-full">
<video v-if="kind === 'video'" :src="src" controls autoplay
class="max-h-[76vh] max-w-[88vw] rounded-xl shadow-2xl object-contain"></video>
<img v-else :src="src" class="max-h-[76vh] max-w-[88vw] rounded-xl shadow-2xl object-contain" />
<div class="mt-3 flex items-start justify-between gap-4 text-white">
<div class="min-w-0 flex-1">
<div v-if="prompt" class="text-sm font-medium leading-snug line-clamp-3 break-words" :title="prompt">{{ prompt }}</div>
<div v-if="meta" class="text-xs text-white/60 mt-1 font-mono break-all">{{ meta }}</div>
<div v-if="metaSub" class="text-xs text-white/45 mt-1">{{ metaSub }}</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<a :href="src" target="_blank"
class="inline-flex items-center gap-1.5 rounded-lg bg-white/10 hover:bg-white/20 px-3 py-1.5 text-xs transition-colors">
<Icon name="open" class="w-3.5 h-3.5" /> 原图
</a>
<a :href="src" :download="downloadName"
class="inline-flex items-center gap-1.5 rounded-lg bg-white text-slate-900 hover:bg-slate-100 px-3 py-1.5 text-xs font-medium transition-colors">
<Icon name="download" class="w-3.5 h-3.5" /> 下载
</a>
<button @click="emit('close')"
class="w-8 h-8 rounded-lg bg-white/10 hover:bg-white/20 grid place-items-center transition-colors">
<Icon name="close" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.lb-fade-enter-active, .lb-fade-leave-active { transition: opacity 0.18s ease; }
.lb-fade-enter-from, .lb-fade-leave-to { opacity: 0; }
</style>
+342
View File
@@ -0,0 +1,342 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { api, jsonBody } from '../api'
import { sortResolutions } from '../utils/format'
import Icon from './Icon.vue'
import SelectMenu from './SelectMenu.vue'
const props = defineProps({
// null → add mode; an object (managed-model record) → edit mode
model: { type: Object, default: null },
})
const emit = defineEmits(['close', 'saved'])
const isEdit = computed(() => !!props.model)
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
const catalog = ref([])
const loading = ref(true)
const selectedId = ref(props.model?.id || '')
const imagePrices = ref({}) // 普通价 { '1K': '', '2K': '', ... } keyed by resolutions
const videoPrices = ref({}) // 普通价 { '5s': '', '10s': '', ... } keyed by durations
const imagePricesAgent = ref({}) // 代理价(留空 = 跟随普通价)
const videoPricesAgent = ref({}) // 代理价(留空 = 跟随普通价)
// Display weight — admin-set (NOT a catalog param): higher = higher up the
// dropdown / model list. Defaults to the stored value in edit mode, else 0.
const weight = ref(Number(props.model?.weight) || 0)
const error = ref('')
const saving = ref(false)
// The entry whose params drive the form: in edit mode it's the stored record,
// in add mode it's the catalog row for the picked id. All generation params are
// read straight off it — the admin never types them, only the price.
const entry = computed(() => {
if (isEdit.value) return props.model
return catalog.value.find((e) => e.id === selectedId.value) || null
})
const isVideo = computed(() => entry.value?.type === 'video')
// Display tiers in canonical ascending order (720p before 1080p; 1K<2K<4K)
// regardless of how the catalog/stored record happens to list them.
const resolutions = computed(() => sortResolutions(entry.value?.resolutions || []))
// Duration tiers for the price inputs. Prefer the declared `durations`; fall
// back to the keys of any stored duration_prices so a model saved without a
// `durations` array (the legacy sync bug) is still viewable/editable.
const durationTiers = computed(() => {
const e = entry.value
if (!e) return []
const ds = e.durations || []
return ds.length ? ds : Object.keys(e.duration_prices || {})
})
// Dropdown options: catalog rows not already in the managed store.
const addOptions = computed(() =>
catalog.value
.filter((e) => !e.added)
.map((e) => ({ value: e.id, label: `${e.id} · ${e.type === 'video' ? '视频' : '图像'}` }))
)
function resetPrices(e) {
imagePrices.value = {}
videoPrices.value = {}
imagePricesAgent.value = {}
videoPricesAgent.value = {}
if (!e) return
// Both image and video price per resolution; video ALSO prices per duration
// (real video price = resolution price + duration price).
for (const r of (e.resolutions || [])) { imagePrices.value[r] = ''; imagePricesAgent.value[r] = '' }
if (e.type === 'video') {
for (const d of (e.durations || [])) { videoPrices.value[d] = ''; videoPricesAgent.value[d] = '' }
}
}
// In add mode, switching the selected model rebuilds the price inputs to match
// that model's resolution / duration tiers.
watch(selectedId, () => { if (!isEdit.value) resetPrices(entry.value) })
onMounted(async () => {
const r = await api('/catalog')
catalog.value = r.data?.data || []
loading.value = false
if (isEdit.value) {
const m = props.model
// Resolution prices apply to both; video additionally has duration prices.
// Agent prices are an optional overlay (blank = follows the normal price).
for (const r of (m.resolutions || [])) {
imagePrices.value[r] = m.prices?.[r] ?? ''
imagePricesAgent.value[r] = m.prices_agent?.[r] ?? ''
}
if (m.type === 'video') {
const durs = (m.durations && m.durations.length) ? m.durations : Object.keys(m.duration_prices || {})
for (const d of durs) {
videoPrices.value[d] = m.duration_prices?.[d] ?? ''
videoPricesAgent.value[d] = m.duration_prices_agent?.[d] ?? ''
}
}
}
})
async function save() {
const e = entry.value
if (!e) { error.value = '请选择模型'; return }
error.value = ''
// Collect valid (>=0) numeric prices from a {key: value} ref into a plain map.
const collect = (src, keys) => {
const out = {}
for (const k of keys) {
const raw = String(src[k] ?? '').trim()
if (raw === '') continue
const n = Number(raw)
if (!isNaN(n) && n >= 0) out[k] = n
}
return out
}
let payload
if (e.type === 'video') {
// Real video price = resolution price + duration price. Both tiers are
// priced independently; a blank tier on either axis = unsupported. Charge
// happens server-side as prices[res] + duration_prices[dur].
const prices = collect(imagePrices.value, e.resolutions || [])
const duration_prices = collect(videoPrices.value, durationTiers.value)
// 代理价:可选覆盖,留空的档跟随普通价。
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
const duration_prices_agent = collect(videoPricesAgent.value, durationTiers.value)
if (!Object.keys(prices).length) { error.value = '至少填写一个分辨率价格'; return }
if (!Object.keys(duration_prices).length) { error.value = '至少填写一个时长价格'; return }
payload = {
type: 'video',
provider: e.provider,
ratios: e.ratios || [],
resolutions: e.resolutions || [],
prices,
// durations MUST track duration_prices — the model list / docs iterate
// `durations` to render the per-second price chips. Persisting prices
// without the matching durations array hides them. Keep them in sync.
durations: Object.keys(duration_prices),
duration_prices,
prices_agent,
duration_prices_agent,
max_reference_images: e.max_reference_images || 0,
reference_mode: e.reference_mode || 'none',
weight: Number(weight.value) || 0,
}
} else {
const prices = collect(imagePrices.value, e.resolutions || [])
const prices_agent = collect(imagePricesAgent.value, e.resolutions || [])
if (!Object.keys(prices).length) { error.value = '至少填写一个画质价格'; return }
payload = {
type: 'image',
provider: e.provider,
ratios: e.ratios || [],
prices,
prices_agent,
image_to_image: !!e.image_to_image,
// 多参考图:把目录定义的张数(gpt=3/seedream=6/flux=4 …)写进模型,
// 否则后端仍按旧值(默认 1)限制。
max_reference_images: e.max_reference_images || 0,
reference_mode: e.reference_mode || 'none',
weight: Number(weight.value) || 0,
}
}
saving.value = true
const r = isEdit.value
? await api(`/managed-models/${encodeURIComponent(e.id)}`, jsonBody('PATCH', payload))
: await api('/managed-models', jsonBody('POST', { id: e.id, ...payload }))
saving.value = false
if (r.ok) emit('saved')
else error.value = r.data?.detail || `保存失败 (${r.status})`
}
</script>
<template>
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
@click.self="emit('close')">
<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">{{ isEdit ? '编辑价格' : '新增模型' }}</h2>
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
<Icon name="close" class="w-5 h-5" />
</button>
</div>
<div class="p-5 space-y-4">
<div v-if="loading" class="text-center text-sm text-white/40 py-8">加载支持的模型…</div>
<template v-else>
<!-- model id: dropdown when adding, fixed label when editing -->
<div>
<label class="lbl">模型</label>
<SelectMenu v-if="!isEdit" v-model="selectedId" :options="addOptions"
placeholder="选择一个支持的模型" />
<div v-else class="field font-mono !cursor-default opacity-90">{{ entry?.id }}</div>
<p v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-300/80 mt-1.5">
所有支持的模型都已添加。
</p>
</div>
<!-- read-only param summary, straight from the loaded catalog -->
<div v-if="entry" class="rounded-xl bg-white/[0.03] ring-1 ring-white/[0.06] p-3.5 space-y-2.5">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="isVideo ? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30'
: 'bg-indigo-500/10 text-indigo-300 ring-indigo-400/30'">
{{ isVideo ? '生视频' : '生图' }}
</span>
<span class="text-[11px] text-white/45 capitalize">{{ entry.provider }}</span>
<span class="ml-auto text-[10px] text-white/30">参数自动加载,不可改</span>
</div>
<div class="grid grid-cols-[3.5rem_1fr] gap-x-3 gap-y-1.5 text-[11px]">
<span class="text-white/40">比例</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in (entry.ratios || [])" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!(entry.ratios || []).length" class="text-white/30">—</span>
</div>
<template v-if="isVideo">
<span class="text-white/40">分辨率</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!resolutions.length" class="text-white/30">—</span>
</div>
<span class="text-white/40">时长</span>
<div class="flex flex-wrap gap-1">
<span v-for="d in (entry.durations || [])" :key="d" class="ro-chip">{{ d }}</span>
<span v-if="!(entry.durations || []).length" class="text-white/30">—</span>
</div>
<span class="text-white/40">参考图</span>
<div class="text-white/70">
{{ entry.max_reference_images > 0
? `${entry.max_reference_images} 张 · ${REF_MODE_LABEL[entry.reference_mode] || entry.reference_mode}`
: '不支持' }}
</div>
</template>
<template v-else>
<span class="text-white/40">画质</span>
<div class="flex flex-wrap gap-1">
<span v-for="r in resolutions" :key="r" class="ro-chip">{{ r }}</span>
<span v-if="!resolutions.length" class="text-white/30">—</span>
</div>
<span class="text-white/40">图生图</span>
<div class="text-white/70">{{ entry.image_to_image ? '支持' : '不支持' }}</div>
</template>
</div>
</div>
<!-- ===== PRICE: image = per-quality; video = per-quality + per-duration (additive) ===== -->
<template v-if="entry">
<div class="space-y-4">
<!-- resolution prices (both image & video): 普通价 + 代理价 -->
<div>
<label class="lbl">{{ isVideo ? '分辨率价格' : '画质价格' }} <span class="text-white/35">(普通价留空 = 不支持该档;代理价留空 = 跟随普通价)</span></label>
<div class="space-y-2">
<div v-if="resolutions.length" class="flex items-center gap-2 text-[10px] text-white/35 pl-14">
<span class="flex-1">普通价</span>
<span class="flex-1">代理价</span>
</div>
<div v-for="r in resolutions" :key="r" class="flex items-center gap-2">
<div class="w-12 shrink-0 text-sm text-white/85 font-mono">{{ r }}</div>
<div class="relative flex-1">
<input v-model="imagePrices[r]" type="number" min="0" step="1" class="field !pr-10" placeholder="普通价" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/30 text-[10px]">积分</span>
</div>
<div class="relative flex-1">
<input v-model="imagePricesAgent[r]" type="number" min="0" step="1" class="field !pr-10" placeholder="跟随普通" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-300/40 text-[10px]">代理</span>
</div>
</div>
<p v-if="!resolutions.length" class="text-xs text-white/35">该模型未声明分辨率档位</p>
</div>
</div>
<!-- duration prices (video only): 普通价 + 代理价 -->
<div v-if="isVideo">
<label class="lbl">时长价格 <span class="text-white/35">(代理价留空 = 跟随普通价)</span></label>
<div v-if="durationTiers.length" class="space-y-2">
<div class="flex items-center gap-2 text-[10px] text-white/35 pl-14">
<span class="flex-1">普通价</span>
<span class="flex-1">代理价</span>
</div>
<div v-for="d in durationTiers" :key="d" class="flex items-center gap-2">
<div class="w-12 shrink-0 text-sm text-white/85 font-mono">{{ d }}</div>
<div class="relative flex-1">
<input v-model="videoPrices[d]" type="number" min="0" step="1" class="field !pr-10" placeholder="普通价" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/30 text-[10px]">积分</span>
</div>
<div class="relative flex-1">
<input v-model="videoPricesAgent[d]" type="number" min="0" step="1" class="field !pr-10" placeholder="跟随普通" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-300/40 text-[10px]">代理</span>
</div>
</div>
</div>
<p v-else class="text-xs text-white/35">该模型未声明时长档位</p>
</div>
<p v-if="isVideo" class="text-[11px] text-white/40">实付 = 分辨率价 + 时长价(例:720p 50 + 5s 30 = 80 积分)</p>
<!-- display weight: admin-set ordering (not a catalog param) -->
<div>
<label class="lbl">展示权重 <span class="text-white/35">(数值越大,在下拉 / 列表中越靠前;相同权重按新建时间)</span></label>
<input v-model="weight" type="number" step="1" class="field" placeholder="0" />
</div>
</div>
</template>
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
<div class="flex justify-end gap-2 pt-1">
<button @click="emit('close')" class="btn-soft">取消</button>
<button @click="save" :disabled="saving || !entry" class="btn-primary">{{ saving ? '保存中…' : '保存' }}</button>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.lbl {
display: block;
font-size: 0.72rem;
font-weight: 500;
color: rgb(255 255 255 / 0.55);
margin-bottom: 0.4rem;
}
/* read-only param chip */
.ro-chip {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.45rem;
font-size: 0.68rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
border-radius: 0.4rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.05);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<script setup>
// Custom dropdown that replaces the native <select> so the OPEN list is themed
// too (native option popups can't be styled). Trigger mirrors the `.field` look;
// the panel is a floating dark menu with hover + selected states. Keyboard:
// Enter/Space/↑/↓ open, ↑/↓ move, Enter select, Esc close.
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import Icon from './Icon.vue'
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
// [{ value, label }]
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '请选择' },
mono: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const root = ref(null)
const active = ref(-1) // keyboard-highlighted index
const selected = computed(() => props.options.find((o) => o.value === props.modelValue) || null)
const label = computed(() => (selected.value ? selected.value.label : props.placeholder))
function toggle() {
if (props.disabled) return
open.value ? close() : openMenu()
}
function openMenu() {
open.value = true
active.value = Math.max(0, props.options.findIndex((o) => o.value === props.modelValue))
nextTick(scrollActiveIntoView)
}
function close() {
open.value = false
active.value = -1
}
function pick(opt) {
emit('update:modelValue', opt.value)
close()
}
function onKeydown(e) {
if (!open.value) {
if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(e.key)) { e.preventDefault(); openMenu() }
return
}
if (e.key === 'Escape') { e.preventDefault(); close() }
else if (e.key === 'ArrowDown') { e.preventDefault(); active.value = Math.min(props.options.length - 1, active.value + 1); scrollActiveIntoView() }
else if (e.key === 'ArrowUp') { e.preventDefault(); active.value = Math.max(0, active.value - 1); scrollActiveIntoView() }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.options[active.value]) pick(props.options[active.value]) }
}
const panel = ref(null)
function scrollActiveIntoView() {
nextTick(() => {
const el = panel.value?.querySelector(`[data-idx="${active.value}"]`)
el?.scrollIntoView({ block: 'nearest' })
})
}
function onDocClick(e) {
if (open.value && root.value && !root.value.contains(e.target)) close()
}
onMounted(() => document.addEventListener('mousedown', onDocClick))
onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
</script>
<template>
<div ref="root" class="relative">
<!-- trigger -->
<button type="button" @click="toggle" @keydown="onKeydown"
:aria-expanded="open"
:disabled="disabled"
class="field flex items-center justify-between gap-2 text-left disabled:opacity-50 disabled:cursor-not-allowed"
:class="[mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']">
<span class="truncate">{{ label }}</span>
<Icon name="chevron"
class="w-4 h-4 shrink-0 text-[color:var(--fg-3)] transition-transform duration-200"
:class="open ? 'rotate-180' : ''" />
</button>
<!-- panel -->
<transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 -translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-1">
<div v-if="open" ref="panel"
class="absolute z-30 mt-2 w-full max-h-64 overflow-auto rounded-xl border border-[color:var(--hairline)]
bg-[var(--menu-bg)] backdrop-blur-xl p-1.5 shadow-2xl shadow-black/20 ring-1 ring-[color:var(--hairline)]">
<button v-for="(o, i) in options" :key="o.value" type="button"
:data-idx="i" @click="pick(o)" @mouseenter="active = i"
class="w-full flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors"
:class="[
mono ? 'font-mono' : '',
i === active ? 'bg-[var(--hover)] text-[color:var(--fg)]' : 'text-[color:var(--fg-2)]',
]">
<span class="truncate">{{ o.label }}</span>
<Icon v-if="o.value === modelValue" name="check" class="w-4 h-4 shrink-0 text-violet-400" />
</button>
<div v-if="!options.length" class="px-3 py-2 text-xs text-[color:var(--fg-3)]">无选项</div>
</div>
</transition>
</div>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue'
import Icon from './Icon.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
presets: { type: Array, default: () => [] },
placeholder: { type: String, default: '输入后回车添加' },
})
const emit = defineEmits(['update:modelValue'])
const input = ref('')
function add(v) {
const s = String(v ?? '').trim()
if (s && !props.modelValue.includes(s)) emit('update:modelValue', [...props.modelValue, s])
}
function addInput() { add(input.value); input.value = '' }
function remove(i) {
const arr = [...props.modelValue]
arr.splice(i, 1)
emit('update:modelValue', arr)
}
</script>
<template>
<div>
<div v-if="modelValue.length" class="flex flex-wrap gap-1.5 mb-2">
<span v-for="(t, i) in modelValue" :key="t" class="chip">
{{ t }}
<button type="button" @click="remove(i)" class="chip-x" :title="`移除 ${t}`">
<Icon name="close" class="w-3 h-3" />
</button>
</span>
</div>
<div class="flex gap-2">
<input v-model="input" @keyup.enter.prevent="addInput" class="field" :placeholder="placeholder" />
<button type="button" @click="addInput" class="btn-soft shrink-0">添加</button>
</div>
<div v-if="presets.length" class="flex flex-wrap gap-1.5 mt-2">
<button v-for="p in presets" :key="p" type="button" @click="add(p)" :disabled="modelValue.includes(p)"
class="preset-btn">
+ {{ p }}
</button>
</div>
</div>
</template>
<style scoped>
/* Tag chip — small pill that reads as a brand-tinted token on the dark
admin shell. The close button stays subtle until you hover, then turns
white on a muted-rose hover state so removal feels intentional. */
.chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.55rem 0.25rem 0.65rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
color: rgb(238 224 255); /* near-white violet */
background: linear-gradient(135deg, rgb(167 139 250 / 0.18), rgb(236 72 153 / 0.14));
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.3);
transition: background 0.15s ease;
}
.chip:hover { background: linear-gradient(135deg, rgb(167 139 250 / 0.25), rgb(236 72 153 / 0.2)); }
.chip-x {
display: grid;
place-items: center;
width: 1.1rem;
height: 1.1rem;
margin-right: -0.15rem;
border-radius: 9999px;
color: rgb(255 255 255 / 0.55);
transition: color 0.12s ease, background 0.12s ease;
}
.chip-x:hover {
color: white;
background: rgb(244 63 94 / 0.35); /* rose hint on hover */
}
/* Preset suggestion buttons — sit below the input, soft dark surface. */
.preset-btn {
font-size: 0.72rem;
padding: 0.25rem 0.55rem;
border-radius: 0.5rem;
color: rgb(255 255 255 / 0.6);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s ease, color 0.15s ease;
}
.preset-btn:hover:not(:disabled) { background: rgb(255 255 255 / 0.08); color: white; }
.preset-btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
+314
View File
@@ -0,0 +1,314 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api, jsonBody } from '../api'
import { sortResolutions } from '../utils/format'
import Icon from './Icon.vue'
const props = defineProps({
model: { type: Object, required: true },
})
const emit = defineEmits(['close'])
const isVideo = props.model.type === 'video'
// Video capabilities — the authoritative source is the family preset
// (core/video_models.FAMILIES), not the user-edited managed-model record
// which may have stale max_reference_images / reference_mode values.
const familyPreset = ref(null)
onMounted(async () => {
if (!isVideo) return
const r = await api('/video-presets')
const list = r.data?.data || []
familyPreset.value = list.find((p) => p.key === props.model.id) || null
})
const ratios = (props.model.ratios && props.model.ratios.length)
? props.model.ratios
: (isVideo ? ['16x9'] : ['1:1'])
const resolutions = (props.model.resolutions && props.model.resolutions.length)
? sortResolutions(props.model.resolutions)
: (isVideo ? ['720p'] : ['2K'])
const durations = computed(() => {
// duration_prices keys come back alphabetically from Go ("10s" before "5s");
// sort by numeric seconds so the shortest is first.
const keys = Object.keys(props.model?.duration_prices || {})
.sort((a, b) => parseFloat(a) - parseFloat(b))
if (keys.length) return keys
if (familyPreset.value?.durations?.length) return familyPreset.value.durations
return ['5s', '10s']
})
// Reference image support — for video models, max_reference_images > 0 means
// frames can/must be uploaded. Kling 3 i2v actually requires >= 1.
const maxRefs = computed(() => {
const fromPreset = Number(familyPreset.value?.max_reference_images || 0)
const fromModel = Number(props.model?.max_reference_images || 0)
const m = Math.max(fromPreset, fromModel)
if (m > 0) return m
// Image models advertise image-to-image via a boolean (not a count) — allow one.
if (!isVideo && props.model?.image_to_image) return 1
return 0
})
const refMode = computed(() => familyPreset.value?.reference_mode || props.model?.reference_mode || 'none')
// Reference images are ALWAYS optional — every video model supports pure
// text2video; frame refs (首帧/末帧) only enhance the result when supplied.
const refsRequired = computed(() => false)
const refsLabel = computed(() => {
if (!isVideo) return maxRefs.value > 0 ? '参考图 (可选, 图生图)' : ''
if (refMode.value === 'asset') return `参考图 (asset 模式, 最多 ${maxRefs.value} 张)`
if (refMode.value === 'frame') {
if (maxRefs.value >= 2) return `首帧 / 末帧 (1=首帧, 2=首尾帧, 最多 ${maxRefs.value} 张)`
return `首帧 (可选, ${maxRefs.value} 张)`
}
return ''
})
const prompt = ref(isVideo
? 'A cinematic shot of a golden retriever running through a wheat field at sunset.'
: 'a cute cat sitting on a desk, studio lighting')
const ratio = ref(ratios[0])
const resolution = ref(resolutions[0])
const duration = ref(durations.value[0])
const refImages = ref([]) // [{ name, dataUrl }]
const fileInput = ref(null)
// Image 5 instruct-edit derives aspect from the reference image — hide the ratio
// picker when a ref is attached (backend omits aspectRatio to avoid a 400).
const showRatio = computed(() => !(props.model.id === 'firefly-image-5' && refImages.value.length > 0))
function openPicker() { fileInput.value && fileInput.value.click() }
function onFiles(ev) {
const files = Array.from(ev.target.files || [])
const room = Math.max(0, maxRefs.value - refImages.value.length)
const toAdd = files.slice(0, room)
for (const f of toAdd) {
const reader = new FileReader()
reader.onload = () => {
refImages.value.push({ name: f.name, dataUrl: reader.result })
}
reader.readAsDataURL(f)
}
if (ev.target) ev.target.value = ''
}
function removeRef(i) { refImages.value.splice(i, 1) }
const busy = ref(false)
const status = ref('')
const error = ref('')
const resultUrl = ref('')
const resultKind = ref('')
// Gateway-timeout recovery: the backend detaches the render from the request
// (context.WithoutCancel), so an EdgeOne 524 / proxy timeout does NOT kill it —
// it finishes and is logged as a source="admin" event. On such a timeout we keep
// the modal "生成中" and poll /jobs/mine?source=admin to recover the result.
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
let recoverTimer = null
let recoverJobId = ''
let recoverSubmitTs = 0
onUnmounted(() => clearTimeout(recoverTimer))
async function run() {
if (!prompt.value.trim()) { error.value = '请输入提示词'; return }
if (refsRequired.value && refImages.value.length < 1) {
error.value = '该视频模型需要至少 1 张参考图 (首帧)'
return
}
busy.value = true
error.value = ''
status.value = isVideo ? '正在生成视频 (约 1–3 分钟)…' : '正在生成…'
resultUrl.value = ''
resultKind.value = ''
const payload = {
model: props.model.id,
prompt: prompt.value,
ratio: ratio.value,
resolution: resolution.value,
}
if (isVideo) {
payload.duration = duration.value
}
if (refImages.value.length) {
// Backend accepts raw base64 only — strip the "data:...;base64," prefix.
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
}
recoverSubmitTs = Date.now()
const r = await api('/test', jsonBody('POST', payload))
if (r.ok && r.data?.url) {
busy.value = false
resultUrl.value = r.data.url
resultKind.value = r.data.kind || (isVideo ? 'video' : 'image')
status.value = `完成 · ${r.data.provider} · ${r.data.elapsed_ms}ms`
} else if (GATEWAY_TIMEOUT.has(r.status)) {
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。保持锁住,轮询恢复结果。
status.value = isVideo ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
recoverJobId = ''
clearTimeout(recoverTimer)
recoverTimer = setTimeout(recover, 3000)
} else {
busy.value = false
status.value = ''
error.value = r.data?.detail || `失败 (${r.status})`
}
}
// Poll the admin's own in-flight/just-finished test job after a gateway timeout.
async function recover() {
const r = await api('/jobs/mine?source=admin')
if (!r.ok) { recoverTimer = setTimeout(recover, 3000); return }
const { pending, latest } = r.data || {}
if (pending) {
// Still rendering server-side — remember its id and keep waiting.
recoverJobId = pending.id
recoverTimer = setTimeout(recover, 3000)
return
}
// No pending: did OUR job finish? Match by the id we saw, or (if it completed
// before our first poll) by a latest that started at/after our submit.
const mine = latest && (
(recoverJobId && latest.id === recoverJobId) ||
(!recoverJobId && latest.ts && latest.ts * 1000 >= recoverSubmitTs - 2000)
)
if (mine && latest.status === 'success' && latest.url) {
busy.value = false
resultUrl.value = latest.url
resultKind.value = latest.kind || (isVideo ? 'video' : 'image')
status.value = `完成 · ${(latest.elapsed_ms / 1000).toFixed(1)}s`
return
}
if (mine && latest.status === 'failed') {
busy.value = false
status.value = ''
error.value = latest.error || '生成失败'
return
}
// Not resolved yet (event still committing) — keep polling.
recoverTimer = setTimeout(recover, 3000)
}
</script>
<template>
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
@click.self="emit('close')">
<div class="card !shadow-2xl my-12 w-full max-w-lg">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<div class="min-w-0">
<h2 class="text-sm font-semibold">测试模型</h2>
<div class="text-xs text-white/45 font-mono truncate">{{ model.id }}</div>
</div>
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
<Icon name="close" class="w-5 h-5" />
</button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="lbl">提示词</label>
<textarea v-model="prompt" rows="3" class="field resize-none" placeholder="输入测试提示词…"></textarea>
</div>
<!-- Param controls — same pill-button row as the public 画图 page.
Show the row whenever there's at least one option (even a single
one) so the chosen value is visible, not silently hidden. -->
<div v-if="ratios.length > 0 && showRatio">
<label class="lbl">比例</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="r in ratios" :key="r" type="button" @click="ratio = r"
class="opt" :class="ratio === r && 'opt-on'">{{ r }}</button>
</div>
</div>
<div v-if="resolutions.length > 0">
<label class="lbl">{{ isVideo ? '分辨率' : '画质' }}</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="r in resolutions" :key="r" type="button" @click="resolution = r"
class="opt" :class="resolution === r && 'opt-on'">{{ r }}</button>
</div>
</div>
<div v-if="isVideo && durations.length > 0">
<label class="lbl">时长</label>
<div class="flex flex-wrap gap-1.5">
<button v-for="d in durations" :key="d" type="button" @click="duration = d"
class="opt" :class="duration === d && 'opt-on'">{{ d }}</button>
</div>
</div>
<!-- reference images -->
<div v-if="maxRefs > 0">
<label class="lbl">
{{ refsLabel }}
<span v-if="refsRequired" class="text-rose-300">*</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 ring-1 ring-white/10 bg-white/[0.04]">
<img :src="img.dataUrl" class="w-full h-full object-cover" />
<button type="button" @click="removeRef(i)"
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white hover:bg-rose-500 transition-colors grid place-items-center">
<Icon name="close" class="w-3 h-3" />
</button>
<div v-if="refMode === 'frame' && maxRefs >= 2"
class="absolute bottom-0 inset-x-0 text-[10px] text-white bg-black/60 text-center py-0.5">
{{ i === 0 ? '首帧' : (i === 1 ? '末帧' : '') }}
</div>
</div>
<button v-if="refImages.length < maxRefs" type="button" @click="openPicker"
class="w-20 h-20 rounded-lg border-2 border-dashed border-white/15 text-white/40 hover:bg-white/[0.04] hover:border-white/30 transition-colors grid place-items-center">
<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" class="btn-primary w-full">
<Icon name="spark" class="w-4 h-4" /> {{ busy ? (isVideo ? '生成中…(请耐心等待)' : '生成中…') : '生成' }}
</button>
<p v-if="status" class="text-xs text-white/55">{{ status }}</p>
<p v-if="error" class="text-xs text-rose-300 break-all">{{ error }}</p>
<div v-if="resultUrl" class="rounded-xl ring-1 ring-white/10 bg-white/[0.03] overflow-hidden grid place-items-center min-h-[220px]">
<video v-if="resultKind === 'video'" :src="resultUrl" controls autoplay
class="max-w-full max-h-[420px] object-contain" />
<img v-else :src="resultUrl" class="max-w-full max-h-[360px] object-contain" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.lbl {
display: block;
font-size: 0.72rem;
font-weight: 500;
color: rgb(255 255 255 / 0.55);
margin-bottom: 0.4rem;
}
/* Pill option button — exactly the same shape as the public 画图 page's
ratio/resolution/duration row. Replaces the native <select> drop-down. */
.opt {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.5rem;
color: rgb(255 255 255 / 0.65);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.opt:hover { background: rgb(255 255 255 / 0.08); color: white; }
.opt-on {
background: rgb(255 255 255 / 0.92);
color: rgb(15 23 42);
box-shadow: none;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
// Credit display helpers. Credits and model prices are stored server-side as
// integer 积分 (points) — the single unit across the whole app. These helpers
// only format for display; they do NOT convert units.
/** Round to an integer 积分 value. */
export function points(value) {
return Math.round(Number(value || 0))
}
/** "<n> 积分" label with thousands separators. */
export function pointsLabel(value) {
return points(value).toLocaleString('en-US') + ' 积分'
}
+116
View File
@@ -0,0 +1,116 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '../components/Icon.vue'
import Logo from '../components/Logo.vue'
import { site } from '../site'
import { isDark, toggleTheme } from '../theme'
const route = useRoute()
const tabs = [
{ label: '概览', to: '/admin/overview', icon: 'overview' },
{ label: '模型管理', to: '/admin/models', icon: 'models' },
{ label: '账号管理', to: '/admin/accounts', icon: 'plug' },
{ label: '用户管理', to: '/admin/users', icon: 'accounts' },
{ label: '兑换码', to: '/admin/cdks', icon: 'spark' },
{ label: '邀请日志', to: '/admin/invites', icon: 'accounts' },
{ label: '图片管理', to: '/admin/images', icon: 'files' },
{ label: '首页内容', to: '/admin/showcase', icon: 'spark' },
{ label: '日志', to: '/admin/logs', icon: 'log' },
{ label: '配置', to: '/admin/config', icon: 'config' },
]
const currentLabel = computed(() => route.meta?.label || '')
</script>
<template>
<div class="theme-x h-screen flex bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-violet-400/30 overflow-hidden">
<!-- ===== Sidebar ===== -->
<aside class="w-60 shrink-0 border-r border-[color:var(--hairline)] bg-[var(--surface)] backdrop-blur-md flex flex-col">
<router-link to="/" class="h-16 flex items-center gap-2.5 px-5 border-b border-[color:var(--hairline)] group">
<Logo :size="32" class="rounded-[10px] shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
<div class="leading-tight min-w-0">
<div class="text-sm font-semibold truncate tracking-tight text-[color:var(--fg)]">{{ site.title }}</div>
<div class="text-[11px] text-[color:var(--fg-3)] truncate">Admin</div>
</div>
</router-link>
<nav class="flex-1 px-3 py-4 space-y-1">
<router-link
v-for="t in tabs" :key="t.to" :to="t.to"
class="admin-link group"
active-class="active">
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full opacity-0 transition-opacity"
style="background: linear-gradient(180deg, #f0abfc, #a78bfa)"></span>
<Icon :name="t.icon" class="w-4 h-4 shrink-0 opacity-70 group-hover:opacity-100 transition-opacity" />
<span class="text-sm">{{ t.label }}</span>
</router-link>
</nav>
<div class="p-3 border-t border-[color:var(--hairline)] space-y-1">
<button type="button" @click="toggleTheme"
class="w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs text-[color:var(--fg-2)] hover:bg-[var(--hover)] hover:text-[color:var(--fg)] transition-colors">
<svg v-if="isDark" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
{{ isDark ? '亮色模式' : '暗色模式' }}
</button>
<router-link to="/user"
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs text-[color:var(--fg-2)] hover:bg-[var(--hover)] hover:text-[color:var(--fg)] transition-colors">
<Icon name="spark" class="w-3.5 h-3.5" /> 用户端
<Icon name="open" class="w-3 h-3 ml-auto" />
</router-link>
</div>
</aside>
<!-- ===== Main ===== -->
<div class="flex-1 min-w-0 flex flex-col relative">
<!-- soft background mesh, mirrors the public shell -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="absolute -top-32 left-1/3 w-[40rem] h-[40rem] rounded-full opacity-[0.12]"
style="background: radial-gradient(circle, #a855f7, transparent 60%); filter: blur(110px)"></div>
<div class="absolute top-1/2 -right-40 w-[36rem] h-[36rem] rounded-full opacity-[0.10]"
style="background: radial-gradient(circle, #06b6d4, transparent 60%); filter: blur(110px)"></div>
<div class="absolute bottom-0 left-0 w-[32rem] h-[32rem] rounded-full opacity-[0.08]"
style="background: radial-gradient(circle, #f43f5e, transparent 60%); filter: blur(110px)"></div>
</div>
<header class="relative z-10 h-14 shrink-0 border-b border-[color:var(--hairline)] bg-[var(--app-bg)]/70 backdrop-blur-md flex items-center px-8">
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)] font-medium mr-3">Admin</div>
<div class="text-[color:var(--fg-faint)] mr-3">/</div>
<h1 class="text-sm font-semibold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</h1>
</header>
<main :class="['theme-text flex-1 overflow-y-auto overscroll-y-none relative z-10', { 'public-dark': isDark }]">
<div class="px-8 py-7">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</main>
</div>
</div>
</template>
<style scoped>
.admin-link {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 0.875rem;
border-radius: 0.625rem;
color: var(--fg-2);
font-weight: 500;
transition: background 0.15s ease, color 0.15s ease;
}
.admin-link:hover { background: var(--hover); color: var(--fg); }
.admin-link.active { color: var(--fg); background: var(--hover); }
.admin-link.active > span:first-child { opacity: 1; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
.fade-enter-from { opacity: 0; transform: translateY(4px); }
.fade-leave-to { opacity: 0; }
</style>
+208
View File
@@ -0,0 +1,208 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { auth, isAuthed, isAdmin, openLogin } from '../auth'
import { isDark, toggleTheme } from '../theme'
import { site } from '../site'
import Icon from '../components/Icon.vue'
import Logo from '../components/Logo.vue'
import { pointsLabel } from '../credits'
import { draft } from '../playground'
const route = useRoute()
// 画图/记录 only show once signed in; clicking 设置 while logged out opens login.
const nav = computed(() => {
const items = [{ to: '/', label: '首页', icon: 'overview' }]
if (isAuthed()) {
items.push({ to: '/user', label: '画图', icon: 'spark' })
items.push({ to: '/logs', label: '记录', icon: 'log' })
items.push({ to: '/mylogs', label: '日志', icon: 'files' })
items.push({ to: '/invite', label: '邀请', icon: 'accounts' })
}
// 文档 + 关于 are public — visible to guests too.
items.push({ to: '/docs', label: '文档', icon: 'log' })
items.push({ to: '/about', label: '关于', icon: 'accounts' })
return items
})
function onSettings(e) {
if (!isAuthed()) { e.preventDefault(); openLogin('/settings') }
}
// credits — the logged-in user's real server-side balance (auth.user.credits)
const credits = computed(() => Number(auth.user?.credits || 0))
const showBalance = computed(() => route.path === '/user')
const creditsLabel = computed(() => pointsLabel(credits.value))
// On the 画图 workbench the header label tracks the active mode — it flips
// with the 生图/生视频 tab, and on state restore (回显) reflects whatever a
// pending job is generating (video → 生视频). draft.mode is kept in sync by
// PlaygroundView for both cases.
const currentLabel = computed(() => {
if (route.path === '/user') return draft.mode === 'video' ? '生视频' : '生图'
return route.meta?.label || ''
})
</script>
<template>
<div class="theme-x min-h-screen flex bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-violet-400/30">
<!-- ===== Left rail ===== -->
<aside class="fixed inset-y-0 left-0 z-30 w-16 md:w-20 flex flex-col items-center py-5 border-r border-[color:var(--hairline)]">
<!-- Logo -->
<router-link to="/" class="mb-8 group transition-transform hover:scale-105">
<Logo :size="40" class="rounded-xl shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
</router-link>
<!-- Nav -->
<nav class="flex flex-col gap-1.5 flex-1">
<router-link
v-for="n in nav" :key="n.to" :to="n.to"
:exact-active-class="n.to === '/' ? 'active' : ''"
:active-class="n.to === '/' ? '' : 'active'"
class="rail-link group">
<span class="w-10 h-10 rounded-xl grid place-items-center transition-all ring-1 ring-transparent group-hover:bg-[var(--hover)] group-hover:ring-[color:var(--hairline)]">
<Icon :name="n.icon" class="w-4 h-4 transition-colors" />
</span>
<span class="rail-label">{{ n.label }}</span>
</router-link>
</nav>
<!-- Bottom: admin shortcut (admins only) + settings -->
<div class="flex flex-col items-center gap-3">
<router-link v-if="isAdmin()" to="/admin/overview" title="进入管理后台"
class="rail-bottom">
<Icon name="shield" class="w-4 h-4" />
</router-link>
<!-- Light/dark toggle — sits right above 设置. Sun when dark (click→light),
moon when light (click→dark). -->
<button type="button" @click="toggleTheme"
:title="isDark ? '切换到亮色' : '切换到暗色'" class="rail-bottom">
<svg v-if="isDark" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
<router-link to="/settings" title="设置" @click="onSettings"
:class="$route.path === '/settings' ? 'rail-bottom active' : 'rail-bottom'">
<Icon name="config" class="w-4 h-4" />
</router-link>
</div>
</aside>
<!-- ===== Main column ===== -->
<div class="flex-1 min-w-0 ml-16 md:ml-20 relative">
<!-- soft background mesh -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="absolute -top-32 left-1/3 w-[40rem] h-[40rem] rounded-full opacity-[0.16]"
style="background: radial-gradient(circle, #a855f7, transparent 60%); filter: blur(100px)"></div>
<div class="absolute top-1/2 -right-40 w-[36rem] h-[36rem] rounded-full opacity-[0.14]"
style="background: radial-gradient(circle, #06b6d4, transparent 60%); filter: blur(100px)"></div>
<div class="absolute bottom-0 left-0 w-[32rem] h-[32rem] rounded-full opacity-[0.10]"
style="background: radial-gradient(circle, #f43f5e, transparent 60%); filter: blur(100px)"></div>
</div>
<!-- Page header — shows the Vivid wordmark on home, route label
elsewhere. Same vertical position across routes so the brand
stamp doesn't jump around. -->
<header class="relative z-10 px-8 md:px-14 pt-10 pb-4 flex items-center justify-between gap-4">
<div class="flex items-baseline gap-2">
<span class="text-[22px] font-bold tracking-tight bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent">
{{ site.title }}
</span>
<span class="text-[10px] uppercase tracking-[0.3em] text-[color:var(--fg-faint)]">{{ route.path === '/' ? 'AI 生图 · 生视频' : currentLabel }}</span>
</div>
<router-link v-if="showBalance" to="/settings"
class="text-xs text-[color:var(--fg-2)] hover:text-[color:var(--fg)] tabular-nums transition-colors">
余额 <span class="text-[color:var(--fg)] font-semibold">{{ creditsLabel }}</span>
<span class="text-[color:var(--fg-faint)] ml-1">· 充值</span>
</router-link>
</header>
<main :class="['relative z-10 px-8 md:px-14 pb-24 pt-2', { 'public-dark': isDark }]">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</div>
</template>
<style scoped>
.rail-link {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
color: var(--fg-3);
transition: color 0.15s ease;
}
.rail-link:hover { color: var(--fg); }
.rail-link.active { color: var(--fg); }
.rail-link.active::before {
content: '';
position: absolute;
left: -1rem;
/* Anchor to the icon box, not the whole link (which includes the label
underneath). rail-link has padding-top 0.25rem and the icon span is 2.5rem
tall — so the icon center sits at 0.25rem + 1.25rem = 1.5rem from top. */
top: 1.5rem;
transform: translateY(-50%);
width: 3px;
height: 24px;
border-radius: 4px;
background: linear-gradient(180deg, #f0abfc, #a78bfa);
}
.rail-link.active > span:first-child {
background: var(--hover) !important;
--tw-ring-color: var(--hairline);
}
.rail-label {
font-size: 10px;
letter-spacing: 0.04em;
font-weight: 500;
}
.rail-bottom {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
display: grid;
place-items: center;
color: var(--fg-3);
--tw-ring-color: transparent;
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.rail-bottom:hover {
color: var(--fg);
background: var(--hover);
box-shadow: inset 0 0 0 1px var(--hairline);
}
.rail-bottom.active {
color: var(--fg);
background: var(--hover);
box-shadow: inset 0 0 0 1px var(--hairline);
}
/* Admin shortcut — only rendered for admins. Tinted so it doesn't get lost
next to the plain settings cog right below it. */
.admin-shortcut {
color: rgb(196 181 253); /* violet-300 */
background: linear-gradient(135deg, rgb(167 139 250 / 0.12), rgb(236 72 153 / 0.10));
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.25);
}
.admin-shortcut:hover {
color: white;
background: linear-gradient(135deg, rgb(167 139 250 / 0.22), rgb(236 72 153 / 0.18));
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.45);
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.fade-enter-from { opacity: 0; transform: translateY(8px); }
.fade-leave-to { opacity: 0; }
</style>
+119
View File
@@ -0,0 +1,119 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import './style.css'
import { auth, refreshMe, openLogin } from './auth'
import { site, loadSite } from './site'
import PublicLayout from './layouts/PublicLayout.vue'
import AdminLayout from './layouts/AdminLayout.vue'
import HomeView from './views/HomeView.vue'
import PlaygroundView from './views/PlaygroundView.vue'
import UserLogsView from './views/UserLogsView.vue'
import UserLogsTableView from './views/UserLogsTableView.vue'
import SettingsView from './views/SettingsView.vue'
import InviteView from './views/InviteView.vue'
import DocsView from './views/DocsView.vue'
import AboutView from './views/AboutView.vue'
import OverviewView from './views/OverviewView.vue'
import ModelsView from './views/ModelsView.vue'
import AccountsView from './views/AccountsView.vue'
import UsersView from './views/UsersView.vue'
import CdksView from './views/CdksView.vue'
import InvitesAdminView from './views/InvitesAdminView.vue'
import ImagesView from './views/ImagesView.vue'
import LogsView from './views/LogsView.vue'
import ConfigView from './views/ConfigView.vue'
import ShowcaseView from './views/ShowcaseView.vue'
const routes = [
{
path: '/',
component: PublicLayout,
children: [
{ path: '', component: HomeView, meta: { label: '首页' } },
{ path: 'user', component: PlaygroundView, meta: { label: '画图' } },
{ path: 'logs', component: UserLogsView, meta: { label: '记录' } },
{ path: 'mylogs', component: UserLogsTableView, meta: { label: '日志' } },
{ path: 'invite', component: InviteView, meta: { label: '邀请' } },
{ path: 'docs', component: DocsView, meta: { label: '文档' } },
{ path: 'about', component: AboutView, meta: { label: '关于' } },
{ path: 'settings', component: SettingsView, meta: { label: '设置' } },
],
},
{
path: '/admin',
component: AdminLayout,
children: [
{ path: '', redirect: '/admin/overview' },
{ path: 'overview', component: OverviewView, meta: { label: '概览' } },
{ path: 'models', component: ModelsView, meta: { label: '模型管理' } },
{ path: 'accounts', component: AccountsView, meta: { label: '账号管理' } },
{ path: 'users', component: UsersView, meta: { label: '用户管理' } },
{ path: 'cdks', component: CdksView, meta: { label: '兑换码' } },
{ path: 'invites', component: InvitesAdminView, meta: { label: '邀请日志' } },
{ path: 'images', component: ImagesView, meta: { label: '图片管理' } },
{ path: 'showcase', component: ShowcaseView, meta: { label: '首页内容' } },
{ path: 'logs', component: LogsView, meta: { label: '日志' } },
{ path: 'config', component: ConfigView, meta: { label: '配置' } },
],
},
// legacy redirects
{ path: '/playground', redirect: '/user' },
{ path: '/home', redirect: '/' },
{ path: '/overview', redirect: '/admin/overview' },
{ path: '/models', redirect: '/admin/models' },
{ path: '/video-models', redirect: '/admin/models' },
{ path: '/accounts', redirect: '/admin/accounts' },
// NOTE: /images is the generated-artifact path (served by the backend), so the
// old "/images → /admin/images" shortcut is gone. Use /files for that shortcut.
{ path: '/files', redirect: '/admin/images' },
{ path: '/config', redirect: '/admin/config' },
{ path: '/refresh', redirect: '/admin/overview' },
{ path: '/test', redirect: '/user' },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// Pages that require a login. The home page (/) stays public; everything a
// signed-in user touches (画图/记录/设置) and the whole admin area is gated.
const PROTECTED = ['/user', '/logs', '/invite', '/settings']
function isProtected(path) {
return path.startsWith('/admin') || PROTECTED.includes(path)
}
// Guard: validate the stored token against /me once (auth.ready), then trust
// state. Unauthed visits to a protected page stay on home and pop the login
// modal (no separate login page); the modal navigates to `intent` on success.
router.beforeEach(async (to) => {
if (!isProtected(to.path)) return true
if (!auth.ready) await refreshMe()
if (!auth.token || !auth.user) {
openLogin(to.fullPath)
return to.path === '/' ? false : '/'
}
if (to.path.startsWith('/admin') && auth.user.role !== 'admin') {
return '/user' // logged in but not an admin -> user side
}
return true
})
// Keep the browser tab title in sync with the current route's label and the
// admin-editable site title. Admin routes get an extra prefix so the two
// sides are distinguishable at a glance.
function applyTitle(route) {
const label = route.meta?.label || ''
const scope = route.path.startsWith('/admin') ? 'Admin · ' : ''
const brand = site.title || 'Vivid'
document.title = label ? `${brand} • ${scope}${label}` : brand
}
router.afterEach(applyTitle)
// Re-apply once the admin-set title resolves (loadSite is async — the first
// navigation uses the default, then this catches up).
loadSite().then(() => applyTitle(router.currentRoute.value))
createApp(App).use(router).mount('#app')
+26
View File
@@ -0,0 +1,26 @@
// Reactive draft of the 画图 form fields. Lives at module scope so the
// values survive PlaygroundView being unmounted (navigation to 首页/记录 etc.)
// and remounted — without this, switching away and back wiped the prompt
// and selected model. Per-tab only; not persisted to localStorage.
import { reactive } from 'vue'
export const draft = reactive({
mode: '', // 'image' | 'video'
modelId: '',
prompt: '',
ratio: '',
resolution: '',
duration: '',
})
// Copy fields from a server-side job entry (the `/jobs/mine` payload) into
// the draft so a parallel tab can pick up exactly what's being generated.
export function applyJobToDraft(entry) {
if (!entry) return
draft.mode = entry.kind === 'video' ? 'video' : 'image'
draft.modelId = entry.model || ''
draft.prompt = entry.prompt || ''
draft.ratio = entry.ratio || ''
draft.resolution = entry.resolution || ''
draft.duration = entry.duration || ''
}
+35
View File
@@ -0,0 +1,35 @@
// Site-wide branding (currently just the wordmark / tab title) backed by the
// admin-editable config at /admin/api/site. One reactive object so every
// component that wants to display "<title>" stays in sync the moment the
// admin saves a change.
import { reactive } from 'vue'
const BASE = import.meta.env.VITE_API_BASE || ''
export const site = reactive({
title: 'Vivid',
// Defaults so the 关于 page is never blank even if /site hasn't loaded (or a
// cache serves an older payload without `contact`). The backend value, once
// fetched, overrides these.
contact: {
qq: '1114639355',
qq_link: 'https://qm.qq.com/q/ItgCcNA7ac',
qq_group: '1106849765',
qq_group_link: 'https://qm.qq.com/q/976LeMFoHu',
email: 'vividairun@gmail.com',
shop: 'https://pay.ldxp.cn/shop/chiyi',
},
ready: false,
})
export async function loadSite() {
try {
const r = await fetch(`${BASE}/admin/api/site`)
if (r.ok) {
const data = await r.json()
if (data.title) site.title = String(data.title)
if (data.contact) site.contact = { ...site.contact, ...data.contact }
}
} catch { /* offline — keep the default. */ }
site.ready = true
}
+416
View File
@@ -0,0 +1,416 @@
@import "tailwindcss";
:root {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
--tw-ring-color: rgb(15 23 42 / 0.08);
/* ===== Theme palette — LIGHT (default) =====
Drives the app chrome (rails/header) and the public marketing pages.
The `.dark` class on <html> flips these to the dark values below.
Text over generated images keeps a literal text-white (handled in markup). */
--app-bg: rgb(250 250 251); /* page background */
--fg: rgb(15 23 42); /* primary heading/text */
--fg-2: rgb(15 23 42 / 0.62); /* body / secondary */
--fg-3: rgb(15 23 42 / 0.45); /* muted */
--fg-faint: rgb(15 23 42 / 0.3); /* labels / hints */
--surface: rgb(255 255 255 / 0.7); /* raised card on page bg */
--surface-2: rgb(15 23 42 / 0.035); /* subtle inset panel (counter cells) */
--hairline: rgb(15 23 42 / 0.08); /* borders / rings */
--hover: rgb(15 23 42 / 0.05); /* hover wash */
/* solid CTA button that sits on the page bg — inverts per theme */
--btn-solid-bg: rgb(15 23 42);
--btn-solid-bg-h: rgb(30 41 59);
--btn-solid-fg: #fff;
--menu-bg: #ffffff; /* dropdown / popover panel */
}
html.dark {
--app-bg: #08090d;
--fg: rgb(255 255 255 / 0.92);
--fg-2: rgb(255 255 255 / 0.55);
--fg-3: rgb(255 255 255 / 0.4);
--fg-faint: rgb(255 255 255 / 0.3);
--surface: rgb(255 255 255 / 0.03);
--surface-2: rgb(255 255 255 / 0.04);
--hairline: rgb(255 255 255 / 0.06);
--hover: rgb(255 255 255 / 0.06);
--btn-solid-bg: #fff;
--btn-solid-bg-h: rgb(255 255 255 / 0.9);
--btn-solid-fg: rgb(2 6 23);
--menu-bg: #15171f;
}
/* Smooth the flip so toggling doesn't hard-cut. */
body, .theme-x { transition: background-color 0.25s ease, color 0.25s ease; }
/* Footer CTA band — soft tinted panel that flips with the theme. */
.cta-band {
background:
radial-gradient(at 30% 20%, rgb(168 85 247 / 0.16) 0%, transparent 50%),
radial-gradient(at 80% 70%, rgb(244 114 182 / 0.14) 0%, transparent 55%),
linear-gradient(180deg, rgb(255 255 255), rgb(246 244 251));
}
html.dark .cta-band {
background:
radial-gradient(at 30% 20%, rgb(168 85 247 / 0.35) 0%, transparent 50%),
radial-gradient(at 80% 70%, rgb(244 114 182 / 0.3) 0%, transparent 55%),
linear-gradient(180deg, #0f1117, #080a10);
}
/* ===== Light-mode rescue for hardcoded-dark pages =====
The admin shell + settings/workbench views were authored dark with literal
text-white / white-alpha / bg-white utilities (not the slate system). In
LIGHT mode, remap those neutral whites to the dark-on-light palette. Scoped
to a `.theme-text` wrapper so the marketing homepage's over-image whites are
never touched. Un-layered, so these win over Tailwind's @layer utilities. */
html:not(.dark) .theme-text :is(.text-white, [class*="text-white/9"], [class*="text-white/8"]) { color: rgb(15 23 42 / 0.95); }
html:not(.dark) .theme-text :is([class*="text-white/7"], [class*="text-white/6"]) { color: rgb(15 23 42 / 0.7); }
html:not(.dark) .theme-text :is([class*="text-white/5"], [class*="text-white/4"]) { color: rgb(15 23 42 / 0.52); }
html:not(.dark) .theme-text :is([class*="text-white/3"], [class*="text-white/2"], [class*="text-white/1"]) { color: rgb(15 23 42 / 0.4); }
html:not(.dark) .theme-text [class*="bg-white/"] { background-color: var(--surface-2); }
html:not(.dark) .theme-text [class*="border-white/"] { border-color: var(--hairline); }
html:not(.dark) .theme-text [class*="ring-white/"] { --tw-ring-color: var(--hairline); }
/* White placeholders (placeholder:text-white/30 etc.) — the rescue above only
recolors element text, not the ::placeholder pseudo. */
html:not(.dark) .theme-text [class*="placeholder:text-white"]::placeholder { color: var(--fg-faint); }
/* Exception: "selected" pills / dark buttons keep their white text. They paint a
genuinely dark surface (bg-slate-900/800, bg-black) so the rescue must NOT
flip their text to dark — that's where it goes invisible. */
html:not(.dark) .theme-text :is([class*="bg-slate-900"], [class*="bg-slate-800"], [class*="bg-black"])[class*="text-white"] { color: #fff !important; }
/* Pastel -200/-300 accent text (labels, KPI numbers, badges) is tuned for a dark
shell and washes out on white. In LIGHT mode, darken to the -600 tone. The
matcher also catches the /80 opacity variants. Badges keep their pale tinted
bg (bg-*-500/10) which reads fine on white. */
html:not(.dark) .theme-text :is([class*="text-indigo-300"], [class*="text-indigo-200"]) { color: rgb(79 70 229); }
html:not(.dark) .theme-text :is([class*="text-violet-300"], [class*="text-violet-200"]) { color: rgb(124 58 237); }
html:not(.dark) .theme-text :is([class*="text-fuchsia-300"], [class*="text-fuchsia-200"]) { color: rgb(192 38 211); }
html:not(.dark) .theme-text :is([class*="text-emerald-300"], [class*="text-emerald-200"]) { color: rgb(5 150 105); }
html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(2 132 199); }
html:not(.dark) .theme-text :is([class*="text-amber-300"], [class*="text-amber-200"]) { color: rgb(217 119 6); }
html:not(.dark) .theme-text :is([class*="text-rose-300"], [class*="text-rose-200"]) { color: rgb(225 29 72); }
html:not(.dark) .theme-text :is([class*="text-teal-300"], [class*="text-teal-200"]) { color: rgb(13 148 136); }
/* Per-view scoped pills / buttons / chips hardcode white for the dark shell.
Recolor them centrally in light mode — the extra .theme-text class outranks
the scoped `.x[data-v-*]` rules. Dark mode keeps the scoped look (gated on
:not(.dark)). */
html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset-btn, .opt, .chip-x, .ro-chip) {
color: var(--fg-2); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--hairline);
}
html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset-btn, .opt, .chip-x):hover {
color: var(--fg); background: var(--hover);
}
html:not(.dark) .theme-text :is(.fp-on, .pg-on, .seg-on, .opt-on) { background: rgb(15 23 42); color: #fff; box-shadow: none; }
/* Selected COLORED filter pills — their scoped (dark) selected colors get
overridden by the neutral `.fp` rescue above, so re-state a light-mode
variant (tinted bg + dark-enough text + colored ring) with equal specificity
placed later, so the selection reads on a white page. */
html:not(.dark) .theme-text .fp-emerald { background: rgb(16 185 129 / 0.14); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.45); }
html:not(.dark) .theme-text .fp-rose { background: rgb(244 63 94 / 0.12); color: rgb(190 18 60); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.45); }
html:not(.dark) .theme-text .fp-fuchsia { background: rgb(217 70 239 / 0.12); color: rgb(162 28 175); box-shadow: inset 0 0 0 1px rgb(217 70 239 / 0.45); }
html:not(.dark) .theme-text .fp-amber { background: rgb(245 158 11 / 0.16); color: rgb(180 83 9); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.5); }
html:not(.dark) .theme-text .fp-violet { background: rgb(139 92 246 / 0.14); color: rgb(109 40 217); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.45); }
html:not(.dark) .theme-text .fp-sky { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.45); }
html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color: rgb(15 118 110); box-shadow: inset 0 0 0 1px rgb(20 184 166 / 0.45); }
/* Custom checkbox for admin tables (<input type="checkbox" class="chk">).
Replaces the plain accent-color box with a rounded, branded one + white check.
Neutral border + transparent bg so it reads on both the dark admin shell and
light mode; fills fuchsia when checked. */
.chk {
appearance: none;
-webkit-appearance: none;
width: 1rem;
height: 1rem;
flex: none;
border-radius: 0.3rem;
border: 1.5px solid rgb(148 163 184 / 0.55);
background: transparent;
cursor: pointer;
display: inline-grid;
place-content: center;
vertical-align: middle;
transition: background 0.15s, border-color 0.15s;
}
.chk:hover:not(:checked) { border-color: rgb(217 70 239 / 0.7); }
.chk:checked {
background: rgb(217 70 239);
border-color: rgb(217 70 239);
}
.chk:checked::after {
content: "";
width: 0.3rem;
height: 0.55rem;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
margin-top: -0.12rem;
}
.chk:focus-visible { outline: 2px solid rgb(217 70 239 / 0.45); outline-offset: 1px; }
/* Capability chips (ModelsView): all variants need bg + ring + color reset in light mode.
The scoped dark-shell values (white-alpha bg/ring, pale text) are invisible on white. */
html:not(.dark) .theme-text .cap-emerald {
background: rgb(16 185 129 / 0.12);
color: rgb(4 120 87);
box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.4);
}
html:not(.dark) .theme-text .cap-amber {
background: rgb(245 158 11 / 0.12);
color: rgb(180 83 9);
box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.45);
}
html:not(.dark) .theme-text :is(.cap-slate, .cap-mono, .seg) {
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
}
/* Price chips (ModelsView): the white-alpha bg/ring disappear on a white card. */
html:not(.dark) .theme-text .price-chip {
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
}
/* fp-white selected pill: on white bg the all-white fill vanishes — use a neutral dark variant. */
html:not(.dark) .theme-text .fp-white {
background: var(--surface-2);
color: var(--fg-2);
box-shadow: inset 0 0 0 1px var(--hairline);
}
/* label / hint text classes (form labels in modals & config) */
html:not(.dark) .theme-text .lbl { color: var(--fg); }
html:not(.dark) .theme-text :is(.hint, .flbl) { color: var(--fg-3); }
/* Delete icon button: a clean neutral button with a red glyph (not a heavy pink
box), filling solid red on hover. */
html:not(.dark) .theme-text .act.danger { color: rgb(225 29 72); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--hairline); }
html:not(.dark) .theme-text .act.danger:hover { color: #fff; background: rgb(244 63 94); box-shadow: inset 0 0 0 1px rgb(225 29 72); }
/* Soft "danger" toolbar buttons (删除选中 / 删除异常账号 …): a muted red, not a
heavy pink fill; solid red on hover. Outranks the per-view scoped pink. */
html:not(.dark) .theme-text .btn-soft.danger { color: rgb(190 18 60); background: rgb(244 63 94 / 0.08); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.22); }
html:not(.dark) .theme-text .btn-soft.danger:hover { color: #fff; background: rgb(225 29 72); box-shadow: none; }
/* ===== Media card =====
An image/video thumbnail whose overlay text sits ON the media — it must stay
white even when the surrounding page is in LIGHT mode. The page itself stays
light; only these cards keep the dark-on-image treatment. */
html:not(.dark) .media-card :is(.text-white, [class*="text-white/9"], [class*="text-white/8"], [class*="text-white/7"], [class*="text-white/6"]) { color: rgb(255 255 255 / 0.95) !important; }
html:not(.dark) .media-card :is([class*="text-white/5"], [class*="text-white/4"]) { color: rgb(255 255 255 / 0.6) !important; }
html:not(.dark) .media-card :is([class*="text-white/3"], [class*="text-white/2"]) { color: rgb(255 255 255 / 0.45) !important; }
html:not(.dark) .media-card [class*="bg-white/"] { background-color: rgb(255 255 255 / 0.12) !important; }
html:not(.dark) .media-card :is([class*="border-white/"], [class*="ring-white/"]) { border-color: rgb(255 255 255 / 0.12) !important; --tw-ring-color: rgb(255 255 255 / 0.12) !important; }
/* Accent text inside a media-card sits on dark imagery too — keep it at its
bright (dark-mode) shade instead of the darkened light-theme rescue above. */
html:not(.dark) .media-card :is([class*="text-emerald-3"], [class*="text-emerald-2"]) { color: rgb(110 231 183) !important; }
html:not(.dark) .media-card :is([class*="text-fuchsia-3"], [class*="text-fuchsia-2"]) { color: rgb(240 171 252) !important; }
html:not(.dark) .media-card :is([class*="text-indigo-3"], [class*="text-indigo-2"]) { color: rgb(165 180 252) !important; }
html:not(.dark) .media-card :is([class*="text-violet-3"], [class*="text-violet-2"]) { color: rgb(196 181 253) !important; }
html:not(.dark) .media-card :is([class*="text-sky-3"], [class*="text-sky-2"]) { color: rgb(125 211 252) !important; }
html:not(.dark) .media-card :is([class*="text-amber-3"], [class*="text-amber-2"]) { color: rgb(252 211 77) !important; }
/* Code example blocks (docs) read like an editor — solid dark surface in BOTH
themes. Paired with .media-card so the code/title text stays light in light
mode instead of being darkened by the rescue above. */
.doc-code { background-color: #0f172a; }
/* `.public-dark code` gives the inner <code> its faint highlight in dark mode;
doc-code is dark in BOTH themes, so re-apply the same in light mode. */
html:not(.dark) .doc-code code { background: rgb(255 255 255 / 0.08); color: rgb(255 255 255 / 0.9); }
html {
scroll-behavior: smooth;
/* Kill the bounce/glow when scrolling past the top or bottom of any
scroll surface (the dark admin shell has fixed viewport height and we
don't want a rubber-band-y feel at the edges of inner scroll areas). */
overscroll-behavior: none;
}
body {
margin: 0;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overscroll-behavior: none;
/* Subtle ambient gradient + grain (used by PublicLayout via .ambient-bg) */
}
::selection { background: rgb(99 102 241 / 0.22); color: rgb(15 23 42); }
/* Tailwind v4 / preflight leaves <button> on the default arrow cursor, so
admins miss the affordance on icon-only actions (复制 / 删除 / 生成 等).
One global rule restores the pointer cursor everywhere, while disabled
buttons keep the "not-allowed" cursor already applied via Tailwind. */
button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: pointer; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgb(203 213 225); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgb(148 163 184); }
::-webkit-scrollbar-track { background: transparent; }
/* ===== Ambient background — used by the public shell ===== */
.ambient-bg {
background:
radial-gradient(at 15% 10%, rgb(238 242 255) 0%, transparent 45%),
radial-gradient(at 85% 25%, rgb(253 244 255) 0%, transparent 50%),
radial-gradient(at 50% 90%, rgb(236 254 255) 0%, transparent 50%),
rgb(250 250 251);
}
.ambient-grain::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22180%22 height=%22180%22><filter id=%22n%22><feTurbulence baseFrequency=%220.9%22 numOctaves=%222%22 seed=%225%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.4%22/></svg>');
opacity: 0.035;
mix-blend-mode: multiply;
z-index: 1;
}
/* Float a soft mesh "orb" anywhere */
.mesh-orb {
position: absolute;
border-radius: 9999px;
filter: blur(80px);
pointer-events: none;
z-index: 0;
}
/* ===== Dark public-shell theme overrides =====
Apply by adding `.public-dark` to a wrapper element. Views can keep their
existing Tailwind classes (text-slate-*, .card, .field) — these rules
remap them to the dark palette. */
.public-dark { color: rgb(255 255 255 / 0.9); }
.public-dark .card {
background: rgb(255 255 255 / 0.03);
border-color: rgb(255 255 255 / 0.07);
color: rgb(255 255 255 / 0.92);
box-shadow: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 8px 24px rgb(0 0 0 / 0.25);
}
.public-dark .field {
background: rgb(255 255 255 / 0.04);
border-color: rgb(255 255 255 / 0.1);
color: rgb(255 255 255 / 0.95);
transition: border-color 0.18s, background 0.18s, box-shadow 0.18s;
}
.public-dark .field::placeholder { color: rgb(255 255 255 / 0.3); }
/* Single focus treatment shared by every <.field> across the admin shell —
matches the per-component overrides in ConfigView/LoginModal so all
inputs glow with the same violet ring on focus. */
.public-dark .field:focus,
.public-dark .field:focus-visible {
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);
outline: none;
}
.public-dark .btn-soft {
background: rgb(255 255 255 / 0.06);
color: rgb(255 255 255 / 0.85);
}
.public-dark .btn-soft:hover { background: rgb(255 255 255 / 0.12); color: white; }
.public-dark .btn-ghost { color: rgb(255 255 255 / 0.65); }
.public-dark .btn-ghost:hover { background: rgb(255 255 255 / 0.06); color: white; }
.public-dark .btn-primary {
background: white; color: rgb(2 6 23); box-shadow: 0 1px 0 rgb(0 0 0 / 0.1);
}
.public-dark .btn-primary:hover { background: rgb(255 255 255 / 0.92); }
.public-dark .pill { background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.85); }
/* Remap slate text tokens */
.public-dark .text-slate-900,
.public-dark .text-slate-800,
.public-dark .text-slate-700 { color: rgb(255 255 255 / 0.95); }
.public-dark .text-slate-600 { color: rgb(255 255 255 / 0.75); }
.public-dark .text-slate-500 { color: rgb(255 255 255 / 0.55); }
.public-dark .text-slate-400 { color: rgb(255 255 255 / 0.4); }
.public-dark .text-slate-300 { color: rgb(255 255 255 / 0.3); }
/* Backgrounds & borders that views used */
.public-dark .bg-white { background: rgb(255 255 255 / 0.12); color: rgb(255 255 255 / 0.95); }
.public-dark .bg-slate-50, .public-dark .bg-slate-50\/60 { background: rgb(255 255 255 / 0.025); }
.public-dark .bg-slate-100 { background: rgb(255 255 255 / 0.06); }
.public-dark .bg-slate-200 { background: rgb(255 255 255 / 0.1); }
.public-dark .border-slate-100,
.public-dark .border-slate-200,
.public-dark .border-slate-300 { border-color: rgb(255 255 255 / 0.08); }
.public-dark .ring-slate-200 { --tw-ring-color: rgb(255 255 255 / 0.08); }
/* Tables in dark */
.public-dark .th { color: rgb(255 255 255 / 0.4); }
.public-dark .row { border-color: rgb(255 255 255 / 0.06); }
.public-dark .row:hover { background: rgb(255 255 255 / 0.03); }
/* Inline code blocks (pill highlight) — only for inline <code>, NOT code inside
a <pre> block (docs examples), which would get an unwanted full-width tint. */
.public-dark code { background: rgb(255 255 255 / 0.08); color: rgb(255 255 255 / 0.9); }
.public-dark pre code { background: transparent; padding: 0; }
/* Filter pills + segmented controls that admin pages stamp with bg-slate-900
for the "selected" state — that lands invisible on the dark shell. Treat
slate-900 as a strong-contrast surface in the dark palette. */
.public-dark .bg-slate-900 { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
.public-dark .hover\:bg-slate-700:hover { background: rgb(255 255 255 / 0.85); color: rgb(15 23 42); }
.public-dark .text-slate-700 { color: rgb(255 255 255 / 0.85); }
.public-dark .text-slate-200 { color: rgb(255 255 255 / 0.7); }
.public-dark .text-slate-100 { color: rgb(255 255 255 / 0.92); }
/* Divider utilities used by lists (recent activity, table rows). */
.public-dark .divide-slate-50 > :not([hidden]) ~ :not([hidden]),
.public-dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) {
border-color: rgb(255 255 255 / 0.05);
}
.public-dark .border-slate-100\/80 { border-color: rgb(255 255 255 / 0.06); }
.public-dark .hover\:bg-slate-50:hover { background: rgb(255 255 255 / 0.04); }
.public-dark .hover\:bg-slate-100:hover { background: rgb(255 255 255 / 0.06); }
@layer components {
/* surfaces */
.card { @apply bg-white rounded-xl border border-slate-200 shadow-sm; }
/* buttons — each variant is self-contained (Tailwind v4 can't @apply a custom class) */
.btn-primary {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-medium
transition-colors disabled:opacity-50 disabled:cursor-not-allowed
bg-slate-900 text-white hover:bg-slate-700 px-3.5 py-2;
}
.btn-soft {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium
transition-colors disabled:opacity-50 disabled:cursor-not-allowed
bg-slate-100 text-slate-700 hover:bg-slate-200 px-3 py-1.5;
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium
transition-colors disabled:opacity-50 disabled:cursor-not-allowed
text-slate-600 hover:bg-slate-100 hover:text-slate-900 px-3 py-1.5;
}
/* text link / quiet action */
.link { @apply text-slate-500 hover:text-slate-900 transition-colors cursor-pointer; }
/* form controls */
.field {
@apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800
outline-none transition-colors placeholder:text-slate-400
focus:border-slate-400 focus:ring-2;
}
/* Native <select> using .field: drop the OS arrow for a custom chevron that
sits away from the right edge, with room so the text never overlaps it. */
select.field {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
padding-right: 2.5rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.9rem center;
background-size: 1rem;
}
select.field::-ms-expand { display: none; }
/* status pill */
.pill { @apply inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium; }
/* table primitives */
.th { @apply text-left px-4 py-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400; }
.td { @apply px-4 py-3.5 align-middle; }
.row { @apply border-t border-slate-100 transition-colors hover:bg-slate-50; }
/* avatar chip for identity columns */
.avatar { @apply w-8 h-8 rounded-full grid place-items-center text-xs font-semibold shrink-0 ring-1 ring-black/5; }
}
+46
View File
@@ -0,0 +1,46 @@
// Light/dark theme state. Default is LIGHT. The choice persists in localStorage
// and is reflected as a `dark` class on <html>, which drives the CSS-variable
// palette in style.css (and the conditional `.public-dark` override layer).
import { ref } from 'vue'
const KEY = 'gw_theme'
const mql = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null
function systemTheme() {
return mql && mql.matches ? 'dark' : 'light'
}
// Precedence: an explicit user choice (localStorage) wins; otherwise follow the
// OS's light/dark setting.
const saved = localStorage.getItem(KEY)
const initial = saved === 'dark' || saved === 'light' ? saved : systemTheme()
export const theme = ref(initial)
export const isDark = ref(initial === 'dark')
function apply(t) {
isDark.value = t === 'dark'
const el = document.documentElement
el.classList.toggle('dark', t === 'dark')
}
// Apply at module load so the first paint already matches the resolved choice.
apply(theme.value)
// While the user hasn't made an explicit choice, keep tracking the OS setting
// live (e.g. they flip macOS/Windows to dark mode with the tab open).
if (mql) {
mql.addEventListener('change', () => {
if (!localStorage.getItem(KEY)) {
theme.value = systemTheme()
apply(theme.value)
}
})
}
/** Flip light ⇄ dark and persist as the user's explicit choice. */
export function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
localStorage.setItem(KEY, theme.value)
apply(theme.value)
}
+107
View File
@@ -0,0 +1,107 @@
// Date/relative-time formatting helpers ported from admin.html.
const CN_LOCALE = 'zh-CN'
const CN_TZ_OPTS = { timeZone: 'Asia/Shanghai', hour12: false }
/** Format a unix-seconds timestamp in Asia/Shanghai. */
export function fmtTs(sec) {
sec = Number(sec)
if (!sec || Number.isNaN(sec)) return '—'
try {
return new Date(sec * 1000).toLocaleString(CN_LOCALE, {
...CN_TZ_OPTS, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
} catch { return '—' }
}
/** Format an ISO-8601 string in Asia/Shanghai. */
export function fmtIso(iso) {
if (!iso) return '—'
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
try {
return d.toLocaleString(CN_LOCALE, {
...CN_TZ_OPTS, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
} catch { return iso }
}
/** Human-friendly "5m 后 / 3h 前" relative time from unix seconds.
* Floors to integer seconds so floating-point ts (e.g. `time.time()` on the
* server) never leaks "15.88s 前" into the UI. */
export function fmtRelative(ts) {
ts = Number(ts)
if (!ts || Number.isNaN(ts)) return '—'
const diff = Math.round(ts - Date.now() / 1000)
const abs = Math.abs(diff)
const u = (n, s) => `${n}${s}`
let txt
if (abs < 60) txt = u(abs, 's')
else if (abs < 3600) txt = u(Math.floor(abs / 60), 'm')
else if (abs < 86400) txt = u(Math.floor(abs / 3600), 'h')
else txt = u(Math.floor(abs / 86400), 'd')
return diff >= 0 ? `${txt} 后` : `${txt} 前`
}
// Accepts either unix-seconds (number or numeric string) or an ISO-8601 string,
// returning a Date in either case (null when unparseable). Lets the stacked
// date/time cells below work for both the unix timestamps (created_at, etc.)
// and the ISO reset_after string without callers caring which they hold.
function toDate(v) {
if (v === null || v === undefined || v === '') return null
if (typeof v === 'number' || /^\d+(\.\d+)?$/.test(String(v))) {
const n = Number(v)
return n ? new Date(n * 1000) : null
}
const d = new Date(v)
return isNaN(d.getTime()) ? null : d
}
/** Date part only — "2026/06/18". Pair with fmtClock for a compact 2-line cell. */
export function fmtDate(v) {
const d = toDate(v)
if (!d) return '—'
try {
return d.toLocaleDateString(CN_LOCALE, { ...CN_TZ_OPTS, year: 'numeric', month: '2-digit', day: '2-digit' })
} catch { return '—' }
}
/** Time part only — "00:31:09". Empty string when there's no timestamp. */
export function fmtClock(v) {
const d = toDate(v)
if (!d) return ''
try {
return d.toLocaleTimeString(CN_LOCALE, { ...CN_TZ_OPTS, hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch { return '' }
}
// Rank a resolution tier for ascending sort. Handles both video ("720p"/"1080p"
// /"4k") and image ("1K"/"2K"/"4K"): the "k" suffix scales ×1000 so "4k"/"4K"
// rank ABOVE "1080p" (a plain parseFloat would put "4k"=4 first, which is wrong).
function resRank(r) {
const s = String(r).trim()
const n = parseFloat(s) || 0
return /k$/i.test(s) ? n * 1000 : n
}
/** Sort resolution tiers ascending (720p before 1080p; 1K before 2K before 4K). */
export function sortResolutions(list) {
return [...(list || [])].sort((a, b) => resRank(a) - resRank(b))
}
export function nowTime() {
return new Date().toLocaleTimeString(CN_LOCALE, CN_TZ_OPTS)
}
/** Human-readable byte size — "86 MB", "5.3 MB", "512 KB". Rounds to a whole
* number at ≥10 units, one decimal below, so the same byte count reads
* identically everywhere (overview KPI, 图片管理, lightbox…). */
export function fmtSize(bytes) {
bytes = Number(bytes)
if (!bytes || Number.isNaN(bytes)) return '0 B'
const u = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0; let v = bytes
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`
}
+115
View File
@@ -0,0 +1,115 @@
// Smart parsing of pasted credentials (Adobe cookies / ChatGPT JWTs),
// ported verbatim from admin.html so import behaviour is unchanged.
export function looksLikeJwt(s) {
s = (s || '').replace(/^Bearer\s+/i, '').trim()
const parts = s.split('.')
if (parts.length !== 3) return false
return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 4)
}
function decodeJwtPayload(s) {
try {
let p = (s || '').replace(/^Bearer\s+/i, '').trim().split('.')[1]
if (!p) return null
p = p.replace(/-/g, '+').replace(/_/g, '/')
p += '='.repeat((4 - (p.length % 4)) % 4)
return JSON.parse(atob(p))
} catch (_) { return null }
}
// Runway JWTs carry a top-level numeric `id` plus an `sso` claim and, crucially,
// no OpenAI (https://api.openai.com/*) claims — that's what distinguishes them
// from a ChatGPT JWT, which is otherwise also an opaque three-part token.
export function looksLikeRunwayJwt(s) {
const claims = decodeJwtPayload(s)
if (!claims || typeof claims !== 'object') return false
if (Object.keys(claims).some((k) => k.startsWith('https://api.openai.com/'))) return false
return 'sso' in claims && claims.id != null
}
// Leonardo cookies carry the better-auth session cookie — that's what tells them
// apart from an Adobe cookie (both are otherwise opaque cookie strings).
export function looksLikeLeonardoCookie(s) {
return /better-auth\.session_token/.test(s || '') || /better-auth\.session_data/.test(s || '')
}
// Krea cookies carry the Supabase auth cookie.
export function looksLikeKreaCookie(s) {
return /sb-superb-auth-token/.test(s || '')
}
// An Imagine.art credential is a JSON object { token, refreshToken } (both JWTs).
function isImagineObj(o) {
return !!o && typeof o === 'object' &&
typeof o.token === 'string' && looksLikeJwt(o.token) &&
typeof o.refreshToken === 'string' && looksLikeJwt(o.refreshToken)
}
// String form (a pasted JSON object on a line).
export function looksLikeImagineToken(s) {
try { return isImagineObj(JSON.parse(s)) } catch (_) { return false }
}
// Classify an opaque credential string by its distinctive shape. Imagine is
// JSON-shaped, so it must be checked before the cookie heuristics.
function cookieType(v) {
if (looksLikeImagineToken(v)) return 'imagine'
if (looksLikeKreaCookie(v)) return 'krea'
if (looksLikeLeonardoCookie(v)) return 'leonardo'
return 'adobe'
}
function cookieFromAny(item) {
if (typeof item === 'string') return item.trim()
if (item && typeof item === 'object') {
if (typeof item.cookie === 'string') return item.cookie.trim()
if (typeof item.value === 'string' && !('name' in item)) return item.value.trim()
if (Array.isArray(item.cookies)) {
return item.cookies.filter((c) => c && c.name).map((c) => `${c.name}=${c.value}`).join('; ')
}
}
return ''
}
/** Returns a list of { type: 'adobe' | 'openai' | 'runway' | 'leonardo', value }. */
export function parseImportInput(text) {
text = (text || '').trim()
if (!text) return []
// Try JSON first.
try {
const j = JSON.parse(text)
if (Array.isArray(j) && j.length > 0) {
// Chrome cookie export: array of {name,value} → one cookie account.
if (j.every((it) => it && typeof it === 'object' && 'name' in it && 'value' in it)) {
const joined = j.filter((c) => c && c.name).map((c) => `${c.name}=${c.value}`).join('; ')
return joined ? [{ type: cookieType(joined), value: joined }] : []
}
// Otherwise treat as multiple accounts. An Imagine credential is itself a
// JSON object {token,refreshToken} — keep it as its JSON string value.
return j.map((it) => {
if (isImagineObj(it)) return { type: 'imagine', value: JSON.stringify(it) }
const v = cookieFromAny(it)
return { type: cookieType(v), value: v }
}).filter((x) => x.value)
}
if (j && typeof j === 'object') {
if (isImagineObj(j)) return [{ type: 'imagine', value: JSON.stringify(j) }]
const v = cookieFromAny(j)
return v ? [{ type: cookieType(v), value: v }] : []
}
} catch (_) { /* not JSON */ }
// Not JSON → split per line, identify each. A JWT is either a Runway token
// (top-level id+sso, no openai claims) or a ChatGPT token; anything else is
// treated as an Adobe cookie string.
const lines = text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
return lines.map((line) => {
if (looksLikeJwt(line)) {
const value = line.replace(/^Bearer\s+/i, '')
return looksLikeRunwayJwt(value)
? { type: 'runway', value }
: { type: 'openai', value }
}
return { type: cookieType(line), value: line }
})
}
+72
View File
@@ -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>
+651
View File
@@ -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>
+383
View File
@@ -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>
+509
View File
@@ -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>
+372
View File
@@ -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 &lt;key&gt;</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>
+402
View File
@@ -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>
+272
View File
@@ -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>
+229
View File
@@ -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>
+102
View File
@@ -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>
+438
View File
@@ -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>
+399
View File
@@ -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>
+518
View File
@@ -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>
+648
View File
@@ -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>
+364
View File
@@ -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>
+417
View File
@@ -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>
+302
View File
@@ -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>
+269
View File
@@ -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>
+569
View File
@@ -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>