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
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.vite
*.log
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
.env.local
*.local
+23
View File
@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1
# Frontend is open-source: built from source inside the image, then served by
# nginx which also reverse-proxies the API and terminates TLS (certs from the
# acme.sh sidecar via a shared volume).
# ---- build stage ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ---- serve stage ----
FROM nginx:1.27-alpine
# openssl: self-signed bootstrap cert. The nginx image substitutes ${DOMAIN} in
# /etc/nginx/templates/*.template at startup (limited to DOMAIN via the filter).
RUN apk add --no-cache openssl
COPY --from=build /app/dist /usr/share/nginx/html
COPY default.conf.template /etc/nginx/templates/default.conf.template
COPY docker-entrypoint.d/10-selfsigned.sh /docker-entrypoint.d/10-selfsigned.sh
COPY docker-entrypoint.d/30-cert-watch.sh /docker-entrypoint.d/30-cert-watch.sh
RUN chmod +x /docker-entrypoint.d/10-selfsigned.sh /docker-entrypoint.d/30-cert-watch.sh
EXPOSE 80 443
+36
View File
@@ -0,0 +1,36 @@
# ai-gateway frontend
Vue 3 + Vite admin console for the ai-gateway backend. This replaces the old
single-file `static/admin.html`.
## Develop
```bash
npm install
npm run dev # http://localhost:5173
```
The dev server proxies `/admin`, `/health`, `/generated`, `/v1` to the backend.
Start the backend separately:
```bash
# from repo root
python app.py # http://0.0.0.0:6060
```
If the backend runs elsewhere, set `VITE_BACKEND` before `npm run dev`:
```bash
VITE_BACKEND=http://192.168.1.10:6060 npm run dev
```
## Build
```bash
npm run build # outputs static assets to ./dist
npm run preview # serve the production build locally
```
When hosting `dist/` on a different origin than the API, set `VITE_API_BASE`
(e.g. `VITE_API_BASE=http://api-host:6060`) at build time, and add that frontend
origin to the backend's `CORS_ORIGINS` env var.
+74
View File
@@ -0,0 +1,74 @@
# nginx for the docker stack. ${DOMAIN} is filled at container start (envsubst,
# limited to DOMAIN via NGINX_ENVSUBST_FILTER). Port 80 serves the ACME
# http-01 challenge and redirects everything else to HTTPS; port 443 serves the
# SPA + reverse-proxies the API. Certs come from the shared volume, issued/renewed
# by the acme.sh sidecar (a self-signed cert bootstraps 443 before the real one).
# ---- 80: ACME challenge + redirect to HTTPS ----
server {
listen 80;
listen [::]:80;
server_name ${DOMAIN};
# acme.sh writes http-01 challenge files here (shared volume).
location ^~ /.well-known/acme-challenge/ {
root /var/www/acme;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
# ---- 443: the app ----
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${DOMAIN};
ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 50m;
# Proxy headers (inherited by all proxy_pass locations below).
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# video / large-image generation can block minutes — avoid the 60s 504.
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# Hashed build assets never change — cache hard.
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# SPA fallback; index.html must never be cached (else stale bundle hash).
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# ---- API / media / health -> backend ----
# 动态 API no-store,防止 CDN 缓存 GET 响应(如 managed-models)→ 改价不生效。
location ^~ /admin/api/ { proxy_pass http://backend:6666; add_header Cache-Control "no-store" always; }
location ^~ /images/ { proxy_pass http://backend:6666; }
location = /health { proxy_pass http://backend:6666; }
# /v1 is per-API-key authenticated — must NOT be cached by any CDN/proxy.
location ^~ /v1/ {
proxy_pass http://backend:6666;
add_header Cache-Control "no-store" always;
}
}
@@ -0,0 +1,14 @@
#!/bin/sh
# Bootstrap a self-signed cert so nginx's 443 server block can start BEFORE
# acme.sh has issued the real certificate. acme.sh later overwrites these files
# in the shared volume; 30-cert-watch.sh reloads nginx when that happens.
set -e
D="${DOMAIN:-localhost}"
CERT_DIR="/etc/nginx/certs/live/$D"
mkdir -p "$CERT_DIR" /var/www/acme
if [ ! -s "$CERT_DIR/fullchain.pem" ] || [ ! -s "$CERT_DIR/privkey.pem" ]; then
echo "nginx: generating self-signed bootstrap cert for $D"
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout "$CERT_DIR/privkey.pem" -out "$CERT_DIR/fullchain.pem" \
-subj "/CN=$D" >/dev/null 2>&1
fi
@@ -0,0 +1,20 @@
#!/bin/sh
# Reload nginx whenever the certificate file changes — i.e. once acme.sh has
# issued/renewed the real cert into the shared volume, nginx picks it up within
# ~a minute without a container restart. Runs in the background so it doesn't
# block startup.
D="${DOMAIN:-localhost}"
CERT="/etc/nginx/certs/live/$D/fullchain.pem"
(
last=""
while true; do
sleep 60
cur="$(stat -c %Y "$CERT" 2>/dev/null || echo '')"
if [ -n "$cur" ] && [ "$cur" != "$last" ]; then
# Skip the very first observation (the self-signed bootstrap); only reload
# on a genuine change (acme.sh overwrote the cert).
[ -n "$last" ] && nginx -s reload 2>/dev/null || true
last="$cur"
fi
done
) &
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Vivid 首页</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1986
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "vivid-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.0",
"vite": "^6.0.7"
}
}
+17
View File
@@ -0,0 +1,17 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="vbg" 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="vgl" 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>
<rect x="2" y="2" width="44" height="44" rx="13" fill="url(#vbg)" />
<rect x="2" y="2" width="44" height="22" rx="13" fill="url(#vgl)" />
<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>

After

Width:  |  Height:  |  Size: 898 B

+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 ? '正在生成视频 (约 13 分钟)…' : '正在生成…'
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 ? '生成视频中 (约 13 分钟)…' : '生成中…'
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 (clicklight),
moon when light (clickdark). -->
<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 }} 是一个聚合 GPTGeminiFireflyFlux 等顶级模型的 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 条的硬上限范围 1365 ,默认 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">超过该天数的生成文件(包括用户生图和后台测试图)会被自动删除范围 1365 ,默认 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">
把脑海里的画面写成一句话,GPTGeminiFireflyFlux 等顶级模型替你变成图像与视频
</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 (2448h 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' ? '生成视频中 (约 13 分钟)…' : '生成中…'
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' ? '生成视频中 (约 13 分钟)…' : '生成中…'
} 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' ? '生成视频中 (约 13 分钟)…' : '生成中…'
}
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>
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// Dev server proxies backend routes to the Go backend (default :6666) so the
// frontend can use relative paths exactly like the old static UI did. Override
// the target with VITE_BACKEND when the backend runs elsewhere.
const backend = process.env.VITE_BACKEND || 'http://127.0.0.1:6666'
// Vite's underlying http-proxy doesn't add X-Forwarded-For / X-Real-IP by
// default, so the backend just sees the proxy's loopback address as the
// caller and stamps every login as 127.0.0.1. This hook forwards the real
// socket peer instead. (Only useful when the dev server is reachable from
// another device on the LAN — same-machine browsing is genuinely 127.0.0.1.)
function forwardClientIp(proxy) {
proxy.on('proxyReq', (proxyReq, req) => {
const ip = (req.socket && req.socket.remoteAddress) || ''
if (!ip) return
const existing = req.headers['x-forwarded-for']
proxyReq.setHeader('x-forwarded-for', existing ? `${existing}, ${ip}` : ip)
if (!req.headers['x-real-ip']) proxyReq.setHeader('x-real-ip', ip)
})
}
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
port: 5173,
proxy: {
// Only the admin API is proxied — bare /admin/* is an SPA route now
// (the admin shell), handled client-side by vue-router.
'/admin/api': { target: backend, changeOrigin: true, configure: forwardClientIp },
'/health': { target: backend, changeOrigin: true, configure: forwardClientIp },
// Generated artifacts are served from /images.
'/images': { target: backend, changeOrigin: true, configure: forwardClientIp },
'/v1': { target: backend, changeOrigin: true, configure: forwardClientIp },
},
},
})