Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
) &
|
||||
@@ -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>
|
||||
Generated
+1986
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,314 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { sortResolutions } from '../utils/format'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
model: { type: Object, required: true },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const isVideo = props.model.type === 'video'
|
||||
|
||||
// Video capabilities — the authoritative source is the family preset
|
||||
// (core/video_models.FAMILIES), not the user-edited managed-model record
|
||||
// which may have stale max_reference_images / reference_mode values.
|
||||
const familyPreset = ref(null)
|
||||
onMounted(async () => {
|
||||
if (!isVideo) return
|
||||
const r = await api('/video-presets')
|
||||
const list = r.data?.data || []
|
||||
familyPreset.value = list.find((p) => p.key === props.model.id) || null
|
||||
})
|
||||
|
||||
const ratios = (props.model.ratios && props.model.ratios.length)
|
||||
? props.model.ratios
|
||||
: (isVideo ? ['16x9'] : ['1:1'])
|
||||
|
||||
const resolutions = (props.model.resolutions && props.model.resolutions.length)
|
||||
? sortResolutions(props.model.resolutions)
|
||||
: (isVideo ? ['720p'] : ['2K'])
|
||||
|
||||
const durations = computed(() => {
|
||||
// duration_prices keys come back alphabetically from Go ("10s" before "5s");
|
||||
// sort by numeric seconds so the shortest is first.
|
||||
const keys = Object.keys(props.model?.duration_prices || {})
|
||||
.sort((a, b) => parseFloat(a) - parseFloat(b))
|
||||
if (keys.length) return keys
|
||||
if (familyPreset.value?.durations?.length) return familyPreset.value.durations
|
||||
return ['5s', '10s']
|
||||
})
|
||||
|
||||
// Reference image support — for video models, max_reference_images > 0 means
|
||||
// frames can/must be uploaded. Kling 3 i2v actually requires >= 1.
|
||||
const maxRefs = computed(() => {
|
||||
const fromPreset = Number(familyPreset.value?.max_reference_images || 0)
|
||||
const fromModel = Number(props.model?.max_reference_images || 0)
|
||||
const m = Math.max(fromPreset, fromModel)
|
||||
if (m > 0) return m
|
||||
// Image models advertise image-to-image via a boolean (not a count) — allow one.
|
||||
if (!isVideo && props.model?.image_to_image) return 1
|
||||
return 0
|
||||
})
|
||||
const refMode = computed(() => familyPreset.value?.reference_mode || props.model?.reference_mode || 'none')
|
||||
// Reference images are ALWAYS optional — every video model supports pure
|
||||
// text2video; frame refs (首帧/末帧) only enhance the result when supplied.
|
||||
const refsRequired = computed(() => false)
|
||||
const refsLabel = computed(() => {
|
||||
if (!isVideo) return maxRefs.value > 0 ? '参考图 (可选, 图生图)' : ''
|
||||
if (refMode.value === 'asset') return `参考图 (asset 模式, 最多 ${maxRefs.value} 张)`
|
||||
if (refMode.value === 'frame') {
|
||||
if (maxRefs.value >= 2) return `首帧 / 末帧 (1=首帧, 2=首尾帧, 最多 ${maxRefs.value} 张)`
|
||||
return `首帧 (可选, ${maxRefs.value} 张)`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const prompt = ref(isVideo
|
||||
? 'A cinematic shot of a golden retriever running through a wheat field at sunset.'
|
||||
: 'a cute cat sitting on a desk, studio lighting')
|
||||
const ratio = ref(ratios[0])
|
||||
const resolution = ref(resolutions[0])
|
||||
const duration = ref(durations.value[0])
|
||||
|
||||
const refImages = ref([]) // [{ name, dataUrl }]
|
||||
const fileInput = ref(null)
|
||||
// Image 5 instruct-edit derives aspect from the reference image — hide the ratio
|
||||
// picker when a ref is attached (backend omits aspectRatio to avoid a 400).
|
||||
const showRatio = computed(() => !(props.model.id === 'firefly-image-5' && refImages.value.length > 0))
|
||||
|
||||
function openPicker() { fileInput.value && fileInput.value.click() }
|
||||
|
||||
function onFiles(ev) {
|
||||
const files = Array.from(ev.target.files || [])
|
||||
const room = Math.max(0, maxRefs.value - refImages.value.length)
|
||||
const toAdd = files.slice(0, room)
|
||||
for (const f of toAdd) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
refImages.value.push({ name: f.name, dataUrl: reader.result })
|
||||
}
|
||||
reader.readAsDataURL(f)
|
||||
}
|
||||
if (ev.target) ev.target.value = ''
|
||||
}
|
||||
|
||||
function removeRef(i) { refImages.value.splice(i, 1) }
|
||||
|
||||
const busy = ref(false)
|
||||
const status = ref('')
|
||||
const error = ref('')
|
||||
const resultUrl = ref('')
|
||||
const resultKind = ref('')
|
||||
|
||||
// Gateway-timeout recovery: the backend detaches the render from the request
|
||||
// (context.WithoutCancel), so an EdgeOne 524 / proxy timeout does NOT kill it —
|
||||
// it finishes and is logged as a source="admin" event. On such a timeout we keep
|
||||
// the modal "生成中" and poll /jobs/mine?source=admin to recover the result.
|
||||
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
||||
let recoverTimer = null
|
||||
let recoverJobId = ''
|
||||
let recoverSubmitTs = 0
|
||||
onUnmounted(() => clearTimeout(recoverTimer))
|
||||
|
||||
async function run() {
|
||||
if (!prompt.value.trim()) { error.value = '请输入提示词'; return }
|
||||
if (refsRequired.value && refImages.value.length < 1) {
|
||||
error.value = '该视频模型需要至少 1 张参考图 (首帧)'
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
status.value = isVideo ? '正在生成视频 (约 1–3 分钟)…' : '正在生成…'
|
||||
resultUrl.value = ''
|
||||
resultKind.value = ''
|
||||
const payload = {
|
||||
model: props.model.id,
|
||||
prompt: prompt.value,
|
||||
ratio: ratio.value,
|
||||
resolution: resolution.value,
|
||||
}
|
||||
if (isVideo) {
|
||||
payload.duration = duration.value
|
||||
}
|
||||
if (refImages.value.length) {
|
||||
// Backend accepts raw base64 only — strip the "data:...;base64," prefix.
|
||||
payload.reference_images = refImages.value.map((r) => r.dataUrl.replace(/^data:[^,]*,/, ''))
|
||||
}
|
||||
recoverSubmitTs = Date.now()
|
||||
const r = await api('/test', jsonBody('POST', payload))
|
||||
if (r.ok && r.data?.url) {
|
||||
busy.value = false
|
||||
resultUrl.value = r.data.url
|
||||
resultKind.value = r.data.kind || (isVideo ? 'video' : 'image')
|
||||
status.value = `完成 · ${r.data.provider} · ${r.data.elapsed_ms}ms`
|
||||
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
||||
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。保持锁住,轮询恢复结果。
|
||||
status.value = isVideo ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
recoverJobId = ''
|
||||
clearTimeout(recoverTimer)
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
} else {
|
||||
busy.value = false
|
||||
status.value = ''
|
||||
error.value = r.data?.detail || `失败 (${r.status})`
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the admin's own in-flight/just-finished test job after a gateway timeout.
|
||||
async function recover() {
|
||||
const r = await api('/jobs/mine?source=admin')
|
||||
if (!r.ok) { recoverTimer = setTimeout(recover, 3000); return }
|
||||
const { pending, latest } = r.data || {}
|
||||
if (pending) {
|
||||
// Still rendering server-side — remember its id and keep waiting.
|
||||
recoverJobId = pending.id
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
return
|
||||
}
|
||||
// No pending: did OUR job finish? Match by the id we saw, or (if it completed
|
||||
// before our first poll) by a latest that started at/after our submit.
|
||||
const mine = latest && (
|
||||
(recoverJobId && latest.id === recoverJobId) ||
|
||||
(!recoverJobId && latest.ts && latest.ts * 1000 >= recoverSubmitTs - 2000)
|
||||
)
|
||||
if (mine && latest.status === 'success' && latest.url) {
|
||||
busy.value = false
|
||||
resultUrl.value = latest.url
|
||||
resultKind.value = latest.kind || (isVideo ? 'video' : 'image')
|
||||
status.value = `完成 · ${(latest.elapsed_ms / 1000).toFixed(1)}s`
|
||||
return
|
||||
}
|
||||
if (mine && latest.status === 'failed') {
|
||||
busy.value = false
|
||||
status.value = ''
|
||||
error.value = latest.error || '生成失败'
|
||||
return
|
||||
}
|
||||
// Not resolved yet (event still committing) — keep polling.
|
||||
recoverTimer = setTimeout(recover, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4"
|
||||
@click.self="emit('close')">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-lg">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-semibold">测试模型</h2>
|
||||
<div class="text-xs text-white/45 font-mono truncate">{{ model.id }}</div>
|
||||
</div>
|
||||
<button @click="emit('close')" class="text-white/40 hover:text-white transition-colors">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="lbl">提示词</label>
|
||||
<textarea v-model="prompt" rows="3" class="field resize-none" placeholder="输入测试提示词…"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Param controls — same pill-button row as the public 画图 page.
|
||||
Show the row whenever there's at least one option (even a single
|
||||
one) so the chosen value is visible, not silently hidden. -->
|
||||
<div v-if="ratios.length > 0 && showRatio">
|
||||
<label class="lbl">比例</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="r in ratios" :key="r" type="button" @click="ratio = r"
|
||||
class="opt" :class="ratio === r && 'opt-on'">{{ r }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="resolutions.length > 0">
|
||||
<label class="lbl">{{ isVideo ? '分辨率' : '画质' }}</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="r in resolutions" :key="r" type="button" @click="resolution = r"
|
||||
class="opt" :class="resolution === r && 'opt-on'">{{ r }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isVideo && durations.length > 0">
|
||||
<label class="lbl">时长</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="d in durations" :key="d" type="button" @click="duration = d"
|
||||
class="opt" :class="duration === d && 'opt-on'">{{ d }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- reference images -->
|
||||
<div v-if="maxRefs > 0">
|
||||
<label class="lbl">
|
||||
{{ refsLabel }}
|
||||
<span v-if="refsRequired" class="text-rose-300">*</span>
|
||||
</label>
|
||||
<div class="flex gap-2 flex-wrap items-start">
|
||||
<div v-for="(img, i) in refImages" :key="i"
|
||||
class="relative w-20 h-20 rounded-lg overflow-hidden ring-1 ring-white/10 bg-white/[0.04]">
|
||||
<img :src="img.dataUrl" class="w-full h-full object-cover" />
|
||||
<button type="button" @click="removeRef(i)"
|
||||
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white hover:bg-rose-500 transition-colors grid place-items-center">
|
||||
<Icon name="close" class="w-3 h-3" />
|
||||
</button>
|
||||
<div v-if="refMode === 'frame' && maxRefs >= 2"
|
||||
class="absolute bottom-0 inset-x-0 text-[10px] text-white bg-black/60 text-center py-0.5">
|
||||
{{ i === 0 ? '首帧' : (i === 1 ? '末帧' : '') }}
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="refImages.length < maxRefs" type="button" @click="openPicker"
|
||||
class="w-20 h-20 rounded-lg border-2 border-dashed border-white/15 text-white/40 hover:bg-white/[0.04] hover:border-white/30 transition-colors grid place-items-center">
|
||||
<Icon name="plus" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" />
|
||||
</div>
|
||||
|
||||
<button @click="run" :disabled="busy" class="btn-primary w-full">
|
||||
<Icon name="spark" class="w-4 h-4" /> {{ busy ? (isVideo ? '生成中…(请耐心等待)' : '生成中…') : '生成' }}
|
||||
</button>
|
||||
|
||||
<p v-if="status" class="text-xs text-white/55">{{ status }}</p>
|
||||
<p v-if="error" class="text-xs text-rose-300 break-all">{{ error }}</p>
|
||||
|
||||
<div v-if="resultUrl" class="rounded-xl ring-1 ring-white/10 bg-white/[0.03] overflow-hidden grid place-items-center min-h-[220px]">
|
||||
<video v-if="resultKind === 'video'" :src="resultUrl" controls autoplay
|
||||
class="max-w-full max-h-[420px] object-contain" />
|
||||
<img v-else :src="resultUrl" class="max-w-full max-h-[360px] object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* Pill option button — exactly the same shape as the public 画图 page's
|
||||
ratio/resolution/duration row. Replaces the native <select> drop-down. */
|
||||
.opt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.opt:hover { background: rgb(255 255 255 / 0.08); color: white; }
|
||||
.opt-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -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') + ' 积分'
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { auth, isAuthed, isAdmin, openLogin } from '../auth'
|
||||
import { isDark, toggleTheme } from '../theme'
|
||||
import { site } from '../site'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import Logo from '../components/Logo.vue'
|
||||
import { pointsLabel } from '../credits'
|
||||
import { draft } from '../playground'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 画图/记录 only show once signed in; clicking 设置 while logged out opens login.
|
||||
const nav = computed(() => {
|
||||
const items = [{ to: '/', label: '首页', icon: 'overview' }]
|
||||
if (isAuthed()) {
|
||||
items.push({ to: '/user', label: '画图', icon: 'spark' })
|
||||
items.push({ to: '/logs', label: '记录', icon: 'log' })
|
||||
items.push({ to: '/mylogs', label: '日志', icon: 'files' })
|
||||
items.push({ to: '/invite', label: '邀请', icon: 'accounts' })
|
||||
}
|
||||
// 文档 + 关于 are public — visible to guests too.
|
||||
items.push({ to: '/docs', label: '文档', icon: 'log' })
|
||||
items.push({ to: '/about', label: '关于', icon: 'accounts' })
|
||||
return items
|
||||
})
|
||||
|
||||
function onSettings(e) {
|
||||
if (!isAuthed()) { e.preventDefault(); openLogin('/settings') }
|
||||
}
|
||||
|
||||
// credits — the logged-in user's real server-side balance (auth.user.credits)
|
||||
const credits = computed(() => Number(auth.user?.credits || 0))
|
||||
|
||||
const showBalance = computed(() => route.path === '/user')
|
||||
|
||||
const creditsLabel = computed(() => pointsLabel(credits.value))
|
||||
|
||||
// On the 画图 workbench the header label tracks the active mode — it flips
|
||||
// with the 生图/生视频 tab, and on state restore (回显) reflects whatever a
|
||||
// pending job is generating (video → 生视频). draft.mode is kept in sync by
|
||||
// PlaygroundView for both cases.
|
||||
const currentLabel = computed(() => {
|
||||
if (route.path === '/user') return draft.mode === 'video' ? '生视频' : '生图'
|
||||
return route.meta?.label || ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-x min-h-screen flex bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-violet-400/30">
|
||||
<!-- ===== Left rail ===== -->
|
||||
<aside class="fixed inset-y-0 left-0 z-30 w-16 md:w-20 flex flex-col items-center py-5 border-r border-[color:var(--hairline)]">
|
||||
<!-- Logo -->
|
||||
<router-link to="/" class="mb-8 group transition-transform hover:scale-105">
|
||||
<Logo :size="40" class="rounded-xl shadow-lg shadow-violet-500/20 ring-1 ring-white/10" />
|
||||
</router-link>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="flex flex-col gap-1.5 flex-1">
|
||||
<router-link
|
||||
v-for="n in nav" :key="n.to" :to="n.to"
|
||||
:exact-active-class="n.to === '/' ? 'active' : ''"
|
||||
:active-class="n.to === '/' ? '' : 'active'"
|
||||
class="rail-link group">
|
||||
<span class="w-10 h-10 rounded-xl grid place-items-center transition-all ring-1 ring-transparent group-hover:bg-[var(--hover)] group-hover:ring-[color:var(--hairline)]">
|
||||
<Icon :name="n.icon" class="w-4 h-4 transition-colors" />
|
||||
</span>
|
||||
<span class="rail-label">{{ n.label }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- Bottom: admin shortcut (admins only) + settings -->
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<router-link v-if="isAdmin()" to="/admin/overview" title="进入管理后台"
|
||||
class="rail-bottom">
|
||||
<Icon name="shield" class="w-4 h-4" />
|
||||
</router-link>
|
||||
<!-- Light/dark toggle — sits right above 设置. Sun when dark (click→light),
|
||||
moon when light (click→dark). -->
|
||||
<button type="button" @click="toggleTheme"
|
||||
:title="isDark ? '切换到亮色' : '切换到暗色'" class="rail-bottom">
|
||||
<svg v-if="isDark" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
<router-link to="/settings" title="设置" @click="onSettings"
|
||||
:class="$route.path === '/settings' ? 'rail-bottom active' : 'rail-bottom'">
|
||||
<Icon name="config" class="w-4 h-4" />
|
||||
</router-link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ===== Main column ===== -->
|
||||
<div class="flex-1 min-w-0 ml-16 md:ml-20 relative">
|
||||
<!-- soft background mesh -->
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div class="absolute -top-32 left-1/3 w-[40rem] h-[40rem] rounded-full opacity-[0.16]"
|
||||
style="background: radial-gradient(circle, #a855f7, transparent 60%); filter: blur(100px)"></div>
|
||||
<div class="absolute top-1/2 -right-40 w-[36rem] h-[36rem] rounded-full opacity-[0.14]"
|
||||
style="background: radial-gradient(circle, #06b6d4, transparent 60%); filter: blur(100px)"></div>
|
||||
<div class="absolute bottom-0 left-0 w-[32rem] h-[32rem] rounded-full opacity-[0.10]"
|
||||
style="background: radial-gradient(circle, #f43f5e, transparent 60%); filter: blur(100px)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Page header — shows the Vivid wordmark on home, route label
|
||||
elsewhere. Same vertical position across routes so the brand
|
||||
stamp doesn't jump around. -->
|
||||
<header class="relative z-10 px-8 md:px-14 pt-10 pb-4 flex items-center justify-between gap-4">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-[22px] font-bold tracking-tight bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent">
|
||||
{{ site.title }}
|
||||
</span>
|
||||
<span class="text-[10px] uppercase tracking-[0.3em] text-[color:var(--fg-faint)]">{{ route.path === '/' ? 'AI 生图 · 生视频' : currentLabel }}</span>
|
||||
</div>
|
||||
<router-link v-if="showBalance" to="/settings"
|
||||
class="text-xs text-[color:var(--fg-2)] hover:text-[color:var(--fg)] tabular-nums transition-colors">
|
||||
余额 <span class="text-[color:var(--fg)] font-semibold">{{ creditsLabel }}</span>
|
||||
<span class="text-[color:var(--fg-faint)] ml-1">· 充值</span>
|
||||
</router-link>
|
||||
</header>
|
||||
|
||||
<main :class="['relative z-10 px-8 md:px-14 pb-24 pt-2', { 'public-dark': isDark }]">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rail-link {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
color: var(--fg-3);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.rail-link:hover { color: var(--fg); }
|
||||
.rail-link.active { color: var(--fg); }
|
||||
.rail-link.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1rem;
|
||||
/* Anchor to the icon box, not the whole link (which includes the label
|
||||
underneath). rail-link has padding-top 0.25rem and the icon span is 2.5rem
|
||||
tall — so the icon center sits at 0.25rem + 1.25rem = 1.5rem from top. */
|
||||
top: 1.5rem;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #f0abfc, #a78bfa);
|
||||
}
|
||||
.rail-link.active > span:first-child {
|
||||
background: var(--hover) !important;
|
||||
--tw-ring-color: var(--hairline);
|
||||
}
|
||||
.rail-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rail-bottom {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--fg-3);
|
||||
--tw-ring-color: transparent;
|
||||
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.rail-bottom:hover {
|
||||
color: var(--fg);
|
||||
background: var(--hover);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
}
|
||||
.rail-bottom.active {
|
||||
color: var(--fg);
|
||||
background: var(--hover);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
}
|
||||
|
||||
/* Admin shortcut — only rendered for admins. Tinted so it doesn't get lost
|
||||
next to the plain settings cog right below it. */
|
||||
.admin-shortcut {
|
||||
color: rgb(196 181 253); /* violet-300 */
|
||||
background: linear-gradient(135deg, rgb(167 139 250 / 0.12), rgb(236 72 153 / 0.10));
|
||||
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.25);
|
||||
}
|
||||
.admin-shortcut:hover {
|
||||
color: white;
|
||||
background: linear-gradient(135deg, rgb(167 139 250 / 0.22), rgb(236 72 153 / 0.18));
|
||||
box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.45);
|
||||
}
|
||||
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.fade-enter-from { opacity: 0; transform: translateY(8px); }
|
||||
.fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -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')
|
||||
@@ -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 || ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { site } from '../site'
|
||||
|
||||
const contact = computed(() => site.contact || {})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-12 max-w-3xl">
|
||||
<!-- intro -->
|
||||
<section>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-fuchsia-300/70 font-medium">关于</div>
|
||||
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight text-[color:var(--fg)]">关于 {{ site.title }}</h1>
|
||||
<p class="text-[color:var(--fg-2)] mt-5 text-base md:text-lg leading-relaxed max-w-xl">
|
||||
{{ site.title }} 是一个聚合 GPT、Gemini、Firefly、Flux 等顶级模型的 AI 生图与生视频平台。
|
||||
把脑海里的画面写成一句话,交给我们替你变成图像与视频。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- contact -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold tracking-tight text-[color:var(--fg)] mb-1">联系我们</h2>
|
||||
<p class="text-[color:var(--fg-3)] mb-6">有问题、合作,或想加入交流群?随时找我们。</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<a v-if="contact.shop" :href="contact.shop" target="_blank" rel="noopener"
|
||||
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-fuchsia-300/80">商店</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 group-hover:text-fuchsia-400 transition-colors">前往充值商店</div>
|
||||
</div>
|
||||
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
|
||||
<a v-if="contact.email" :href="`mailto:${contact.email}`"
|
||||
class="group rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">邮箱</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 break-all group-hover:text-sky-400 transition-colors">{{ contact.email }}</div>
|
||||
</a>
|
||||
|
||||
<a v-if="contact.qq && contact.qq_link" :href="contact.qq_link" target="_blank" rel="noopener"
|
||||
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all group-hover:text-sky-400 transition-colors">{{ contact.qq }}</div>
|
||||
</div>
|
||||
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
<div v-else-if="contact.qq" class="rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5">
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all">{{ contact.qq }}</div>
|
||||
</div>
|
||||
|
||||
<a v-if="contact.qq_group && contact.qq_group_link" :href="contact.qq_group_link" target="_blank" rel="noopener"
|
||||
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ 群</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all group-hover:text-emerald-400 transition-colors">{{ contact.qq_group }}</div>
|
||||
</div>
|
||||
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
<div v-else-if="contact.qq_group" class="rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5">
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)]">QQ 群</div>
|
||||
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 tabular-nums break-all">{{ contact.qq_group }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!contact.shop && !contact.email && !contact.qq && !contact.qq_group"
|
||||
class="text-sm text-[color:var(--fg-3)]">管理员尚未配置联系方式。</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,651 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { fmtTs, fmtIso, fmtDate, fmtClock } from '../utils/format'
|
||||
import ImportModal from '../components/ImportModal.vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const quotaStatus = ref('')
|
||||
const showImport = ref(false)
|
||||
|
||||
const typeFilter = ref('') // '' | 'openai' | 'adobe' | 'runway' | 'leonardo'
|
||||
const statusFilter = ref('') // '' | 'active' | 'quota' | 'disabled'
|
||||
const search = ref('')
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
// Typing a search term must jump back to page 1 — otherwise a narrowed result
|
||||
// set can leave you stranded on a now-empty page.
|
||||
watch(search, () => { page.value = 1 })
|
||||
|
||||
// 每个类型的 成功/失败/限额 三个数(成功=正常可用, 失败=失效/禁用, 限额=额度耗尽)。
|
||||
const stats = computed(() => {
|
||||
const by = (t) => {
|
||||
const s = rows.value.filter((r) => r.type === t)
|
||||
return {
|
||||
n: s.length,
|
||||
ok: s.filter((r) => r.status === 'active').length,
|
||||
dead: s.filter((r) => r.dead || r.status === 'disabled').length,
|
||||
quota: s.filter((r) => r.status === 'quota').length,
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: rows.value.length,
|
||||
openai: by('openai'), adobe: by('adobe'), runway: by('runway'),
|
||||
leonardo: by('leonardo'), krea: by('krea'), imagine: by('imagine'),
|
||||
}
|
||||
})
|
||||
|
||||
// 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。
|
||||
const deadCount = computed(() => rows.value.filter((r) => r.dead).length)
|
||||
|
||||
function typePill(t) {
|
||||
return {
|
||||
adobe: 'bg-rose-500/10 text-rose-300 ring-rose-400/30',
|
||||
openai: 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30',
|
||||
runway: 'bg-violet-500/10 text-violet-300 ring-violet-400/30',
|
||||
leonardo: 'bg-amber-500/10 text-amber-300 ring-amber-400/30',
|
||||
krea: 'bg-sky-500/10 text-sky-300 ring-sky-400/30',
|
||||
imagine: 'bg-teal-500/10 text-teal-300 ring-teal-400/30',
|
||||
}[t] || 'bg-white/[0.06] text-white/70 ring-white/15'
|
||||
}
|
||||
const STATUS_LABEL = { active: '正常', quota: '额度耗尽', disabled: '已禁用', pending: '检测中' }
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
const sorted = [...rows.value].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
return sorted.filter((a) => {
|
||||
if (typeFilter.value && a.type !== typeFilter.value) return false
|
||||
if (statusFilter.value && a.status !== statusFilter.value) return false
|
||||
if (q && !(
|
||||
(a.email || '').toLowerCase().includes(q) ||
|
||||
(a.id || '').toLowerCase().includes(q) ||
|
||||
(a.type || '').toLowerCase().includes(q)
|
||||
)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filtered.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target !== page.value) page.value = target
|
||||
}
|
||||
function setFilter(fn) { fn(); page.value = 1 }
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
let pendingTimer = null
|
||||
|
||||
async function loadAccounts() {
|
||||
loading.value = true
|
||||
quotaStatus.value = ''
|
||||
const r = await api('/accounts')
|
||||
rows.value = r.data?.data || []
|
||||
loading.value = false
|
||||
if (rows.value.length) reconcile()
|
||||
schedulePendingPoll()
|
||||
}
|
||||
|
||||
// While any imported account is still being checked server-side, re-fetch the
|
||||
// list so it flips pending → active/dead on its own (no manual refresh).
|
||||
function schedulePendingPoll() {
|
||||
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null }
|
||||
if (!rows.value.some((r) => r.pending)) return
|
||||
pendingTimer = setTimeout(async () => {
|
||||
const r = await api('/accounts')
|
||||
rows.value = r.data?.data || []
|
||||
schedulePendingPoll()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Background reconciliation: openai → live quota; adobe → reset_after;
|
||||
// adobe without email → fetch email. Only ACTIVE accounts — pending ones are
|
||||
// handled by the import worker, dead/disabled ones need no re-check.
|
||||
//
|
||||
// Scope: ONLY the accounts visible on the current page. Probing all 100+ rows on
|
||||
// every open floods the backend; the user only ever sees ~20 at a time, so we
|
||||
// re-check just those and re-run when the page (or filters) change. New imports
|
||||
// are hydrated server-side by the import worker and surfaced via the pending
|
||||
// poll reading the store — they don't need a frontend probe.
|
||||
let reconcileToken = 0
|
||||
async function reconcile() {
|
||||
const myToken = ++reconcileToken // supersede any in-flight run (fast page flips)
|
||||
const visible = pagedItems.value
|
||||
// NEW accounts (still pending the import worker's server-side check) are never
|
||||
// probed here — the pending poll just reads the store until the worker writes
|
||||
// their quota/email. OLD accounts (active) get a real live /quota probe for
|
||||
// up-to-date remaining + refresh time.
|
||||
const quotaRows = visible.filter((r) => !r.pending && r.status === 'active' && (r.type === 'openai' || r.type === 'adobe' || r.type === 'runway' || r.type === 'leonardo' || r.type === 'krea' || r.type === 'imagine'))
|
||||
const adobeNeedEmail = visible.filter((r) => !r.pending && r.type === 'adobe' && !r.email)
|
||||
const total = quotaRows.length + adobeNeedEmail.length
|
||||
if (total === 0) { quotaStatus.value = ''; return }
|
||||
|
||||
let done = 0, updates = 0
|
||||
quotaStatus.value = `后台校对… 0/${total}`
|
||||
const bump = () => {
|
||||
done++
|
||||
if (myToken !== reconcileToken) return // a newer page-flip superseded us
|
||||
quotaStatus.value = `后台校对… ${done}/${total}${updates ? ` · 更新 ${updates}` : ''}`
|
||||
}
|
||||
|
||||
// Build thunks (NOT immediately-invoked) so the pool controls how many run at
|
||||
// once. Each /accounts/.../quota probe is a *synchronous* backend call to
|
||||
// OpenAI/Adobe. We only probe the visible page (≤ pageSize rows), so the
|
||||
// limit below is effectively bounded by that — no full-list flood.
|
||||
const jobs = []
|
||||
for (const row of quotaRows) {
|
||||
jobs.push(async () => {
|
||||
const result = await fetchOneQuota(row.pool, row.id)
|
||||
if (result && result.auth_failed) {
|
||||
// backend auto-disabled this dead (401) token — reflect it immediately
|
||||
row.status = result.status || 'disabled'
|
||||
row.dead = true
|
||||
row.remaining = null
|
||||
row._unknown = true
|
||||
updates++
|
||||
} else if (result && result.unchanged === false) {
|
||||
if (row.type === 'adobe') row.reset_after = result.reset_after
|
||||
else applyQuota(row, result)
|
||||
updates++
|
||||
}
|
||||
bump()
|
||||
})
|
||||
}
|
||||
for (const row of adobeNeedEmail) {
|
||||
jobs.push(async () => {
|
||||
const result = await fetchOneEmail(row.pool, row.id)
|
||||
if (result && result.email && (!row.email || row.email === '—')) {
|
||||
row.email = result.email
|
||||
updates++
|
||||
}
|
||||
bump()
|
||||
})
|
||||
}
|
||||
await runWithLimit(jobs, Infinity) // no JS-side cap — fire all visible-page probes at once (browser still limits ~6 conns/origin)
|
||||
// clear the indicator when done — but only if we're still the current run
|
||||
// (a page flip mid-reconcile starts a fresh one that owns the indicator).
|
||||
if (myToken === reconcileToken) quotaStatus.value = ''
|
||||
}
|
||||
|
||||
// Re-check the newly visible accounts whenever the page or filters change.
|
||||
// Only the on-screen page is ever probed (see reconcile), so flipping pages is
|
||||
// what triggers checking the rest — never all rows at once.
|
||||
watch([page, typeFilter, statusFilter], () => {
|
||||
if (rows.value.length) reconcile()
|
||||
})
|
||||
|
||||
// Bounded-concurrency runner: keeps at most `limit` thunks in flight at once.
|
||||
async function runWithLimit(thunks, limit) {
|
||||
let next = 0
|
||||
const workers = Array.from({ length: Math.min(limit, thunks.length) }, async () => {
|
||||
while (next < thunks.length) {
|
||||
const idx = next++
|
||||
await thunks[idx]()
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
async function fetchOneQuota(pool, id) {
|
||||
try { return (await api(`/accounts/${pool}/${id}/quota`)).data || {} }
|
||||
catch (e) { return { error: String(e) } }
|
||||
}
|
||||
async function fetchOneEmail(pool, id) {
|
||||
try { return (await api(`/accounts/${pool}/${id}/email`)).data || {} }
|
||||
catch (e) { return { error: String(e) } }
|
||||
}
|
||||
|
||||
function applyQuota(row, result) {
|
||||
// A transient probe error (e.g. connection reset when OpenAI is unreachable
|
||||
// without a proxy) must NOT blank the cached number — keep the last-known
|
||||
// value so a network blip doesn't turn the whole column into "—".
|
||||
if (result.error) { row._quotaError = result.error; return }
|
||||
row._quotaError = null
|
||||
if (result.unknown && result.remaining === null) { row.remaining = null; row._unknown = true; return }
|
||||
row._unknown = false
|
||||
row.remaining = result.remaining
|
||||
row.reset_after = result.reset_after
|
||||
}
|
||||
|
||||
async function toggleAccountStatus(pool, id, current) {
|
||||
const row = rows.value.find((r) => r.pool === pool && r.id === id)
|
||||
const next = current === 'active' ? 'disabled' : 'active'
|
||||
// Optimistic: flip the switch instantly so the UI never waits on the network.
|
||||
// The PATCH itself is a cheap in-memory update server-side; the old 5s lag came
|
||||
// from the follow-up loadAccounts() → reconcile() probing every account's quota.
|
||||
if (row) row.status = next
|
||||
try {
|
||||
const r = await api(`/tokens/${pool}/${id}`, jsonBody('PATCH', { status: next }))
|
||||
if (!r.ok && row) row.status = current // revert on server rejection
|
||||
} catch (e) {
|
||||
if (row) row.status = current // revert on network error
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount(pool, id) {
|
||||
if (!confirm(`确认删除 ${pool} / ${id}?`)) return
|
||||
await api(`/tokens/${pool}/${id}`, { method: 'DELETE' })
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// 一键删除全部异常(已失效/红色锁定)账号。逐个走与单删相同的 DELETE 接口。
|
||||
async function deleteDeadAccounts() {
|
||||
const dead = rows.value.filter((r) => r.dead)
|
||||
if (!dead.length) return
|
||||
if (!confirm(`确认删除全部 ${dead.length} 个异常(已失效)账号?此操作不可撤销。`)) return
|
||||
await Promise.all(dead.map((r) => api(`/tokens/${r.pool}/${r.id}`, { method: 'DELETE' })))
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// ===== 多选删除 =====
|
||||
const selected = ref(new Set())
|
||||
function toggleSelect(id) {
|
||||
const s = new Set(selected.value)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
selected.value = s
|
||||
}
|
||||
// Header checkbox controls the whole filtered set (not just the visible page).
|
||||
const allSelected = computed(() =>
|
||||
filtered.value.length > 0 && filtered.value.every((a) => selected.value.has(a.id)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) filtered.value.forEach((a) => s.delete(a.id))
|
||||
else filtered.value.forEach((a) => s.add(a.id))
|
||||
selected.value = s
|
||||
}
|
||||
async function deleteSelected() {
|
||||
const ids = [...selected.value]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`确认删除选中的 ${ids.length} 个账号?此操作不可撤销。`)) return
|
||||
const r = await api('/tokens/delete-bulk', jsonBody('POST', { ids }))
|
||||
if (r.ok) {
|
||||
selected.value = new Set()
|
||||
loadAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAccounts)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — 每个类型显示 成功/失败/限额 三个数(绿/红/琥珀) -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-7 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/45">账号总数</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5">成功/失败/限额</div>
|
||||
</div>
|
||||
<div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-violet-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80']]"
|
||||
:key="t[0]" class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider" :class="t[2]">{{ t[1] }}</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">
|
||||
<span class="text-emerald-300">{{ stats[t[0]].ok }}</span><span class="text-white/30">/</span><span class="text-rose-300">{{ stats[t[0]].dead }}</span><span class="text-white/30">/</span><span class="text-amber-300">{{ stats[t[0]].quota }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5">共 {{ stats[t[0]].n }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button>
|
||||
<button @click="setFilter(() => typeFilter = 'openai')" class="fp" :class="typeFilter === 'openai' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>OpenAI
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'adobe')" class="fp" :class="typeFilter === 'adobe' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>Adobe
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'runway')" class="fp" :class="typeFilter === 'runway' && 'fp-violet'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-violet-400"></span>Runway
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'leonardo')" class="fp" :class="typeFilter === 'leonardo' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>Leonardo
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'krea')" class="fp" :class="typeFilter === 'krea' && 'fp-sky'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-sky-400"></span>Krea
|
||||
</button>
|
||||
<button @click="setFilter(() => typeFilter = 'imagine')" class="fp" :class="typeFilter === 'imagine' && 'fp-teal'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>Imagine
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'quota')" class="fp" :class="statusFilter === 'quota' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>额度耗尽
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>已禁用
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / ID / 类型…" />
|
||||
</div>
|
||||
<button v-if="selected.size" @click="deleteSelected" class="btn-soft danger" title="删除选中的账号">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
|
||||
</button>
|
||||
<button v-if="deadCount" @click="deleteDeadAccounts" class="btn-soft danger" title="删除全部已失效(401)账号">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除异常账号 ({{ deadCount }})
|
||||
</button>
|
||||
<button @click="loadAccounts" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button @click="showImport = true" class="btn-primary">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 导入账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !rows.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||
<Icon name="accounts" class="w-6 h-6" />
|
||||
</span>
|
||||
<span class="text-sm">{{ rows.length ? '没有匹配的账号' : '还没有账号' }}</span>
|
||||
<button v-if="!rows.length" @click="showImport = true" class="btn-soft mt-1">导入第一个</button>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-9" /> <!-- select -->
|
||||
<col /> <!-- identity (flex) -->
|
||||
<col class="w-20" /> <!-- type -->
|
||||
<col class="w-24" /> <!-- remaining -->
|
||||
<col class="w-32" /> <!-- reset -->
|
||||
<col class="w-28" /> <!-- created -->
|
||||
<col class="w-28" /> <!-- last used -->
|
||||
<col class="w-40" /> <!-- inflight/success/fail -->
|
||||
<col class="w-16" /> <!-- status switch -->
|
||||
<col class="w-16" /> <!-- actions -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-center px-3 py-3 font-medium">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
||||
class="chk" title="全选" />
|
||||
</th>
|
||||
<th class="text-left px-5 py-3 font-medium">账户</th>
|
||||
<th class="text-left px-3 py-3 font-medium">类型</th>
|
||||
<th class="text-right px-3 py-3 font-medium">额度</th>
|
||||
<th class="text-left px-3 py-3 font-medium">恢复时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">创建时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">最后使用</th>
|
||||
<th class="text-center px-3 py-3 font-medium">在途 / 成功 / 失败</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-right px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in pagedItems" :key="a.pool + '/' + a.id"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"
|
||||
:class="a.dead && 'dead-row'">
|
||||
<!-- select -->
|
||||
<td class="px-3 py-3.5 align-middle text-center">
|
||||
<input type="checkbox" :checked="selected.has(a.id)" @change="toggleSelect(a.id)" @click.stop
|
||||
class="chk" />
|
||||
</td>
|
||||
<!-- identity -->
|
||||
<td class="px-5 py-3.5 align-middle">
|
||||
<!-- email + per-kind quota markers on one line. Both-limited shows as
|
||||
额度耗尽 in the status column, so here we only surface the single
|
||||
case. -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-white/90 truncate" :title="a.email || '-'">{{ a.email || '-' }}</span>
|
||||
<span v-if="a.team_id"
|
||||
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-mono bg-violet-500/15 text-violet-300 ring-1 ring-violet-400/20"
|
||||
:title="'Runway team_id ' + a.team_id">{{ a.team_id }}</span>
|
||||
<span v-if="a.image_limited && a.status !== 'quota'"
|
||||
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20"
|
||||
title="图片额度耗尽,仅视频可用">图片限额</span>
|
||||
<span v-if="a.video_limited && a.status !== 'quota'"
|
||||
class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/15 text-amber-300 ring-1 ring-amber-400/20"
|
||||
title="视频额度耗尽,仅图片可用">视频限额</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- type -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
|
||||
:class="typePill(a.type)">{{ a.type }}</span>
|
||||
</td>
|
||||
<!-- remaining -->
|
||||
<td class="px-3 py-3.5 align-middle text-right text-sm tabular-nums whitespace-nowrap">
|
||||
<!-- quota column: 数字 / — (never "未知"/"失败"/"检测中") -->
|
||||
<!-- remaining === -1 is the provider "unlimited" sentinel → show — not a scary red -1 -->
|
||||
<span v-if="(a.type === 'openai' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine') && a.remaining != null && a.remaining !== -1"
|
||||
class="font-mono font-semibold"
|
||||
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}</span>
|
||||
<span v-else class="text-white/25" :title="a._quotaError || ''">—</span>
|
||||
</td>
|
||||
<!-- reset_after -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="a.reset_after" class="leading-tight" :title="fmtIso(a.reset_after)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.reset_after) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.reset_after) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">—</span>
|
||||
</td>
|
||||
<!-- created_at -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div class="leading-tight" :title="fmtTs(a.created_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.created_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.created_at) }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- last_used_at -->
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="a.last_used_at" class="leading-tight" :title="fmtTs(a.last_used_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(a.last_used_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(a.last_used_at) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">从未</span>
|
||||
</td>
|
||||
<!-- inflight / success / fail -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<div class="flex items-center justify-center gap-1.5 text-xs tabular-nums">
|
||||
<span class="px-1.5 py-0.5 rounded"
|
||||
:class="a.in_flight ? 'bg-indigo-500/15 text-indigo-300 font-semibold' : 'text-white/25'"
|
||||
title="在途">{{ a.in_flight || 0 }}</span>
|
||||
<span class="text-white/20">/</span>
|
||||
<span class="px-1.5 py-0.5 rounded text-emerald-300 font-medium" title="成功">{{ a.success_total || 0 }}</span>
|
||||
<span class="text-white/20">/</span>
|
||||
<span class="px-1.5 py-0.5 rounded"
|
||||
:class="a.fail_total ? 'bg-rose-500/15 text-rose-300 font-medium' : 'text-white/25'"
|
||||
title="失败">{{ a.fail_total || 0 }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- status (switch) -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<button class="sw"
|
||||
:class="{ 'sw-on': a.status === 'active', 'sw-dead': a.dead, 'sw-pending': a.status === 'pending', 'sw-quota': a.status === 'quota', 'sw-locked': a.dead || a.status === 'pending' || a.status === 'quota' }"
|
||||
:disabled="a.dead || a.status === 'pending' || a.status === 'quota'"
|
||||
:aria-pressed="a.status === 'active'"
|
||||
:title="a.status === 'pending' ? '正在检测额度…(暂不调度)' : (a.dead ? '号已失效(401) · 已锁定(删除后重新导入有效令牌)' : (a.status === 'quota' ? '额度耗尽 · 已锁定,到恢复时间自动解开' : (a.status === 'active' ? '点击禁用' : '点击启用')))"
|
||||
@click="!(a.dead || a.status === 'pending' || a.status === 'quota') && toggleAccountStatus(a.pool, a.id, a.status)">
|
||||
<span class="sw-thumb"></span>
|
||||
</button>
|
||||
</td>
|
||||
<!-- actions -->
|
||||
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
||||
<button @click="deleteAccount(a.pool, a.id)" class="act danger" title="删除">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
||||
<div>
|
||||
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
||||
<span class="ml-1">/ {{ filtered.length }} 条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-white/35">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImportModal v-if="showImport" @close="showImport = false" @imported="loadAccounts" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* --- filter pills (mirrors LogsView/UsersView/ModelsView) */
|
||||
.fp {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem; font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-rose {
|
||||
background: rgb(244 63 94 / 0.22);
|
||||
color: rgb(253 164 175);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
|
||||
}
|
||||
.fp-amber {
|
||||
background: rgb(245 158 11 / 0.22);
|
||||
color: rgb(253 224 71);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 224 71 / 0.4);
|
||||
}
|
||||
.fp-violet {
|
||||
background: rgb(139 92 246 / 0.22);
|
||||
color: rgb(196 181 253);
|
||||
box-shadow: inset 0 0 0 1px rgb(196 181 253 / 0.45);
|
||||
}
|
||||
.fp-sky {
|
||||
background: rgb(56 189 248 / 0.22);
|
||||
color: rgb(125 211 252);
|
||||
box-shadow: inset 0 0 0 1px rgb(125 211 252 / 0.45);
|
||||
}
|
||||
.fp-teal {
|
||||
background: rgb(20 184 166 / 0.22);
|
||||
color: rgb(94 234 212);
|
||||
box-shadow: inset 0 0 0 1px rgb(94 234 212 / 0.45);
|
||||
}
|
||||
|
||||
/* --- icon-only action buttons */
|
||||
.act {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.9rem; height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
/* toolbar 「删除异常账号」按钮 — rose 变体 */
|
||||
.btn-soft.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.btn-soft.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
/* iOS-style switch (mirrors UsersView/ModelsView) */
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 2.25rem; height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.sw-thumb {
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw-on {
|
||||
background: rgb(16 185 129 / 0.7);
|
||||
box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5);
|
||||
}
|
||||
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
/* dead account (401) — red, thumb stays left */
|
||||
.sw-dead {
|
||||
background: rgb(244 63 94 / 0.8);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.6);
|
||||
}
|
||||
/* pending (import quota probe in flight) — neutral indigo, thumb stays left */
|
||||
.sw-pending {
|
||||
background: rgb(99 102 241 / 0.45);
|
||||
box-shadow: inset 0 0 0 1px rgb(99 102 241 / 0.4);
|
||||
}
|
||||
/* quota exhausted — amber (NOT red/dead), thumb stays left, locked until reset */
|
||||
.sw-quota {
|
||||
background: rgb(245 158 11 / 0.5);
|
||||
box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.45);
|
||||
}
|
||||
/* dead / pending toggle is locked — can't be flipped */
|
||||
.sw-locked { cursor: not-allowed; }
|
||||
/* tint the whole row so a dead account is obvious at a glance */
|
||||
.dead-row { background: rgb(244 63 94 / 0.07); }
|
||||
.dead-row:hover { background: rgb(244 63 94 / 0.12); }
|
||||
|
||||
/* --- numbered pagination buttons */
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,383 @@
|
||||
<script setup>
|
||||
// Admin CDK (redeem code) management — generate fixed-amount codes, list,
|
||||
// copy, delete. Amounts are 积分.
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { fmtTs } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const items = ref([])
|
||||
const stats = ref({ total: 0, active: 0, redeemed: 0, active_amount: 0, redeemed_amount: 0 })
|
||||
const loading = ref(false)
|
||||
|
||||
// filters
|
||||
const statusFilter = ref('') // '' | 'active'(未使用) | 'used'(已使用)
|
||||
const typeFilter = ref('') // '' | 'normal' | 'marketing'
|
||||
const search = ref('')
|
||||
function setFilter(fn) { fn(); page.value = 1 }
|
||||
watch(search, () => { page.value = 1 })
|
||||
const filtered = computed(() => {
|
||||
let list = items.value
|
||||
if (statusFilter.value === 'active') list = list.filter((c) => c.status === 'active')
|
||||
else if (statusFilter.value === 'used') list = list.filter((c) => c.status !== 'active')
|
||||
if (typeFilter.value === 'marketing') list = list.filter((c) => c.type === 'marketing')
|
||||
else if (typeFilter.value === 'normal') list = list.filter((c) => c.type !== 'marketing')
|
||||
const q = search.value.trim().toUpperCase()
|
||||
if (q) list = list.filter((c) => (c.code || '').toUpperCase().includes(q))
|
||||
return list
|
||||
})
|
||||
|
||||
const form = ref({ amount: 5000, count: 10, type: 'normal' })
|
||||
const lastBatch = ref([]) // codes from the most recent generate
|
||||
const flashMsg = ref('')
|
||||
let flashTimer = null
|
||||
function flash(m) { flashMsg.value = m; clearTimeout(flashTimer); flashTimer = setTimeout(() => (flashMsg.value = ''), 2000) }
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const r = await api('/cdks')
|
||||
loading.value = false
|
||||
if (r.ok) { items.value = r.data?.data || []; stats.value = r.data?.stats || stats.value }
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
async function generate() {
|
||||
const amount = Number(form.value.amount), count = Number(form.value.count)
|
||||
if (!amount || amount <= 0) { flash('金额必须大于 0'); return }
|
||||
if (!count || count <= 0) { flash('数量必须大于 0'); return }
|
||||
const r = await api('/cdks', jsonBody('POST', { amount, count, type: form.value.type }))
|
||||
if (!r.ok) { flash(r.data?.detail || '生成失败'); return }
|
||||
lastBatch.value = (r.data?.created || []).map((c) => c.code)
|
||||
flash(`已生成 ${lastBatch.value.length} 个兑换码`)
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function del(code) {
|
||||
if (!confirm(`删除兑换码 ${code}?`)) return
|
||||
const r = await api(`/cdks/${code}`, { method: 'DELETE' })
|
||||
if (r.ok) {
|
||||
flash('已删除')
|
||||
await load()
|
||||
// deleting the last row on the last page would otherwise strand us on an
|
||||
// empty page past the end — clamp back into range.
|
||||
if (page.value > totalPages.value) page.value = totalPages.value
|
||||
} else flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
|
||||
// ===== 多选删除 =====
|
||||
const selected = ref(new Set())
|
||||
function toggleSelect(code) {
|
||||
const s = new Set(selected.value)
|
||||
s.has(code) ? s.delete(code) : s.add(code)
|
||||
selected.value = s
|
||||
}
|
||||
const allSelected = computed(() =>
|
||||
filtered.value.length > 0 && filtered.value.every((c) => selected.value.has(c.code)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) filtered.value.forEach((c) => s.delete(c.code))
|
||||
else filtered.value.forEach((c) => s.add(c.code))
|
||||
selected.value = s
|
||||
}
|
||||
async function delSelected() {
|
||||
const codes = [...selected.value]
|
||||
if (!codes.length) return
|
||||
if (!confirm(`确认删除选中的 ${codes.length} 个兑换码?此操作不可撤销。`)) return
|
||||
const r = await api('/cdks/delete-bulk', jsonBody('POST', { codes }))
|
||||
if (r.ok) {
|
||||
flash(`已删除 ${r.data?.deleted ?? codes.length} 个`)
|
||||
selected.value = new Set()
|
||||
await load()
|
||||
if (page.value > totalPages.value) page.value = totalPages.value
|
||||
} else flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
try { await navigator.clipboard.writeText(text); flash('已复制') } catch { flash('复制失败') }
|
||||
}
|
||||
function copyBatch() { copy(lastBatch.value.join('\n')) }
|
||||
|
||||
// Client-side pagination over the full list (CDK volumes are bounded by
|
||||
// how many the admin generates — comfortably small).
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filtered.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target !== page.value) page.value = target
|
||||
}
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — same shape as LogsView / InvitesAdminView -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/45">总数</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">未使用</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.active }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/35">已使用</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-white/60">{{ stats.redeemed }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">未使用面额</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ Number(stats.active_amount || 0).toLocaleString('en-US') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- generate -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">生成兑换码</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">单个金额 (积分)</label>
|
||||
<input v-model.number="form.amount" type="number" min="1" step="1" class="field w-40" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">数量</label>
|
||||
<input v-model.number="form.count" type="number" min="1" max="500" step="1" class="field w-28" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">类型</label>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" @click="form.type = 'normal'" class="fp" :class="form.type === 'normal' && 'fp-on'">普通</button>
|
||||
<button type="button" @click="form.type = 'marketing'" class="fp" :class="form.type === 'marketing' && 'fp-fuchsia'">营销</button>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="generate" class="btn-primary"><Icon name="plus" class="w-3.5 h-3.5" /> 生成</button>
|
||||
</div>
|
||||
<p v-if="form.type === 'marketing'" class="text-[11px] text-fuchsia-300/80 mt-2">营销兑换码:同一批次每个用户只能兑换一次。</p>
|
||||
|
||||
<!-- last batch -->
|
||||
<div v-if="lastBatch.length" class="mt-4 rounded-xl bg-white/[0.04] ring-1 ring-white/10 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-white/75">刚生成 {{ lastBatch.length }} 个 — 请复制保存</span>
|
||||
<button @click="copyBatch" class="text-xs btn-soft"><Icon name="copy" class="w-3.5 h-3.5" /> 全部复制</button>
|
||||
</div>
|
||||
<div class="font-mono text-xs text-white/85 space-y-0.5 max-h-40 overflow-auto">
|
||||
<div v-for="code in lastBatch" :key="code">{{ code }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button>
|
||||
<button @click="setFilter(() => typeFilter = 'normal')" class="fp" :class="typeFilter === 'normal' && 'fp-on'">普通</button>
|
||||
<button @click="setFilter(() => typeFilter = 'marketing')" class="fp" :class="typeFilter === 'marketing' && 'fp-fuchsia'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span>营销
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>未使用
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'used')" class="fp" :class="statusFilter === 'used' && 'fp-on'">已使用</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[160px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索兑换码…" />
|
||||
</div>
|
||||
<button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的兑换码">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
|
||||
</button>
|
||||
<button @click="load" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
|
||||
<div v-else-if="!items.length" class="text-center text-sm text-white/40 py-16">还没有兑换码</div>
|
||||
<div v-else-if="!filtered.length" class="text-center text-sm text-white/40 py-16">没有匹配的兑换码</div>
|
||||
<table v-else class="w-full text-sm">
|
||||
<colgroup>
|
||||
<col class="w-9" />
|
||||
<col />
|
||||
<col class="w-28" />
|
||||
<col class="w-28" />
|
||||
<col class="w-44" />
|
||||
<col class="w-44" />
|
||||
<col class="w-40" />
|
||||
<col class="w-20" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-center px-3 py-3 font-medium">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
||||
class="chk" title="全选" />
|
||||
</th>
|
||||
<th class="text-left px-5 py-3 font-medium">兑换码</th>
|
||||
<th class="text-right px-3 py-3 font-medium">金额</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-left px-3 py-3 font-medium">创建时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">使用时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">使用者</th>
|
||||
<th class="text-right px-5 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in pagedItems" :key="c.code"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<td class="px-3 py-3.5 align-middle text-center">
|
||||
<input type="checkbox" :checked="selected.has(c.code)" @change="toggleSelect(c.code)" @click.stop
|
||||
class="chk" />
|
||||
</td>
|
||||
<td class="px-5 py-3.5 align-middle font-mono text-xs text-white/90 truncate" :title="c.code">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="truncate">{{ c.code }}</span>
|
||||
<span v-if="c.type === 'marketing'" class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-sans font-medium bg-fuchsia-500/15 text-fuchsia-300 ring-1 ring-fuchsia-400/25">营销</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85 whitespace-nowrap">
|
||||
{{ Number(c.amount).toLocaleString('en-US') }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="c.status === 'active'
|
||||
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||
: 'bg-white/[0.06] text-white/55 ring-white/15'">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
:class="c.status === 'active' ? 'bg-emerald-400' : 'bg-slate-400'"></span>
|
||||
{{ c.status === 'active' ? '未使用' : '已使用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">{{ fmtTs(c.created_at) }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">{{ c.redeemed_at ? fmtTs(c.redeemed_at) : '—' }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 truncate">{{ c.redeemed_by_name || '—' }}</td>
|
||||
<td class="px-5 py-3.5 align-middle text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button @click="copy(c.code)" class="act" title="复制"><Icon name="copy" class="w-3.5 h-3.5" /></button>
|
||||
<button @click="del(c.code)" class="act danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
||||
<div>
|
||||
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(items.length, page * pageSize) }}</span>
|
||||
<span class="ml-1">/ {{ items.length }} 条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-white/35">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="flashMsg" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-slate-900 text-white text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">{{ flashMsg }}</div>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.btn-soft.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.btn-soft.danger:hover {
|
||||
color: white;
|
||||
background: rgb(244 63 94 / 0.25);
|
||||
}
|
||||
|
||||
/* row icon action buttons — mirror 账号管理 for a consistent look */
|
||||
.act {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.9rem; height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
.fp {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem; font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-fuchsia {
|
||||
background: rgb(217 70 239 / 0.22);
|
||||
color: rgb(245 208 254);
|
||||
box-shadow: inset 0 0 0 1px rgb(245 208 254 / 0.45);
|
||||
}
|
||||
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,509 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { site } from '../site'
|
||||
import TagInput from '../components/TagInput.vue'
|
||||
|
||||
// ---- logs (retention window) ----
|
||||
const logsCfg = reactive({ retention_days: 30 })
|
||||
const logsBusy = ref(false); const logsSaved = ref(false)
|
||||
async function loadLogs() {
|
||||
const r = await api('/settings/logs')
|
||||
if (r.ok && r.data) logsCfg.retention_days = Number(r.data.retention_days) || 30
|
||||
}
|
||||
async function saveLogs() {
|
||||
logsBusy.value = true; logsSaved.value = false
|
||||
const r = await api('/settings/logs', jsonBody('PUT', { retention_days: Number(logsCfg.retention_days) || 30 }))
|
||||
logsBusy.value = false
|
||||
if (r.ok) { logsSaved.value = true; setTimeout(() => (logsSaved.value = false), 2000) }
|
||||
}
|
||||
|
||||
// ---- media (生成图片/视频文件留存) ----
|
||||
const mediaCfg = reactive({ retention_days: 30 })
|
||||
const mediaBusy = ref(false); const mediaSaved = ref(false); const mediaRemoved = ref(0)
|
||||
async function loadMedia() {
|
||||
const r = await api('/settings/media')
|
||||
if (r.ok && r.data) mediaCfg.retention_days = Number(r.data.retention_days) || 30
|
||||
}
|
||||
async function saveMedia() {
|
||||
mediaBusy.value = true; mediaSaved.value = false; mediaRemoved.value = 0
|
||||
const r = await api('/settings/media', jsonBody('PUT', { retention_days: Number(mediaCfg.retention_days) || 30 }))
|
||||
mediaBusy.value = false
|
||||
if (r.ok) {
|
||||
mediaSaved.value = true
|
||||
mediaRemoved.value = Number(r.data?.removed || 0)
|
||||
setTimeout(() => (mediaSaved.value = false), 2500)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- site (branding shown across the app) ----
|
||||
const siteForm = reactive({ title: '', qq: '', qq_link: '', qq_group: '', qq_group_link: '', email: '', shop: '' })
|
||||
const siteBusy = ref(false); const siteSaved = ref(false)
|
||||
async function loadSite() {
|
||||
const r = await api('/settings/site')
|
||||
if (r.ok && r.data) {
|
||||
siteForm.title = r.data.title || ''
|
||||
const c = r.data.contact || {}
|
||||
siteForm.qq = c.qq || ''; siteForm.qq_link = c.qq_link || ''
|
||||
siteForm.qq_group = c.qq_group || ''
|
||||
siteForm.qq_group_link = c.qq_group_link || ''
|
||||
siteForm.email = c.email || ''; siteForm.shop = c.shop || ''
|
||||
}
|
||||
}
|
||||
async function saveSite() {
|
||||
siteBusy.value = true; siteSaved.value = false
|
||||
const r = await api('/settings/site', jsonBody('PUT', {
|
||||
title: siteForm.title,
|
||||
contact: { qq: siteForm.qq, qq_link: siteForm.qq_link, qq_group: siteForm.qq_group, qq_group_link: siteForm.qq_group_link, email: siteForm.email, shop: siteForm.shop },
|
||||
}))
|
||||
siteBusy.value = false
|
||||
if (r.ok && r.data) {
|
||||
// Mirror the change into the shared `site` store so every header /
|
||||
// wordmark / tab title updates without a reload. The PUT response is
|
||||
// nested ({ ok, data: { title } }) unlike the flat GET, so read the
|
||||
// saved value from there — falling back to the input we just submitted.
|
||||
site.title = r.data.data?.title || siteForm.title.trim()
|
||||
site.contact = r.data.data?.contact || site.contact
|
||||
siteSaved.value = true
|
||||
setTimeout(() => (siteSaved.value = false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- registration ----
|
||||
const reg = reactive({ open: true, email_code: false, allow_password_reset: true })
|
||||
// Email domain whitelist edited as tag chips so admins can't typo a comma
|
||||
// out of a domain or accidentally leave a stray space.
|
||||
const domains = ref([])
|
||||
const regBusy = ref(false); const regSaved = ref(false)
|
||||
|
||||
// ---- smtp ----
|
||||
const smtp = reactive({ host: '', port: 587, username: '', password: '', from_addr: '', use_tls: true })
|
||||
const smtpBusy = ref(false); const smtpSaved = ref(false)
|
||||
|
||||
// ---- rewards ----
|
||||
const credits = reactive({ checkin_enabled: true, checkin_reward: 3, invite_enabled: true, invite_reward: 3 })
|
||||
const credBusy = ref(false); const credSaved = ref(false)
|
||||
|
||||
// ---- proxy (carried when calling upstream during generation) ----
|
||||
const proxy = reactive({ proxy: '' })
|
||||
const proxyBusy = ref(false); const proxySaved = ref(false)
|
||||
async function loadProxy() {
|
||||
const r = await api('/settings/proxy')
|
||||
if (r.ok && r.data) proxy.proxy = r.data.proxy || ''
|
||||
}
|
||||
async function saveProxy() {
|
||||
proxyBusy.value = true; proxySaved.value = false
|
||||
const r = await api('/settings/proxy', jsonBody('PUT', { proxy: proxy.proxy }))
|
||||
proxyBusy.value = false
|
||||
if (r.ok) { proxySaved.value = true; setTimeout(() => (proxySaved.value = false), 2000) }
|
||||
}
|
||||
// Probe the currently-entered proxy (not necessarily saved) — surfaces the
|
||||
// egress IP on success, or the concrete dial/DNS error on failure.
|
||||
const proxyTestBusy = ref(false)
|
||||
const proxyTest = reactive({ ok: null, msg: '' }) // ok: null=idle, true/false=result
|
||||
async function testProxy() {
|
||||
proxyTestBusy.value = true; proxyTest.ok = null; proxyTest.msg = ''
|
||||
const r = await api('/settings/proxy/test', jsonBody('POST', { proxy: proxy.proxy }))
|
||||
proxyTestBusy.value = false
|
||||
if (r.ok && r.data?.ok) {
|
||||
proxyTest.ok = true
|
||||
const ip = r.data.data?.exit_ip || '未知'
|
||||
const ms = r.data.data?.elapsed_ms
|
||||
proxyTest.msg = `连接成功 · 出口 IP ${ip}${ms != null ? ` · ${ms}ms` : ''}`
|
||||
} else {
|
||||
proxyTest.ok = false
|
||||
proxyTest.msg = r.data?.detail || '代理测试失败'
|
||||
}
|
||||
}
|
||||
|
||||
// Email-code requires SMTP to be configured (host saved) first.
|
||||
const smtpConfigured = computed(() => !!(smtp.host || '').trim())
|
||||
|
||||
// SMTP save requires the four required fields to be non-empty. Password is
|
||||
// optional on update — leaving it blank means "keep the current one".
|
||||
const smtpReady = computed(() =>
|
||||
(smtp.host || '').trim() &&
|
||||
Number(smtp.port) > 0 &&
|
||||
(smtp.username || '').trim() &&
|
||||
(smtp.from_addr || '').trim()
|
||||
)
|
||||
|
||||
async function loadReg() {
|
||||
const r = await api('/settings/registration')
|
||||
if (r.ok && r.data) {
|
||||
Object.assign(reg, { open: r.data.open, email_code: r.data.email_code,
|
||||
allow_password_reset: r.data.allow_password_reset })
|
||||
// Normalise to lowercase + strip a leading @ on each so the chips render
|
||||
// exactly what the server will compare against.
|
||||
domains.value = (r.data.allowed_email_domains || [])
|
||||
.map((d) => String(d).trim().toLowerCase().replace(/^@/, ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
async function loadSmtp() {
|
||||
const r = await api('/settings/smtp')
|
||||
if (r.ok && r.data) Object.assign(smtp, r.data)
|
||||
}
|
||||
async function loadCredits() {
|
||||
const r = await api('/settings/credits')
|
||||
if (r.ok && r.data) Object.assign(credits, r.data)
|
||||
}
|
||||
|
||||
async function saveReg() {
|
||||
regBusy.value = true; regSaved.value = false
|
||||
const r = await api('/settings/registration', jsonBody('PUT', {
|
||||
open: reg.open, email_code: reg.email_code,
|
||||
allow_password_reset: reg.allow_password_reset,
|
||||
allowed_email_domains: domains.value,
|
||||
}))
|
||||
regBusy.value = false
|
||||
if (r.ok) { regSaved.value = true; setTimeout(() => (regSaved.value = false), 2000); loadReg() }
|
||||
}
|
||||
async function saveSmtp() {
|
||||
smtpBusy.value = true; smtpSaved.value = false
|
||||
const payload = { host: smtp.host, port: Number(smtp.port) || 587, username: smtp.username,
|
||||
from_addr: smtp.from_addr, use_tls: smtp.use_tls }
|
||||
if (smtp.password && smtp.password !== '***') payload.password = smtp.password
|
||||
const r = await api('/settings/smtp', jsonBody('PUT', payload))
|
||||
smtpBusy.value = false
|
||||
if (r.ok) { smtpSaved.value = true; setTimeout(() => (smtpSaved.value = false), 2000); loadSmtp() }
|
||||
}
|
||||
|
||||
// ---- SMTP test send ----
|
||||
const testEmail = ref('')
|
||||
const testBusy = ref(false)
|
||||
const testMsg = ref('') // result message (success or error)
|
||||
const testOk = ref(false)
|
||||
async function sendTest() {
|
||||
const to = (testEmail.value || '').trim()
|
||||
if (!to || !to.includes('@')) { testMsg.value = '请填写有效的收件邮箱'; testOk.value = false; return }
|
||||
testBusy.value = true; testMsg.value = ''
|
||||
// Tests the SAVED config, so save first if you just edited the fields.
|
||||
const r = await api('/settings/smtp/test', jsonBody('POST', { email: to }))
|
||||
testBusy.value = false
|
||||
testOk.value = r.ok
|
||||
testMsg.value = r.ok ? (r.data?.detail || `测试邮件已发送至 ${to}`) : (r.data?.detail || `发送失败 (${r.status})`)
|
||||
}
|
||||
async function saveCredits() {
|
||||
credBusy.value = true; credSaved.value = false
|
||||
const r = await api('/settings/credits', jsonBody('PUT', {
|
||||
checkin_enabled: credits.checkin_enabled,
|
||||
checkin_reward: Number(credits.checkin_reward) || 0,
|
||||
invite_enabled: credits.invite_enabled,
|
||||
invite_reward: Number(credits.invite_reward) || 0,
|
||||
}))
|
||||
credBusy.value = false
|
||||
if (r.ok) { credSaved.value = true; setTimeout(() => (credSaved.value = false), 2000) }
|
||||
}
|
||||
|
||||
onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadProxy(); loadLogs(); loadMedia() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<!-- site -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">网站</h2>
|
||||
<span v-if="siteSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="row">
|
||||
<span><span class="lbl">网页主标题</span><span class="hint">显示在浏览器标签、首页 Logo、侧栏和登录卡上。未设置时默认显示 "Vivid"。</span></span>
|
||||
<input v-model="siteForm.title" placeholder="Vivid" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">联系 QQ</span><span class="hint">QQ 号(显示用)。留空则不显示该项。</span></span>
|
||||
<input v-model="siteForm.qq" placeholder="1114639355" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">QQ 链接</span><span class="hint">加好友链接(qm.qq.com/...)。填了则「关于」里的 QQ 可点击、新标签打开。</span></span>
|
||||
<input v-model="siteForm.qq_link" placeholder="https://qm.qq.com/q/ItgCcNA7ac" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">QQ 群</span><span class="hint">交流群号(显示用)。留空则不显示。</span></span>
|
||||
<input v-model="siteForm.qq_group" placeholder="1106849765" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">QQ 群链接</span><span class="hint">加群链接(qm.qq.com/...)。填了则「关于」里的 QQ 群可点击、新标签打开。</span></span>
|
||||
<input v-model="siteForm.qq_group_link" placeholder="https://qm.qq.com/q/976LeMFoHu" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">联系邮箱</span><span class="hint">首页「联系我们」里可点击发邮件。留空则不显示。</span></span>
|
||||
<input v-model="siteForm.email" placeholder="vividairun@gmail.com" class="txt" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">商店地址</span><span class="hint">充值/购买页链接,首页「联系我们」里展示为"前往充值商店"。留空则不显示。</span></span>
|
||||
<input v-model="siteForm.shop" placeholder="https://pay.ldxp.cn/shop/chiyi" class="txt" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button @click="saveSite" :disabled="siteBusy || !siteForm.title.trim()" class="btn-primary">{{ siteBusy ? '保存中…' : '保存设置' }}</button>
|
||||
<span v-if="!siteForm.title.trim()" class="text-xs text-slate-400">请输入主标题</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- registration -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">注册与登录</h2>
|
||||
<span v-if="regSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="row">
|
||||
<span><span class="lbl">开放注册</span><span class="hint">关闭后只能由管理员手动创建账号(首个账号不受限)。</span></span>
|
||||
<input type="checkbox" v-model="reg.open" class="sw" />
|
||||
</label>
|
||||
<label class="row" :class="!smtpConfigured && 'opacity-50'">
|
||||
<span><span class="lbl">注册/找回需要邮箱验证码</span><span class="hint">启用后注册、找回密码需输入邮件发送的验证码。<b v-if="!smtpConfigured">需先在下方配置并保存 SMTP 才能开启。</b></span></span>
|
||||
<input type="checkbox" v-model="reg.email_code" :disabled="!smtpConfigured" class="sw" />
|
||||
</label>
|
||||
<label class="row" :class="!reg.email_code && 'opacity-50'">
|
||||
<span><span class="lbl">支持找回密码</span><span class="hint">凭邮箱+邮件验证码重置。<b v-if="!reg.email_code">需先开启「邮箱验证码」才能启用。</b></span></span>
|
||||
<!-- When email_code is off, password reset is effectively disabled
|
||||
regardless of the stored flag (the auth endpoint gates it the
|
||||
same way). Reflect that by showing the switch off — but keep
|
||||
the stored value untouched so re-enabling email_code restores
|
||||
the user's prior preference. -->
|
||||
<input type="checkbox"
|
||||
:checked="reg.email_code && reg.allow_password_reset"
|
||||
:disabled="!reg.email_code"
|
||||
@change="reg.allow_password_reset = $event.target.checked"
|
||||
class="sw" />
|
||||
</label>
|
||||
<!-- Stack the tag input under the label so the chips have room -->
|
||||
<div class="row !block !border-b-0">
|
||||
<div class="mb-2">
|
||||
<span class="lbl">允许的邮箱后缀</span><br />
|
||||
<span class="hint">输入后缀按回车添加(如 gmail.com),点 × 删除。<b>留空 = 不限制</b>,允许任意域名注册。</span>
|
||||
</div>
|
||||
<TagInput v-model="domains" placeholder="留空 = 不限制,或输入后缀回车添加" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button @click="saveReg" :disabled="regBusy" class="btn-primary">{{ regBusy ? '保存中…' : '保存设置' }}</button>
|
||||
<span v-if="!domains.length" class="text-xs text-slate-400">未设置后缀 = 不限制邮箱域名</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMTP -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">邮件服务 (SMTP)</h2>
|
||||
<span v-if="smtpSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-4">用于发送注册 / 找回密码的验证码邮件。465 端口自动用 SSL,其余端口可选 STARTTLS。</p>
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div><label class="flbl">SMTP 主机</label><input v-model="smtp.host" placeholder="smtp.gmail.com" class="field" /></div>
|
||||
<div><label class="flbl">端口</label><input type="number" v-model.number="smtp.port" placeholder="587" class="field" /></div>
|
||||
<div><label class="flbl">用户名</label><input v-model="smtp.username" placeholder="you@gmail.com" class="field" /></div>
|
||||
<div><label class="flbl">密码 / 授权码</label><input v-model="smtp.password" type="password" placeholder="留空表示不修改" class="field" /></div>
|
||||
<div><label class="flbl">发件地址 (From)</label><input v-model="smtp.from_addr" placeholder="no-reply@yourdomain.com" class="field" /></div>
|
||||
</div>
|
||||
<!-- STARTTLS sits below the grid as its own labelled row, matching the
|
||||
layout of the toggle-style settings in 注册与登录 / 积分奖励 -->
|
||||
<label class="row mt-2">
|
||||
<span><span class="lbl">使用 STARTTLS</span><span class="hint">端口 587 等明文端口加密会话;465 端口已自动用 SSL,无需开启。</span></span>
|
||||
<input type="checkbox" v-model="smtp.use_tls" class="sw" />
|
||||
</label>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<button @click="saveSmtp" :disabled="smtpBusy || !smtpReady" class="btn-primary">{{ smtpBusy ? '保存中…' : '保存设置' }}</button>
|
||||
<span v-if="!smtpReady" class="text-xs text-slate-400">请填写 主机 / 端口 / 用户名 / 发件地址</span>
|
||||
</div>
|
||||
|
||||
<!-- Test send: verifies the SAVED config actually delivers mail -->
|
||||
<div class="mt-4 pt-4 border-t border-white/[0.06]">
|
||||
<label class="flbl">测试发送 <span class="text-slate-400 font-normal">(用已保存的配置发一封测试邮件验证)</span></label>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<input v-model="testEmail" type="email" placeholder="收件邮箱,如 you@example.com" class="field flex-1" @keyup.enter="sendTest" />
|
||||
<button @click="sendTest" :disabled="testBusy || !smtpConfigured" class="btn-soft whitespace-nowrap">
|
||||
{{ testBusy ? '发送中…' : '发送测试' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!smtpConfigured" class="text-xs text-slate-400 mt-1.5">请先保存 SMTP 配置后再测试。</p>
|
||||
<p v-else-if="testMsg" class="text-xs mt-1.5" :class="testOk ? 'text-emerald-300' : 'text-rose-300'">{{ testMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- proxy -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">代理 (生图请求)</h2>
|
||||
<span v-if="proxySaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mb-4">调用上游生成图片/视频时统一使用的 HTTP 代理,留空 = 直连。格式如 <code class="px-1 bg-slate-100 rounded">http://127.0.0.1:7890</code>。修改即时生效,无需重启。</p>
|
||||
<input v-model="proxy.proxy" placeholder="留空 = 直连,如 http://127.0.0.1:7890" class="field" />
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button @click="saveProxy" :disabled="proxyBusy" class="btn-primary">{{ proxyBusy ? '保存中…' : '保存设置' }}</button>
|
||||
<button @click="testProxy" :disabled="proxyTestBusy || !proxy.proxy.trim()" class="btn-ghost">{{ proxyTestBusy ? '测试中…' : '代理测试' }}</button>
|
||||
</div>
|
||||
<p v-if="proxyTest.msg" class="text-xs mt-2" :class="proxyTest.ok ? 'text-emerald-300' : 'text-rose-300'">{{ proxyTest.msg }}</p>
|
||||
</div>
|
||||
|
||||
<!-- rewards -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">积分奖励</h2>
|
||||
<span v-if="credSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="row">
|
||||
<span><span class="lbl">开启每日签到</span><span class="hint">关闭后用户无法签到领取积分。</span></span>
|
||||
<input type="checkbox" v-model="credits.checkin_enabled" class="sw" />
|
||||
</label>
|
||||
<label class="row" :class="!credits.checkin_enabled && 'opacity-50'">
|
||||
<span><span class="lbl">每日签到奖励</span><span class="hint">用户每天签到获得的积分。</span></span>
|
||||
<input type="number" min="0" v-model.number="credits.checkin_reward" :disabled="!credits.checkin_enabled" class="num" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span><span class="lbl">开启邀请奖励</span><span class="hint">关闭后邀请好友不再发放积分奖励。</span></span>
|
||||
<input type="checkbox" v-model="credits.invite_enabled" class="sw" />
|
||||
</label>
|
||||
<label class="row" :class="!credits.invite_enabled && 'opacity-50'">
|
||||
<span><span class="lbl">邀请奖励</span><span class="hint">被邀请好友首次生图后,邀请人获得的积分。</span></span>
|
||||
<input type="number" min="0" v-model.number="credits.invite_reward" :disabled="!credits.invite_enabled" class="num" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4"><button @click="saveCredits" :disabled="credBusy" class="btn-primary">{{ credBusy ? '保存中…' : '保存设置' }}</button></div>
|
||||
</div>
|
||||
|
||||
<!-- logs retention -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">日志</h2>
|
||||
<span v-if="logsSaved" class="text-xs text-emerald-300">已保存 ✓</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="row">
|
||||
<span>
|
||||
<span class="lbl">最大留存时间</span>
|
||||
<span class="hint">超过这个天数的日志会被自动清除,内存里同时还有 500 条的硬上限。范围 1–365 天,默认 30。</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="number" min="1" max="365" v-model.number="logsCfg.retention_days" class="num" />
|
||||
<span class="text-xs text-white/45">天</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4"><button @click="saveLogs" :disabled="logsBusy || !logsCfg.retention_days" class="btn-primary">{{ logsBusy ? '保存中…' : '保存设置' }}</button></div>
|
||||
</div>
|
||||
|
||||
<!-- media (生成文件) -->
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold">生成文件 (图片 / 视频)</h2>
|
||||
<span v-if="mediaSaved" class="text-xs text-emerald-300">
|
||||
已保存 ✓<span v-if="mediaRemoved" class="text-white/45"> · 立即清理 {{ mediaRemoved }} 个文件</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="row">
|
||||
<span>
|
||||
<span class="lbl">最大留存时间</span>
|
||||
<span class="hint">超过该天数的生成文件(包括用户生图和后台测试图)会被自动删除。范围 1–365 天,默认 30。每 5 分钟最多扫一次,保存设置时会立刻清理一次。</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="number" min="1" max="365" v-model.number="mediaCfg.retention_days" class="num" />
|
||||
<span class="text-xs text-white/45">天</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4"><button @click="saveMedia" :disabled="mediaBusy || !mediaCfg.retention_days" class="btn-primary">{{ mediaBusy ? '保存中…' : '保存设置' }}</button></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Colors here pair with the dark admin shell (`.public-dark` wraps <main>).
|
||||
We use white-alpha instead of slate-* so they hold up against the glass
|
||||
card background; the older slate values were authored for the white
|
||||
light-mode admin and washed out almost completely. */
|
||||
.row { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; padding: 0.75rem 0; border-bottom: 1px solid var(--hairline); }
|
||||
.row:last-child { border-bottom: none; }
|
||||
.row > span:first-child { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.lbl { font-weight: 500; color: var(--fg); font-size: 0.875rem; }
|
||||
.hint { font-size: 0.72rem; color: var(--fg-3); line-height: 1.5; }
|
||||
.hint b { color: rgb(225 29 72); font-weight: 500; }
|
||||
html.dark .hint b { color: rgb(253 164 175); } /* rose — visible warning, not pure red */
|
||||
.flbl { display: block; font-size: 0.72rem; color: var(--fg-3); margin-bottom: 0.35rem; }
|
||||
/* Pill toggle switch — applied to <input type="checkbox" class="sw">. Keeps
|
||||
the markup as-is so the existing v-model bindings keep working, but the
|
||||
control now reads as an on/off slider instead of a tick box. The "locked
|
||||
but currently on" state (e.g. allow_password_reset when email_code is off)
|
||||
now reads as a disabled-but-on switch, which matches user intuition. */
|
||||
.sw {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 2.25rem;
|
||||
height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(203 213 225); /* slate-300 */
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease;
|
||||
outline: none;
|
||||
}
|
||||
.sw::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: calc(1.3rem - 4px);
|
||||
height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.2);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw:checked { background: #4f46e5; } /* indigo-600 — matches btn-primary */
|
||||
.sw:checked::after { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
.sw:focus-visible { box-shadow: 0 0 0 3px rgb(99 102 241 / 0.25); }
|
||||
.sw:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
/* All three input variants now share the same dark-glass surface as the rest
|
||||
of the admin shell. Background + border use white-alpha so they read
|
||||
against the card; placeholders are tuned for legibility, not noise. */
|
||||
.num, .txt, .field {
|
||||
background: rgb(15 23 42 / 0.03);
|
||||
border: 1px solid var(--hairline);
|
||||
color: var(--fg);
|
||||
border-radius: 0.55rem;
|
||||
outline: none;
|
||||
transition: border-color 0.18s, background 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
html.dark .num, html.dark .txt, html.dark .field {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
color: white;
|
||||
}
|
||||
.num::placeholder, .txt::placeholder, .field::placeholder { color: var(--fg-faint); }
|
||||
.num:focus, .txt:focus, .field:focus {
|
||||
border-color: rgb(167 139 250 / 0.65);
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
box-shadow: 0 0 0 3px rgb(167 139 250 / 0.15);
|
||||
}
|
||||
.num:disabled, .txt:disabled, .field:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.num { width: 6rem; padding: 0.4rem 0.55rem; font-size: 0.8rem; text-align: right; }
|
||||
.txt { width: 16rem; max-width: 60%; padding: 0.4rem 0.65rem; font-size: 0.8rem; }
|
||||
.field { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; }
|
||||
|
||||
/* Section save buttons — solid violet to match brand. The global .btn-primary
|
||||
under .public-dark goes to white; we override here so the save action stays
|
||||
visually distinct as the primary action on a form. */
|
||||
.btn-primary {
|
||||
padding: 0.55rem 1.15rem; border-radius: 0.6rem;
|
||||
font-size: 0.8rem; font-weight: 600; color: white;
|
||||
background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%);
|
||||
box-shadow: 0 8px 20px -8px rgb(168 85 247 / 0.55);
|
||||
transition: filter 0.15s, transform 0.12s, box-shadow 0.18s, opacity 0.15s;
|
||||
}
|
||||
/* Re-assert the gradient on hover for ALL states: the global
|
||||
`.public-dark .btn-primary:hover` in style.css repaints the background white
|
||||
with no `:not(:disabled)` guard, so even disabled save buttons (注册与登录 /
|
||||
邮件服务 default to disabled) flashed white on hover. Keep this rule
|
||||
unconditional; only the brightness/lift below is gated on :not(:disabled). */
|
||||
.btn-primary:hover { background: linear-gradient(135deg, #a855f7 0%, #7c3aed 50%, #ec4899 100%); }
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); box-shadow: 0 10px 24px -8px rgb(168 85 247 / 0.7); }
|
||||
.btn-primary:active:not(:disabled) { transform: translateY(1px); }
|
||||
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup>
|
||||
// API 对接文档 — OpenAI-compatible. Lists live models and shows ready-to-run
|
||||
// curl / Python(openai SDK) examples for image + video, wired to this
|
||||
// deployment's base URL and the caller's model ids.
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { points } from '../credits'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
|
||||
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'YOUR_API_KEY')
|
||||
|
||||
const models = ref([])
|
||||
onMounted(async () => {
|
||||
const r = await api('/managed-models')
|
||||
if (r.ok) models.value = (r.data?.data || []).filter((m) => m.enabled !== false)
|
||||
})
|
||||
|
||||
const imageModels = computed(() => models.value.filter((m) => m.type === 'image'))
|
||||
const videoModels = computed(() => models.value.filter((m) => m.type === 'video'))
|
||||
const sampleImage = computed(() => imageModels.value[0]?.id || 'firefly-image-4')
|
||||
const sampleVideo = computed(() => videoModels.value[0]?.id || 'firefly-kling3')
|
||||
const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, ''))
|
||||
|
||||
function priceOf(m) {
|
||||
if (m.type === 'video') {
|
||||
// Video charge = resolution price + duration price; show the combined range.
|
||||
const rv = Object.values(m.prices || {}).filter((v) => v != null).map(Number)
|
||||
const dv = Object.values(m.duration_prices || {}).filter((v) => v != null).map(Number)
|
||||
if (!rv.length || !dv.length) return '—'
|
||||
const lo = Math.min(...rv) + Math.min(...dv)
|
||||
const hi = Math.max(...rv) + Math.max(...dv)
|
||||
return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分`
|
||||
}
|
||||
const vals = Object.values(m.prices || {}).filter((v) => v != null).map(Number)
|
||||
if (!vals.length) return '—'
|
||||
const lo = Math.min(...vals), hi = Math.max(...vals)
|
||||
return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分`
|
||||
}
|
||||
|
||||
// ---- request parameter tables ----
|
||||
const imageParams = [
|
||||
['model', 'string', '必填', '模型 id,见上表(图像)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['size', 'string', '可选', '"1024x1024" / "1536x1024" / "1024x1536" / "auto" → 决定比例'],
|
||||
['quality', 'string', '可选', '"low"|"medium"|"high"|"auto" → 画质档 1K/2K/4K(钳到模型支持档)'],
|
||||
]
|
||||
const editParams = [
|
||||
['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'],
|
||||
['prompt', 'string', '必填', '编辑/参考描述'],
|
||||
['model', 'string', '必填', '模型 id(需支持图生图)'],
|
||||
['size', 'string', '可选', '同图像:决定比例'],
|
||||
['quality', 'string', '可选', '同图像:决定画质档'],
|
||||
]
|
||||
const videoParams = [
|
||||
['model', 'string', '必填', '模型 id,见上表(视频)'],
|
||||
['prompt', 'string', '必填', '文字描述'],
|
||||
['seconds', 'string|int', '必填', '时长秒数,如 "5" "8"(取决于模型支持)'],
|
||||
['size', 'string', '可选', '如 "1280x720" / "720x1280" → 决定比例与分辨率'],
|
||||
['input_reference', 'file', '可选', '首帧/参考图(multipart 文件;runway 图生视频必填 1 张)'],
|
||||
]
|
||||
|
||||
// ---- examples (built in script so refs resolve correctly) ----
|
||||
const examples = computed(() => [
|
||||
{
|
||||
title: '文生图 · curl',
|
||||
code:
|
||||
`curl ${base.value}/v1/images/generations \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "${sampleImage.value}",
|
||||
"prompt": "a corgi running in a golden wheat field, cinematic",
|
||||
"size": "1024x1024",
|
||||
"quality": "high"
|
||||
}'`,
|
||||
},
|
||||
{
|
||||
title: '文生图 · Python (openai SDK)',
|
||||
code:
|
||||
`import base64
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||
|
||||
resp = client.images.generate(
|
||||
model="${sampleImage.value}",
|
||||
prompt="a corgi running in a golden wheat field, cinematic",
|
||||
size="1024x1024",
|
||||
quality="high",
|
||||
)
|
||||
# 结果是 base64(无 URL)
|
||||
with open("out.png", "wb") as f:
|
||||
f.write(base64.b64decode(resp.data[0].b64_json))`,
|
||||
},
|
||||
{
|
||||
title: '图生图 / 参考图 · curl (multipart)',
|
||||
code:
|
||||
`curl ${base.value}/v1/images/edits \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-F model="${sampleImage.value}" \\
|
||||
-F prompt="把这张图改成赛博朋克风格" \\
|
||||
-F quality="high" \\
|
||||
-F image=@input.png
|
||||
# 多张参考图:重复 -F image=@a.png -F image=@b.png`,
|
||||
},
|
||||
{
|
||||
title: '图生图 · Python (openai SDK)',
|
||||
code:
|
||||
`import base64
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||
|
||||
resp = client.images.edit(
|
||||
model="${sampleImage.value}",
|
||||
image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")]
|
||||
prompt="把这张图改成赛博朋克风格",
|
||||
)
|
||||
with open("out.png", "wb") as f:
|
||||
f.write(base64.b64decode(resp.data[0].b64_json))`,
|
||||
},
|
||||
{
|
||||
title: '视频 · curl(创建 → 轮询 → 下载)',
|
||||
code:
|
||||
`# 1) 创建任务 → 立即返回 {"id": "...", "status": "queued"}
|
||||
curl ${base.value}/v1/videos \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "${sampleVideo.value}",
|
||||
"prompt": "a paper boat sailing down a rainy street, cinematic",
|
||||
"seconds": "${sampleSeconds.value}",
|
||||
"size": "1280x720"
|
||||
}'
|
||||
|
||||
# 2) 轮询状态,直到 status=completed
|
||||
curl ${base.value}/v1/videos/<VIDEO_ID> \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"
|
||||
|
||||
# 3) 下载 mp4(完成后)
|
||||
curl ${base.value}/v1/videos/<VIDEO_ID>/content \\
|
||||
-H "Authorization: Bearer ${keyHint.value}" -o out.mp4`,
|
||||
},
|
||||
{
|
||||
title: '视频 · Python (requests, 轮询)',
|
||||
code:
|
||||
`import time, requests
|
||||
|
||||
base = "${base.value}/v1"
|
||||
h = {"Authorization": "Bearer ${keyHint.value}"}
|
||||
|
||||
# 1) 创建
|
||||
job = requests.post(f"{base}/videos", headers=h, json={
|
||||
"model": "${sampleVideo.value}",
|
||||
"prompt": "a paper boat sailing down a rainy street",
|
||||
"seconds": "${sampleSeconds.value}",
|
||||
"size": "1280x720",
|
||||
}).json()
|
||||
vid = job["id"]
|
||||
|
||||
# 2) 轮询
|
||||
while True:
|
||||
s = requests.get(f"{base}/videos/{vid}", headers=h).json()
|
||||
if s["status"] in ("completed", "failed"):
|
||||
break
|
||||
time.sleep(5)
|
||||
|
||||
# 3) 下载
|
||||
if s["status"] == "completed":
|
||||
mp4 = requests.get(f"{base}/videos/{vid}/content", headers=h).content
|
||||
open("out.mp4", "wb").write(mp4)`,
|
||||
},
|
||||
{
|
||||
title: '列出模型 · curl',
|
||||
code:
|
||||
`curl ${base.value}/v1/models \\
|
||||
-H "Authorization: Bearer ${keyHint.value}"`,
|
||||
},
|
||||
])
|
||||
|
||||
// ---- copy + toast ----
|
||||
const toastMsg = ref('')
|
||||
let t = null
|
||||
function toast(m) { toastMsg.value = m; clearTimeout(t); t = setTimeout(() => (toastMsg.value = ''), 1800) }
|
||||
async function copy(text) {
|
||||
try { await navigator.clipboard.writeText(text); toast('已复制') } catch { toast('复制失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-text space-y-10">
|
||||
<header>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">开发者</div>
|
||||
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1>
|
||||
<p class="text-white/45 mt-2">完全兼容 OpenAI 接口规范 — 改个 <code class="text-white/70">base_url</code> 和 <code class="text-white/70">api_key</code> 即可直接调用。图像 / 视频 / 图生图全支持。</p>
|
||||
</header>
|
||||
|
||||
<!-- quickstart -->
|
||||
<section class="grid md:grid-cols-2 gap-4">
|
||||
<div class="card p-6">
|
||||
<h2 class="text-sm font-semibold text-white/80">基础信息</h2>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">Base URL</dt><dd class="font-mono text-white/90">{{ base }}/v1</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">鉴权</dt><dd class="font-mono text-white/90">Authorization: Bearer <key></dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-white/45">你的 Key</dt><dd class="font-mono text-white/70">{{ keyHint }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="text-[11px] text-white/40 mt-4">还没有 Key?去 <router-link to="/settings" class="text-violet-300 underline">设置 → API Key</router-link> 生成。</p>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<h2 class="text-sm font-semibold text-white/80">端点</h2>
|
||||
<ul class="mt-4 space-y-2.5 text-sm font-mono">
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/generations</span><span class="text-white/35 font-sans text-xs">文生图</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/edits</span><span class="text-white/35 font-sans text-xs">图生图(multipart)</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/videos</span><span class="text-white/35 font-sans text-xs">建视频任务</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}</span><span class="text-white/35 font-sans text-xs">查状态</span></li>
|
||||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}/content</span><span class="text-white/35 font-sans text-xs">下载 mp4</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- models -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">可用模型</h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-3 font-medium">model</th>
|
||||
<th class="px-4 py-3 font-medium">类型</th>
|
||||
<th class="px-4 py-3 font-medium">分辨率 / 时长</th>
|
||||
<th class="px-4 py-3 font-medium text-right">价格</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in models" :key="m.id" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-3 font-mono text-white/90">{{ m.id }}</td>
|
||||
<td class="px-4 py-3 text-white/60">{{ m.type === 'video' ? '视频' : '图像' }}</td>
|
||||
<td class="px-4 py-3 text-white/60">{{ (m.type === 'video' ? m.durations : m.resolutions || [])?.join(' · ') || '—' }}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-white/80">{{ priceOf(m) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!models.length"><td colspan="4" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- parameters -->
|
||||
<section class="grid lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">文生图参数 <span class="text-xs font-normal text-white/40">/v1/images/generations</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in imageParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">图生图参数 <span class="text-xs font-normal text-white/40">/v1/images/edits · multipart</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in editParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-lg font-semibold mb-3">视频参数 <span class="text-xs font-normal text-white/40">/v1/videos · 异步</span></h2>
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="p in videoParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- examples -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-lg font-semibold">调用示例</h2>
|
||||
<div v-for="ex in examples" :key="ex.title" class="card overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-white/[0.06]">
|
||||
<span class="text-xs text-white/55">{{ ex.title }}</span>
|
||||
<button @click="copy(ex.code)" class="text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors">
|
||||
<Icon name="copy" class="w-3.5 h-3.5" /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<pre class="p-4 text-[12px] leading-relaxed text-white/80 overflow-auto"><code>{{ ex.code }}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- responses -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
|
||||
<div class="card p-6 space-y-3 text-sm text-white/70">
|
||||
<p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "b64_json": "..." }] }' }}</code> —— 产物以 <strong class="text-white/90">base64</strong> 直接放在 <code class="text-white/85 font-mono">data[0].b64_json</code>(原始 base64、无 <code class="text-white/70">data:</code> 前缀),自行解码保存为图片。<strong class="text-white/90">不返回 URL、服务端不留存</strong>。</p>
|
||||
<p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1">
|
||||
<li><code class="text-white/85 font-mono">POST /v1/videos</code> 立即返回任务对象 <code class="text-white/85 font-mono">{{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}</code></li>
|
||||
<li>轮询 <code class="text-white/85 font-mono">GET /v1/videos/{id}</code>,<code class="text-white/70">status</code> 从 <code class="text-white/70">queued → in_progress → completed</code>(或 <code class="text-white/70">failed</code>)</li>
|
||||
<li>完成后 <code class="text-white/85 font-mono">GET /v1/videos/{id}/content</code> 返回 <strong class="text-white/90">mp4 原始二进制</strong>(非 base64、非 URL)</li>
|
||||
</ol>
|
||||
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;图像或视频上游失败会自动退回 —— 失败不扣费。</p>
|
||||
<p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>→比例,<code class="text-white/70">quality</code>(low/medium/high)→画质档(1K/2K/4K,钳到模型支持档),<code class="text-white/70">seconds</code>→视频时长。参数须落在该模型定价表内,否则 400;余额不足 402。</p>
|
||||
<div class="pt-2 grid sm:grid-cols-2 gap-2 text-xs">
|
||||
<div class="flex items-center gap-2"><span class="badge-err">401</span> Key 无效 / 上游需重新授权</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">404</span> 未知 model / 视频任务不存在</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">400</span> 参数缺失 / 不支持或未定价</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">402</span> 积分不足</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">409</span> 视频尚未完成(content 未就绪)</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">429</span> 账号并发已满,请重试</div>
|
||||
<div class="flex items-center gap-2"><span class="badge-err">503</span> 上游繁忙,请重试</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="toastMsg" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">{{ toastMsg }}</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge-get, .badge-post, .badge-err {
|
||||
border-radius: 4px; padding: 2px 6px; font-size: 10px; line-height: 1;
|
||||
}
|
||||
.badge-get { background: rgb(16 185 129 / 0.14); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.35); }
|
||||
.badge-post { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.35); }
|
||||
.badge-err { background: rgb(244 63 94 / 0.12); color: rgb(190 18 60); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); font-family: ui-monospace, monospace; }
|
||||
html.dark .badge-get { background: rgb(16 185 129 / 0.15); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.3); }
|
||||
html.dark .badge-post { background: rgb(14 165 233 / 0.15); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.3); }
|
||||
html.dark .badge-err { background: rgb(244 63 94 / 0.15); color: rgb(253 164 175); box-shadow: inset 0 0 0 1px rgb(251 113 133 / 0.3); }
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { site } from '../site'
|
||||
import { isAuthed, openLogin } from '../auth'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Navigate to a signed-in page, or pop the login modal (remembering the
|
||||
// destination) when the visitor isn't logged in yet.
|
||||
function go(path, query) {
|
||||
const target = query ? { path, query } : { path }
|
||||
if (isAuthed()) { router.push(target); return }
|
||||
openLogin(typeof target === 'string' ? target : router.resolve(target).fullPath)
|
||||
}
|
||||
|
||||
const stats = ref({ generated_count: 0, recent: [] })
|
||||
const managed = ref([]) // managed model records (provider, type, ...)
|
||||
const showcase = ref({ hero: [], bento: [] })
|
||||
// heroDeck holds the top-3 hero cards in a RANDOMIZED order, so a different card
|
||||
// fronts the deck on each page load. It's reshuffled only when the hero set first
|
||||
// loads or its members change — not on every 30s poll, so the deck stays put.
|
||||
const heroDeck = ref([])
|
||||
function shuffleArr(arr) {
|
||||
const a = [...arr]
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[a[i], a[j]] = [a[j], a[i]]
|
||||
}
|
||||
return a
|
||||
}
|
||||
let timer = null
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
// No /logs here — that endpoint now requires a login and only returns the
|
||||
// caller's own entries. The avg-latency KPI comes from /stats (aggregate,
|
||||
// prompt-free) so the public home page exposes nothing per-user.
|
||||
const [s, m, sc] = await Promise.all([
|
||||
api('/stats'),
|
||||
api('/managed-models'),
|
||||
api('/showcase'),
|
||||
])
|
||||
stats.value = s.data || { generated_count: 0, recent: [] }
|
||||
managed.value = (m.data?.data || []).filter((x) => x.enabled !== false)
|
||||
showcase.value = sc.data?.data || { hero: [], bento: [] }
|
||||
// Randomize which hero card fronts the deck: shuffle the top-3 once when the
|
||||
// set first loads / changes; keep the order stable across refreshes so the
|
||||
// deck doesn't reshuffle every 30s while the visitor is looking at it.
|
||||
const top3 = (showcase.value.hero || []).slice(0, 3)
|
||||
const sameSet = heroDeck.value.length === top3.length
|
||||
&& top3.every((c) => heroDeck.value.some((d) => d.id === c.id))
|
||||
if (!sameSet) heroDeck.value = shuffleArr(top3)
|
||||
} catch {}
|
||||
}
|
||||
onMounted(() => { refresh(); timer = setInterval(refresh, 30000) })
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
|
||||
// ---- KPI strip — three signals derived from real data ----
|
||||
const modelCount = computed(() => managed.value.length)
|
||||
|
||||
// Show the 24h average (matches the admin overview); fall back to the all-time
|
||||
// average on a quiet day so the KPI isn't blank.
|
||||
const avgElapsed = computed(() => stats.value?.avg_elapsed_ms_24h ?? stats.value?.avg_elapsed_ms ?? null)
|
||||
const avgLabel = computed(() => {
|
||||
if (avgElapsed.value == null) return '—'
|
||||
if (avgElapsed.value < 1000) return avgElapsed.value + 'ms'
|
||||
return (avgElapsed.value / 1000).toFixed(1) + 's'
|
||||
})
|
||||
|
||||
// ---- “已接入” provider strip, derived from managed models ----
|
||||
const PROVIDER_TINT = {
|
||||
adobe: 'from-rose-400 to-orange-400',
|
||||
chatgpt: 'from-violet-500 to-fuchsia-500',
|
||||
google: 'from-sky-400 to-indigo-500',
|
||||
default: 'from-emerald-400 to-teal-500',
|
||||
}
|
||||
const providerGroups = computed(() => {
|
||||
const map = new Map()
|
||||
for (const m of managed.value) {
|
||||
const key = (m.provider || 'unknown').toLowerCase()
|
||||
const g = map.get(key) || { name: m.provider || 'unknown', image: 0, video: 0 }
|
||||
if (m.type === 'video') g.video++
|
||||
else g.image++
|
||||
map.set(key, g)
|
||||
}
|
||||
return [...map.entries()].map(([key, g]) => ({
|
||||
...g,
|
||||
grad: PROVIDER_TINT[key] || PROVIDER_TINT.default,
|
||||
})).sort((a, b) => (b.image + b.video) - (a.image + a.video))
|
||||
})
|
||||
|
||||
// Admin-curated "我们的作品" entries from /admin/api/showcase (kind=work),
|
||||
// already sorted by weight on the server.
|
||||
const works = computed(() => showcase.value.work || [])
|
||||
|
||||
// Hero deck = a random ordering of the top-3 hero entries (see refresh).
|
||||
const bento = computed(() => showcase.value.bento || [])
|
||||
|
||||
// Resolve an image reference: external URLs pass through, relative paths
|
||||
// (like "user/abc.png") are served from /generated by the backend.
|
||||
function imgSrc(image) {
|
||||
if (!image) return ''
|
||||
return /^https?:\/\//i.test(image) ? image : generatedUrl(image)
|
||||
}
|
||||
// Background style for a showcase card. Prefers a real image (the new shape);
|
||||
// falls back to the legacy CSS gradient so seed entries still render.
|
||||
function cardBg(card) {
|
||||
if (card.image) {
|
||||
return {
|
||||
backgroundImage: `url("${imgSrc(card.image)}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
}
|
||||
return { background: card.gradient }
|
||||
}
|
||||
|
||||
function useExample(ex) {
|
||||
go('/user', { prompt: ex.prompt })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-28">
|
||||
<!-- ============ HERO ============ -->
|
||||
<section class="relative pt-8 md:pt-16 grid lg:grid-cols-[1.15fr_1fr] gap-10 lg:gap-16 items-center min-h-[640px]">
|
||||
<!-- LEFT: copy -->
|
||||
<div>
|
||||
<h1 class="font-bold tracking-tight leading-[0.9] text-[clamp(2.5rem,6.5vw,6.5rem)] text-[color:var(--fg)]">
|
||||
<span class="block text-[color:var(--fg-3)] font-light italic">Imagine</span>
|
||||
<span class="block">it,
|
||||
<span class="bg-gradient-to-r from-fuchsia-300 via-violet-300 to-sky-300 bg-clip-text text-transparent italic">type</span>
|
||||
it,
|
||||
</span>
|
||||
<span class="block">own it.</span>
|
||||
</h1>
|
||||
|
||||
<p class="mt-8 text-base md:text-lg text-[color:var(--fg-2)] max-w-md leading-relaxed">
|
||||
把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex items-center gap-4">
|
||||
<button @click="go('/user')"
|
||||
class="group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
|
||||
开始画图
|
||||
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
<a href="#bento" class="text-sm text-[color:var(--fg-3)] hover:text-[color:var(--fg)] transition-colors">浏览灵感 ↓</a>
|
||||
</div>
|
||||
|
||||
<!-- counter strip — three real signals: models we support, total
|
||||
outputs ever generated, average wall-clock to produce one. -->
|
||||
<div class="mt-14 grid grid-cols-3 gap-px rounded-2xl overflow-hidden ring-1 ring-[color:var(--hairline)] max-w-xl" style="background: var(--hairline)">
|
||||
<div class="bg-[var(--surface)] px-5 py-4">
|
||||
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ modelCount }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已接入模型</div>
|
||||
</div>
|
||||
<div class="bg-[var(--surface)] px-5 py-4">
|
||||
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ stats.generated_count || 0 }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">已生成作品</div>
|
||||
</div>
|
||||
<div class="bg-[var(--surface)] px-5 py-4">
|
||||
<div class="text-2xl md:text-3xl font-bold tabular-nums text-[color:var(--fg)]">{{ avgLabel }}</div>
|
||||
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-[0.2em]">平均出片</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: stacked card deck — driven by /admin/api/showcase (kind=hero),
|
||||
top 3 by weight. Position classes are picked by index so existing CSS
|
||||
transforms in <style> still apply. -->
|
||||
<div class="relative h-[480px] lg:h-[560px] hero-deck">
|
||||
<template v-for="(card, i) in heroDeck" :key="card.id">
|
||||
<!-- back -->
|
||||
<div v-if="i === 2"
|
||||
class="deck-card deck-card-3 absolute inset-y-8 right-12 lg:right-20 w-[68%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl"
|
||||
:style="cardBg(card)">
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-25"
|
||||
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%222%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div>
|
||||
<div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- middle -->
|
||||
<div v-if="i === 1"
|
||||
class="deck-card deck-card-2 absolute inset-y-4 right-4 lg:right-8 w-[72%] rounded-3xl overflow-hidden ring-1 ring-white/10 shadow-2xl"
|
||||
:style="cardBg(card)">
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-25"
|
||||
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%223%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ card.subtitle }}</div>
|
||||
<div class="text-lg font-semibold text-white mt-1">{{ card.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- front -->
|
||||
<div v-if="i === 0"
|
||||
class="deck-card deck-card-1 absolute inset-y-0 right-0 w-[78%] rounded-3xl overflow-hidden ring-1 ring-white/15 shadow-[0_30px_80px_-20px_rgba(168,85,247,0.45)]"
|
||||
:style="cardBg(card)">
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-30"
|
||||
style="background-image:url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<div class="absolute top-0 inset-x-0 flex items-center justify-between px-5 py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-white/20"></span>
|
||||
</div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-white/60 font-mono">live</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-6 bg-gradient-to-t from-black/90 via-black/40 to-transparent">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/65">{{ card.subtitle }}</div>
|
||||
<div class="text-2xl font-bold text-white mt-2">{{ card.title }}</div>
|
||||
<p class="text-xs text-white/70 mt-2 line-clamp-2 leading-relaxed">{{ card.prompt }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ WORKS STRIP ============ -->
|
||||
<section v-if="works.length" class="-mx-8 md:-mx-14">
|
||||
<div class="px-8 md:px-14 mb-6">
|
||||
<h2 class="text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">我们的作品</h2>
|
||||
</div>
|
||||
<div class="marquee-wrap">
|
||||
<div class="marquee-track">
|
||||
<div v-for="(w, i) in [...works, ...works]" :key="w.id + '-' + i"
|
||||
class="shrink-0 w-56 h-56 rounded-2xl overflow-hidden ring-1 ring-white/[0.08] hover:ring-white/30 hover:scale-[1.02] transition-all cursor-pointer relative"
|
||||
@click="go('/logs')">
|
||||
<img :src="imgSrc(w.image)" loading="lazy"
|
||||
class="w-full h-full object-cover" />
|
||||
<div v-if="w.title" class="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
|
||||
<div class="text-xs font-medium text-white line-clamp-1">{{ w.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ BENTO EXAMPLES ============ -->
|
||||
<section id="bento">
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-8">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-violet-300/70 font-medium">灵感</div>
|
||||
<h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">从一个起点开始</h2>
|
||||
<p class="text-[color:var(--fg-3)] mt-2 max-w-md">点任意一张,自动进入画图工作台并预填提示词。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 grid-flow-row-dense gap-4 auto-rows-[200px]">
|
||||
<button v-for="ex in bento" :key="ex.id" @click="useExample(ex)"
|
||||
class="group relative text-left overflow-hidden rounded-3xl ring-1 ring-white/[0.06] hover:ring-white/20 transition-all"
|
||||
:class="ex.span"
|
||||
:style="cardBg(ex)">
|
||||
<!-- grain -->
|
||||
<div class="absolute inset-0 mix-blend-overlay opacity-30"
|
||||
style="background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><filter id=%22n%22><feTurbulence baseFrequency=%220.85%22 numOctaves=%222%22 seed=%221%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>')"></div>
|
||||
<!-- veil -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
|
||||
|
||||
<div class="absolute top-5 right-5 w-9 h-9 rounded-xl bg-white/15 backdrop-blur ring-1 ring-white/20 grid place-items-center opacity-0 group-hover:opacity-100 group-hover:scale-100 scale-95 transition-all">
|
||||
<Icon name="open" class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 p-5 md:p-6">
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ ex.subtitle }}</div>
|
||||
<div class="text-xl md:text-2xl font-bold mt-2 text-white">{{ ex.title }}</div>
|
||||
<div class="text-xs text-white/65 mt-2 line-clamp-2 leading-relaxed">{{ ex.prompt }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ MODELS GRID ============ -->
|
||||
<section>
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-8">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">模型</div>
|
||||
<h2 class="mt-2 text-3xl md:text-4xl font-bold tracking-tight text-[color:var(--fg)]">已接入</h2>
|
||||
<p class="text-[color:var(--fg-3)] mt-2 max-w-md">由管理员在后台注册;以下为已对接的上游 family。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div v-for="p in providerGroups" :key="p.name"
|
||||
class="group relative overflow-hidden rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
|
||||
<div class="absolute -right-8 -top-8 w-32 h-32 rounded-full bg-gradient-to-br opacity-30 blur-xl group-hover:opacity-50 transition-opacity"
|
||||
:class="p.grad"></div>
|
||||
<div class="relative flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-base font-semibold capitalize text-[color:var(--fg)]">{{ p.name }}</div>
|
||||
<div class="text-[10px] uppercase tracking-[0.25em] text-[color:var(--fg-3)] mt-1 flex gap-2">
|
||||
<span v-if="p.image">{{ p.image }} 图像</span>
|
||||
<span v-if="p.video">{{ p.video }} 视频</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-gradient-to-br" :class="p.grad"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!providerGroups.length" class="text-sm text-[color:var(--fg-3)] col-span-full text-center py-10">
|
||||
管理员尚未接入任何模型
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ FOOTER CTA ============ -->
|
||||
<section class="cta-band relative overflow-hidden rounded-[2rem] ring-1 ring-[color:var(--hairline)] p-12 md:p-20">
|
||||
<div class="relative max-w-2xl">
|
||||
<h3 class="text-4xl md:text-6xl font-bold tracking-tight leading-[1.05] text-[color:var(--fg)]">
|
||||
停下来思考,<br />
|
||||
<span class="italic font-light text-[color:var(--fg-3)]">不如</span> 直接开始。
|
||||
</h3>
|
||||
<p class="text-base md:text-lg text-[color:var(--fg-2)] mt-6 max-w-lg leading-relaxed">
|
||||
登录即可开始。打开画图工作台,写一句话,看 AI 替你完成。
|
||||
</p>
|
||||
<button @click="go('/user')"
|
||||
class="mt-10 group inline-flex items-center gap-3 rounded-full bg-[var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)] hover:bg-[var(--btn-solid-bg-h)] pl-6 pr-3 py-3 text-sm font-semibold transition-all">
|
||||
开始画图
|
||||
<span class="w-8 h-8 rounded-full bg-[var(--btn-solid-fg)] text-[color:var(--btn-solid-bg)] grid place-items-center group-hover:translate-x-1 transition-transform">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- thin footer line -->
|
||||
<footer class="pt-12 border-t border-[color:var(--hairline)] flex flex-wrap items-center justify-between gap-4 text-xs text-[color:var(--fg-3)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono">{{ site.title }}</span>
|
||||
<span>·</span>
|
||||
<span>AI 生图与生视频平台</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
/* ----- Hero stacked card deck ----- */
|
||||
.hero-deck { perspective: 1200px; }
|
||||
.deck-card { transform-origin: center bottom; will-change: transform, opacity; }
|
||||
/* staggered fade-in-up on load, then a livelier continuous float with a gentle
|
||||
rotation wobble. The :hover rules below still take over to fan the deck out. */
|
||||
.deck-card-1 { animation: deckIn1 0.8s cubic-bezier(0.2,0.7,0.2,1) backwards, deckFloat1 7s ease-in-out 0.8s infinite; }
|
||||
.deck-card-2 { animation: deckIn2 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.12s backwards, deckFloat2 9s ease-in-out 0.92s infinite; }
|
||||
.deck-card-3 { animation: deckIn3 0.8s cubic-bezier(0.2,0.7,0.2,1) 0.24s backwards, deckFloat3 11s ease-in-out 1.04s infinite; opacity: 0.85; }
|
||||
@keyframes deckFloat1 {
|
||||
0%,100% { transform: rotate(2deg) translateY(0); }
|
||||
35% { transform: rotate(0.5deg) translateY(-16px); }
|
||||
70% { transform: rotate(3deg) translateY(-7px); }
|
||||
}
|
||||
@keyframes deckFloat2 {
|
||||
0%,100% { transform: rotate(-3deg) translateY(0); }
|
||||
50% { transform: rotate(-1.5deg) translateY(-13px); }
|
||||
}
|
||||
@keyframes deckFloat3 {
|
||||
0%,100% { transform: rotate(5deg) translateY(0); }
|
||||
50% { transform: rotate(3.5deg) translateY(-10px); }
|
||||
}
|
||||
@keyframes deckIn1 {
|
||||
from { opacity: 0; transform: rotate(2deg) translateY(48px) scale(0.92); }
|
||||
to { opacity: 1; transform: rotate(2deg) translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes deckIn2 {
|
||||
from { opacity: 0; transform: rotate(-3deg) translateY(48px) scale(0.92); }
|
||||
to { opacity: 1; transform: rotate(-3deg) translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes deckIn3 {
|
||||
from { opacity: 0; transform: rotate(5deg) translateY(48px) scale(0.92); }
|
||||
to { opacity: 0.85; transform: rotate(5deg) translateY(0) scale(1); }
|
||||
}
|
||||
.hero-deck:hover .deck-card-1 { transform: rotate(0deg) translateY(-12px); transition: transform 0.4s ease; animation: none; }
|
||||
.hero-deck:hover .deck-card-2 { transform: rotate(-6deg) translate(-30px, 10px); transition: transform 0.4s ease; animation: none; }
|
||||
.hero-deck:hover .deck-card-3 { transform: rotate(8deg) translate(-50px, 20px); transition: transform 0.4s ease; animation: none; opacity: 0.7; }
|
||||
|
||||
.marquee-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%);
|
||||
}
|
||||
.marquee-track {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: max-content;
|
||||
padding: 0 2rem;
|
||||
animation: marquee 55s linear infinite;
|
||||
}
|
||||
.marquee-wrap:hover .marquee-track { animation-play-state: paused; }
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { fmtTs, fmtSize } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const stats = ref({ total: 0, image: 0, video: 0, size_bytes: 0 })
|
||||
const loading = ref(false)
|
||||
const kind = ref('') // '' | 'image' | 'video'
|
||||
const selected = ref(null)
|
||||
const toast = ref('')
|
||||
|
||||
const page = ref(1)
|
||||
// 20 per page so a 4-col (lg) or 5-col (xl) grid lays out as clean rows of
|
||||
// 5×4 or 4×5 instead of a half-empty trailing row.
|
||||
const pageSize = ref(20)
|
||||
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(pageSize.value),
|
||||
offset: String((page.value - 1) * pageSize.value),
|
||||
})
|
||||
if (kind.value) qs.set('kind', kind.value)
|
||||
const r = await api('/images?' + qs.toString())
|
||||
items.value = r.data?.data || []
|
||||
total.value = Number(r.data?.total ?? items.value.length)
|
||||
// Stats arrive with the same payload so the KPI strip stays cheap.
|
||||
stats.value = r.data?.stats || { total: 0, image: 0, video: 0, size_bytes: 0 }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function absUrl(name) {
|
||||
const u = generatedUrl(name)
|
||||
return u.startsWith('http') ? u : location.origin + u
|
||||
}
|
||||
|
||||
async function copyLink(name) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(absUrl(name))
|
||||
flash('链接已复制')
|
||||
} catch {
|
||||
flash('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
let toastTimer = null
|
||||
function flash(msg) {
|
||||
toast.value = msg
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toast.value = ''), 1800)
|
||||
}
|
||||
|
||||
function setKind(v) { kind.value = v; page.value = 1; load() }
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target === page.value) return
|
||||
page.value = target
|
||||
load()
|
||||
}
|
||||
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') selected.value = null
|
||||
}
|
||||
onMounted(() => { load(); window.addEventListener('keydown', onKey) })
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — same shape as the LogsView so the admin shell stays
|
||||
consistent. /images returns all four numbers in one payload. -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/45">总计</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-indigo-300/80">图像</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-indigo-300">{{ stats.image }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">视频</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.video }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">存储</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ fmtSize(stats.size_bytes) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- toolbar -->
|
||||
<div class="card p-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setKind('')" class="fp" :class="kind === '' && 'fp-on'">全部</button>
|
||||
<button @click="setKind('image')" class="fp" :class="kind === 'image' && 'fp-on'">图像</button>
|
||||
<button @click="setKind('video')" class="fp" :class="kind === 'video' && 'fp-on'">视频</button>
|
||||
</div>
|
||||
<button @click="load" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- grid -->
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!items.length" class="card flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||
<Icon name="files" class="w-6 h-6" />
|
||||
</span>
|
||||
<span class="text-sm">还没有生成过任何图片</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
<div v-for="f in items" :key="f.name"
|
||||
class="media-card group relative rounded-xl overflow-hidden ring-1 ring-white/[0.06] bg-white/[0.03] aspect-[4/5] cursor-zoom-in"
|
||||
@click="selected = f">
|
||||
<!-- media -->
|
||||
<template v-if="f.kind === 'video'">
|
||||
<video :src="generatedUrl(f.name)" muted loop preload="metadata"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
@mouseenter="$event.target.play && $event.target.play()"
|
||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||
</template>
|
||||
<img v-else :src="generatedUrl(f.name)" loading="lazy"
|
||||
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" />
|
||||
|
||||
<!-- gradient veil (always visible so the prompt overlay reads) -->
|
||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div>
|
||||
|
||||
<!-- kind chip -->
|
||||
<span class="absolute top-3 left-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
|
||||
:class="f.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
|
||||
{{ f.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
|
||||
<!-- quick actions, hover-revealed; same style as 首页内容 -->
|
||||
<div class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a :href="generatedUrl(f.name)" target="_blank" @click.stop title="新标签打开"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="open" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<button @click.stop="copyLink(f.name)" title="复制链接"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="copy" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<a :href="generatedUrl(f.name)" :download="f.name.split('/').pop()" @click.stop title="下载"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="download" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- caption: prompt (truncated 2 lines) + meta line -->
|
||||
<div class="absolute inset-x-0 bottom-0 p-3 pointer-events-none">
|
||||
<div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1"
|
||||
:title="f.prompt || f.name">
|
||||
{{ f.prompt || f.name.split('/').pop() }}
|
||||
</div>
|
||||
<div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums">
|
||||
<span class="truncate" :title="f.model || ''">{{ f.model || '—' }}</span>
|
||||
<span class="shrink-0 flex items-center gap-1">
|
||||
<span v-if="f.resolution" class="text-emerald-300/90">{{ f.resolution }}</span>
|
||||
<span v-if="f.ratio" class="text-white/40">{{ f.ratio }}</span>
|
||||
<span v-if="f.kind === 'video' && f.duration" class="text-fuchsia-300/80">{{ f.duration }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtSize(f.size) }} · {{ fmtTs(f.mtime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- pagination — hidden when everything fits on one page -->
|
||||
<div v-if="!loading && totalPages > 1" class="card !p-3 flex items-center justify-between gap-3">
|
||||
<div class="text-xs text-white/55 tabular-nums px-2">
|
||||
<span class="text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||||
/ {{ total }} 张
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-white/35">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox (shared component) -->
|
||||
<MediaLightbox
|
||||
v-if="selected"
|
||||
:src="generatedUrl(selected.name)"
|
||||
:kind="selected.kind"
|
||||
:prompt="selected.prompt"
|
||||
:meta="[selected.model, selected.name].filter(Boolean).join(' · ')"
|
||||
:meta-sub="[selected.resolution, selected.ratio, (selected.kind === 'video' ? selected.duration : ''), fmtSize(selected.size), fmtTs(selected.mtime)].filter(Boolean).join(' · ')"
|
||||
:download-name="selected.name.split('/').pop()"
|
||||
@close="selected = null" />
|
||||
|
||||
<!-- toast -->
|
||||
<transition name="fade">
|
||||
<div v-if="toast"
|
||||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg">
|
||||
{{ toast }}
|
||||
</div>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* filter pill — same shape as LogsView so the admin shell stays consistent */
|
||||
.fp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: var(--fg-3);
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.fp:hover { background: var(--hover); color: var(--fg); }
|
||||
.fp-on { background: rgb(15 23 42); color: white; box-shadow: none; }
|
||||
html.dark .fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
|
||||
|
||||
/* numbered pagination buttons */
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: var(--fg-2);
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 0 0 1px var(--hairline);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
|
||||
.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; }
|
||||
html.dark .pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup>
|
||||
// 邀请好友 — its own page (moved out of 设置). Invite data is real, from the
|
||||
// logged-in account (auth.user). The inviter earns INVITE_REWARD 积分 once each
|
||||
// invited friend completes their FIRST generation (生图).
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { auth, refreshMe } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { fmtIso } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
// Reward per completed invite — comes from the backend (credits.invite_reward),
|
||||
// falling back to 3 until the response lands.
|
||||
const INVITE_REWARD = ref(3)
|
||||
|
||||
const records = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
async function loadRecords() {
|
||||
loading.value = true
|
||||
const r = await api('/auth/invites')
|
||||
loading.value = false
|
||||
if (r.ok) {
|
||||
records.value = r.data?.data || []
|
||||
if (r.data?.reward != null) INVITE_REWARD.value = Number(r.data.reward)
|
||||
if ((page.value - 1) * pageSize >= records.value.length) page.value = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side numbered pagination — matches the admin 日志/图片管理 .pg strip.
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(records.value.length / pageSize)))
|
||||
const paged = computed(() => records.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
function goPage(n) { page.value = Math.max(1, Math.min(totalPages.value, n)) }
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshMe() // latest invite_count / invite_earned
|
||||
loadRecords()
|
||||
})
|
||||
|
||||
const inviteCode = computed(() => auth.user?.invite_code || '')
|
||||
const inviteCount = computed(() => Number(auth.user?.invite_count || 0))
|
||||
const inviteEarned = computed(() => Number(auth.user?.invite_earned || 0))
|
||||
const inviteUrl = computed(() => `${location.origin}/?ref=${inviteCode.value}`)
|
||||
|
||||
async function copyInvite() {
|
||||
try { await navigator.clipboard.writeText(inviteUrl.value); toast('邀请链接已复制') }
|
||||
catch { toast('复制失败') }
|
||||
}
|
||||
async function copyCode() {
|
||||
try { await navigator.clipboard.writeText(inviteCode.value); toast('邀请码已复制') }
|
||||
catch { toast('复制失败') }
|
||||
}
|
||||
|
||||
// ---- Toast ----
|
||||
const toastMsg = ref('')
|
||||
let toastTimer = null
|
||||
function toast(m) {
|
||||
toastMsg.value = m
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toastMsg.value = ''), 2200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-text space-y-10">
|
||||
<!-- header -->
|
||||
<header>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-amber-300/70 font-medium">奖励</div>
|
||||
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">邀请好友</h1>
|
||||
<p class="text-white/45 mt-2">好友用你的链接注册,并完成首次生图后,你得 {{ INVITE_REWARD }} 积分。</p>
|
||||
</header>
|
||||
|
||||
<section class="relative rounded-3xl ring-1 ring-white/[0.08] p-7 md:p-8 overflow-hidden"
|
||||
style="background: radial-gradient(at 70% 30%,rgba(251,191,36,0.16) 0%, transparent 55%),linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-amber-500/15 ring-1 ring-amber-400/30 grid place-items-center text-amber-300">
|
||||
<Icon name="accounts" class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<!-- summary -->
|
||||
<div class="mt-6 grid grid-cols-2 gap-3">
|
||||
<div class="rounded-xl bg-white/[0.04] ring-1 ring-white/[0.06] px-4 py-3">
|
||||
<div class="text-2xl font-bold tabular-nums">{{ inviteCount }}</div>
|
||||
<div class="text-[10px] text-white/40 mt-1 uppercase tracking-widest">已邀请</div>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white/[0.04] ring-1 ring-white/[0.06] px-4 py-3">
|
||||
<div class="text-2xl font-bold tabular-nums">{{ inviteEarned.toLocaleString('en-US') }}</div>
|
||||
<div class="text-[10px] text-white/40 mt-1 uppercase tracking-widest">累计积分</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- code -->
|
||||
<div class="mt-6">
|
||||
<label class="block text-xs text-white/50 mb-2">邀请码</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyCode"
|
||||
class="flex-1 rounded-xl bg-white/[0.05] ring-1 ring-white/10 hover:ring-white/30 hover:bg-white/[0.08] px-4 py-3 text-sm font-mono text-left transition-all">
|
||||
{{ inviteCode || '—' }}
|
||||
</button>
|
||||
<button @click="copyInvite"
|
||||
class="rounded-xl bg-white text-black hover:bg-white/90 px-5 py-3 text-sm font-semibold transition-colors">
|
||||
复制链接
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-[11px] text-white/35 break-all font-mono">{{ inviteUrl }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- records -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold">邀请记录</h2>
|
||||
<button @click="loadRecords" class="text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !records.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
|
||||
<div v-else-if="!records.length" class="text-center text-sm text-white/40 py-16">还没有人通过你的链接注册</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col class="w-24" />
|
||||
<col class="w-44" />
|
||||
<col class="w-44" />
|
||||
<col class="w-28" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-left px-5 py-3 font-medium">注册用户名</th>
|
||||
<th class="text-right px-3 py-3 font-medium">奖励</th>
|
||||
<th class="text-left px-3 py-3 font-medium">注册时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">完成时间</th>
|
||||
<th class="text-right px-5 py-3 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in paged" :key="i"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<td class="px-5 py-3.5 align-middle font-medium text-white/90 truncate">{{ r.name }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="r.reward ? 'text-emerald-300' : 'text-white/25'">
|
||||
{{ r.reward ? '+' + r.reward : '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">
|
||||
{{ r.registered_at ? fmtIso(r.registered_at) : '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">
|
||||
{{ r.completed_at ? fmtIso(r.completed_at) : '—' }}
|
||||
</td>
|
||||
<td class="px-5 py-3.5 align-middle text-right">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="r.status === 'completed'
|
||||
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||
: 'bg-amber-500/10 text-amber-300 ring-amber-400/30'">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
:class="r.status === 'completed' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
|
||||
{{ r.status === 'completed' ? '已完成' : '待生图' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination — numbered with ellipsis, same as the admin pages -->
|
||||
<div v-if="records.length && totalPages > 1"
|
||||
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
||||
<div>
|
||||
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(records.length, page * pageSize) }}</span>
|
||||
<span class="ml-1">/ {{ records.length }} 条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-white/35">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- toast -->
|
||||
<transition name="fade">
|
||||
<div v-if="toastMsg"
|
||||
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">
|
||||
{{ toastMsg }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Numbered pagination buttons — dark base (matches the admin pages); the global
|
||||
.theme-text rules in style.css recolor these for light mode automatically. */
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
// Admin invite log — global view of every invite relationship across accounts.
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { fmtIso } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const items = ref([])
|
||||
const stats = ref({ total: 0, completed: 0, pending: 0, reward_paid: 0 })
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const r = await api('/invites')
|
||||
loading.value = false
|
||||
if (r.ok) { items.value = r.data?.data || []; stats.value = r.data?.stats || stats.value }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — same shape as the LogsView so the admin shell stays
|
||||
consistent. /invites already returns the stats payload. -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/45">总邀请</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">已完成</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.completed }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">待生图</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ stats.pending }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">已发奖励</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ Number(stats.reward_paid || 0).toLocaleString('en-US') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<div class="text-xs text-white/55">全站邀请记录:谁邀请了谁、注册与完成时间、奖励状态。</div>
|
||||
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
|
||||
<div v-else-if="!items.length" class="text-center text-sm text-white/40 py-16">还没有邀请记录</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col />
|
||||
<col class="w-24" />
|
||||
<col class="w-44" />
|
||||
<col class="w-44" />
|
||||
<col class="w-28" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-left px-5 py-3 font-medium">邀请人</th>
|
||||
<th class="text-left px-3 py-3 font-medium">被邀请人</th>
|
||||
<th class="text-right px-3 py-3 font-medium">奖励</th>
|
||||
<th class="text-left px-3 py-3 font-medium">注册时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">完成时间</th>
|
||||
<th class="text-right px-5 py-3 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in items" :key="i"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<td class="px-5 py-3.5 align-middle font-medium text-white/90 truncate">{{ r.inviter }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-white/75 truncate">{{ r.invitee }}</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="r.reward ? 'text-emerald-300' : 'text-white/25'">
|
||||
{{ r.reward ? '+' + r.reward : '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">
|
||||
{{ r.registered_at ? fmtIso(r.registered_at) : '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">
|
||||
{{ r.completed_at ? fmtIso(r.completed_at) : '—' }}
|
||||
</td>
|
||||
<td class="px-5 py-3.5 align-middle text-right">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="r.status === 'completed'
|
||||
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||
: 'bg-amber-500/10 text-amber-300 ring-amber-400/30'">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
:class="r.status === 'completed' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
|
||||
{{ r.status === 'completed' ? '已完成' : '待生图' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,438 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
||||
import { generatedUrl } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
|
||||
const items = ref([])
|
||||
const stats = ref({ total: 0, success: 0, failed: 0, pending: 0 })
|
||||
const loading = ref(false)
|
||||
const kindFilter = ref('') // '' | 'image' | 'video'
|
||||
const statusFilter = ref('') // '' | 'success' | 'failed' | 'pending'
|
||||
const sourceFilter = ref('') // '' | 'v1' | 'user' | 'admin'
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const total = ref(0)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const offset = (page.value - 1) * pageSize.value
|
||||
const qs = new URLSearchParams({ limit: String(pageSize.value), offset: String(offset) })
|
||||
// Admin 日志 page: request the full cross-user view. The backend only honors
|
||||
// scope=all for admins; without it /logs returns the caller's own records.
|
||||
qs.set('scope', 'all')
|
||||
if (kindFilter.value) qs.set('kind', kindFilter.value)
|
||||
if (statusFilter.value) qs.set('status', statusFilter.value)
|
||||
if (sourceFilter.value) qs.set('source', sourceFilter.value)
|
||||
const r = await api('/logs?' + qs.toString())
|
||||
items.value = r.data?.data || []
|
||||
total.value = Number(r.data?.total ?? items.value.length)
|
||||
stats.value = r.data?.stats || { total: 0, success: 0, failed: 0, pending: 0 }
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
const pageStart = computed(() => total.value === 0 ? 0 : (page.value - 1) * pageSize.value + 1)
|
||||
const pageEnd = computed(() => Math.min(total.value, page.value * pageSize.value))
|
||||
|
||||
// Numbered pagination strip: always shows first + last + a window around
|
||||
// the current page; gaps collapse to `null` (rendered as "…").
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
// pad the second slot from each end so 1 2 … X … N-1 N feels balanced
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target === page.value) return
|
||||
page.value = target
|
||||
load()
|
||||
}
|
||||
|
||||
// Filters reset the cursor so a narrower view always starts on page 1.
|
||||
function setKind(v) { kindFilter.value = v; page.value = 1; load() }
|
||||
function setStatus(v) { statusFilter.value = v; page.value = 1; load() }
|
||||
function setSource(v) { sourceFilter.value = v; page.value = 1; load() }
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return items.value
|
||||
return items.value.filter((e) =>
|
||||
(e.model || '').toLowerCase().includes(q) ||
|
||||
(e.prompt || '').toLowerCase().includes(q) ||
|
||||
(e.error || '').toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function fmtMs(ms) {
|
||||
if (!ms) return '—'
|
||||
if (ms < 1000) return ms + 'ms'
|
||||
return Math.round(ms / 1000) + 's'
|
||||
}
|
||||
|
||||
// One-line timestamp: within 3 days show a relative phrase ("12h 前"),
|
||||
// older entries collapse to a full Y-M-D H:M:S so the row stays compact.
|
||||
function fmtWhen(ts) {
|
||||
if (!ts) return '—'
|
||||
return fmtTs(ts)
|
||||
}
|
||||
|
||||
const previewing = ref(null) // entry whose generated file is open in the lightbox
|
||||
function openPreview(e) {
|
||||
// API (v1) outputs aren't persisted/served by us (image=b64 inline, video=an
|
||||
// upstream URL for /content) — no in-log preview, same as images. Skip them.
|
||||
if (e.status !== 'success' || !e.file || e.source === 'v1') return
|
||||
previewing.value = e
|
||||
}
|
||||
function closePreview() { previewing.value = null }
|
||||
function onKey(ev) { if (ev.key === 'Escape') closePreview() }
|
||||
|
||||
// 日志不支持手动清空(清空按钮已移除);仅由后台保留期策略自动清理。
|
||||
|
||||
// Auto-refresh removed: the admin can hit 刷新 / change a filter to reload.
|
||||
onMounted(() => {
|
||||
load()
|
||||
window.addEventListener('keydown', onKey)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
})
|
||||
|
||||
// ---- chip helpers ----
|
||||
const statusLabel = (s) => ({ success: '成功', failed: '失败', pending: '进行中' }[s] || s)
|
||||
const statusPill = (s) => ({
|
||||
success: 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30',
|
||||
failed: 'bg-rose-500/10 text-rose-300 ring-rose-400/30',
|
||||
pending: 'bg-amber-500/10 text-amber-300 ring-amber-400/30',
|
||||
}[s] || 'bg-white/[0.06] text-white/65 ring-white/15')
|
||||
const statusDot = (s) => ({
|
||||
success: 'bg-emerald-400',
|
||||
failed: 'bg-rose-400',
|
||||
pending: 'bg-amber-400',
|
||||
}[s] || 'bg-white/40')
|
||||
|
||||
// Source: backend stamps "v1" (API key), "user" (画图台), "admin" (后台测试模型).
|
||||
const sourceLabel = (s) => ({ v1: 'API', user: '画图台', admin: '测试' }[s] || '画图台')
|
||||
const sourcePill = (s) => ({
|
||||
v1: 'bg-violet-500/15 text-violet-300 ring-violet-400/30',
|
||||
admin: 'bg-amber-500/15 text-amber-300 ring-amber-400/30',
|
||||
user: 'bg-sky-500/15 text-sky-300 ring-sky-400/30',
|
||||
}[s] || 'bg-sky-500/15 text-sky-300 ring-sky-400/30')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — dense pills aligned with the dashboard tints -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/45">总计</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">成功</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.success }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">失败</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.failed }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">进行中</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ stats.pending }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setKind('')" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button>
|
||||
<button @click="setKind('image')" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button>
|
||||
<button @click="setKind('video')" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setStatus('')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="setStatus('success')" class="fp" :class="statusFilter === 'success' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>成功
|
||||
</button>
|
||||
<button @click="setStatus('failed')" class="fp" :class="statusFilter === 'failed' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>失败
|
||||
</button>
|
||||
<button @click="setStatus('pending')" class="fp" :class="statusFilter === 'pending' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>进行中
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setSource('')" class="fp" :class="sourceFilter === '' && 'fp-on'">所有来源</button>
|
||||
<button @click="setSource('user')" class="fp" :class="sourceFilter === 'user' && 'fp-on'">画图台</button>
|
||||
<button @click="setSource('v1')" class="fp" :class="sourceFilter === 'v1' && 'fp-on'">API</button>
|
||||
<button @click="setSource('admin')" class="fp" :class="sourceFilter === 'admin' && 'fp-on'">测试</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 / 提示词 / 错误…" />
|
||||
</div>
|
||||
<button @click="load" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="files" class="w-6 h-6" /></span>
|
||||
<!-- Search is client-side over the CURRENT page only, so "no match" here
|
||||
doesn't mean the term is absent globally — say so to avoid confusion. -->
|
||||
<span class="text-sm">{{ search.trim() ? '当前页没有匹配的记录(搜索仅作用于本页)' : '还没有日志' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Each row is a thumbnail + a stack of model/prompt + a meta line.
|
||||
Beats a 9-column table for scanability — the eye lands on the
|
||||
image first, then reads the model + intent, then params. -->
|
||||
<table v-else class="w-full text-sm table-fixed log-table">
|
||||
<colgroup>
|
||||
<col class="w-20" /> <!-- preview -->
|
||||
<col class="w-32" /> <!-- time -->
|
||||
<col class="w-24" /> <!-- status -->
|
||||
<col class="w-28" /> <!-- user -->
|
||||
<col class="w-40" /> <!-- model -->
|
||||
<col /> <!-- prompt + error -->
|
||||
<col class="w-48" /> <!-- params -->
|
||||
<col class="w-16" /> <!-- credits -->
|
||||
<col class="w-16" /> <!-- elapsed -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-center px-4 py-3 font-medium">预览</th>
|
||||
<th class="text-left px-4 py-3 font-medium">时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-left px-3 py-3 font-medium">用户</th>
|
||||
<th class="text-left px-3 py-3 font-medium">模型</th>
|
||||
<th class="text-left px-3 py-3 font-medium">提示词 / 错误</th>
|
||||
<th class="text-left px-3 py-3 font-medium">参数</th>
|
||||
<th class="text-right px-3 py-3 font-medium">积分</th>
|
||||
<th class="text-right px-4 py-3 font-medium">耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in filtered" :key="e.id" class="log-row">
|
||||
<td class="px-4 py-3.5 align-middle text-center">
|
||||
<button v-if="e.status === 'success' && e.file && e.source !== 'v1'"
|
||||
@click="openPreview(e)"
|
||||
class="block w-12 h-12 mx-auto rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all">
|
||||
<img v-if="e.kind !== 'video'" :src="generatedUrl(e.file)" loading="lazy"
|
||||
class="w-full h-full object-cover" />
|
||||
<video v-else :src="generatedUrl(e.file)" muted loop preload="metadata" playsinline
|
||||
class="w-full h-full object-cover"
|
||||
@mouseenter="$event.target.play && $event.target.play()"
|
||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||
</button>
|
||||
<div v-else-if="e.status === 'pending'" class="w-12 h-12 mx-auto rounded-lg bg-amber-500/10 ring-1 ring-amber-400/30 grid place-items-center">
|
||||
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
|
||||
</div>
|
||||
<!-- failed (and any non-success/non-pending) rows: no thumbnail, just a dash.
|
||||
The 状态 column already flags the failure. -->
|
||||
<span v-else class="text-white/20">—</span>
|
||||
</td>
|
||||
<td class="px-4 py-3.5 align-middle text-xs whitespace-nowrap" :title="fmtTs(e.ts)">
|
||||
<div v-if="e.ts" class="leading-tight">
|
||||
<div class="text-white/80 tabular-nums">{{ fmtDate(e.ts) }}</div>
|
||||
<div class="text-white/45 tabular-nums">{{ fmtClock(e.ts) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">—</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="chip ring-1" :class="statusPill(e.status)">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(e.status)"></span>
|
||||
{{ statusLabel(e.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle min-w-0">
|
||||
<div class="text-xs text-white/80 truncate" :title="e.user_name || '匿名'">{{ e.user_name || '匿名' }}</div>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle min-w-0">
|
||||
<div class="font-mono text-xs text-white/90 truncate" :title="e.model">{{ e.model }}</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-[10px] uppercase tracking-wider font-medium truncate min-w-0"
|
||||
:class="e.kind === 'video' ? 'text-fuchsia-300/80' : 'text-indigo-300/80'">
|
||||
{{ e.kind === 'video' ? '视频' : '图像' }}
|
||||
<span v-if="e.provider" class="text-white/30 ml-1">· {{ e.provider }}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded px-1.5 py-px text-[10px] font-medium ring-1 whitespace-nowrap shrink-0"
|
||||
:class="sourcePill(e.source)">{{ sourceLabel(e.source) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Prompt with error inline; the error reads as a follow-up rather
|
||||
than wasting a whole column when there's nothing to show. -->
|
||||
<td class="px-3 py-3.5 align-middle min-w-0">
|
||||
<div class="text-xs text-white/80 truncate" :title="e.prompt">{{ e.prompt || '—' }}</div>
|
||||
<div v-if="e.error" class="mt-1 text-[11px] text-rose-300/85 truncate flex items-center gap-1.5" :title="e.error">
|
||||
<Icon name="close" class="w-3 h-3 shrink-0" />
|
||||
{{ e.error }}
|
||||
</div>
|
||||
</td>
|
||||
<!-- Compact single-line params, dot-separated. -->
|
||||
<td class="px-3 py-3.5 align-middle text-[11px] text-white/55 font-mono whitespace-nowrap tabular-nums">
|
||||
<span>{{ e.ratio || '—' }}</span>
|
||||
<span class="text-white/25 mx-1.5">·</span>
|
||||
<span>{{ e.resolution || '—' }}</span>
|
||||
<template v-if="e.duration">
|
||||
<span class="text-white/25 mx-1.5">·</span>
|
||||
<span>{{ e.duration }}</span>
|
||||
</template>
|
||||
<template v-if="e.refs > 0">
|
||||
<span class="text-white/25 mx-1.5">·</span>
|
||||
<span class="text-white/40">参考 {{ e.refs }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap">
|
||||
<span v-if="e.cost > 0" class="text-amber-300 font-medium">{{ e.cost }}</span>
|
||||
<span v-else class="text-white/25">0</span>
|
||||
</td>
|
||||
<td class="px-4 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap text-white/85">
|
||||
{{ fmtMs(e.elapsed_ms) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination — numbered with ellipsis, no prev/next buttons -->
|
||||
<div v-if="!loading && total > 0"
|
||||
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
||||
<div>
|
||||
<span class="tabular-nums text-white/85">{{ pageStart }}–{{ pageEnd }}</span>
|
||||
<span class="ml-1">/ {{ total }} 条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-white/35">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox (shared component) -->
|
||||
<MediaLightbox
|
||||
v-if="previewing"
|
||||
:src="generatedUrl(previewing.file)"
|
||||
:kind="previewing.kind"
|
||||
:prompt="previewing.prompt"
|
||||
:meta="[previewing.model, previewing.ratio, previewing.resolution, previewing.duration, fmtMs(previewing.elapsed_ms)].filter(Boolean).join(' · ')"
|
||||
:download-name="previewing.file"
|
||||
@close="closePreview" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* --- filter pills --- */
|
||||
.fp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-rose {
|
||||
background: rgb(244 63 94 / 0.22);
|
||||
color: rgb(253 164 175);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
|
||||
}
|
||||
.fp-amber {
|
||||
background: rgb(245 158 11 / 0.22);
|
||||
color: rgb(252 211 77);
|
||||
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45);
|
||||
}
|
||||
|
||||
/* --- type / status chip used inside table rows --- */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
border-radius: 9999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- "danger" variant for the 清空 button --- */
|
||||
.btn-soft.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.btn-soft.danger:hover {
|
||||
color: white;
|
||||
background: rgb(244 63 94 / 0.25);
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* --- log table: subtle row separators + a barely-there hover tint that
|
||||
extends a soft violet accent on the left of the row (read as a
|
||||
focus indicator without being noisy). --- */
|
||||
.log-table { border-collapse: separate; border-spacing: 0; }
|
||||
.log-row td {
|
||||
border-bottom: 1px solid rgb(255 255 255 / 0.04);
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.log-row:hover td { background: rgb(255 255 255 / 0.025); }
|
||||
.log-row:hover td:first-child {
|
||||
box-shadow: inset 2px 0 0 rgb(167 139 250 / 0.55);
|
||||
}
|
||||
.log-row:last-child td { border-bottom: none; }
|
||||
|
||||
/* --- pagination buttons --- */
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,399 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import ModelFormModal from '../components/ModelFormModal.vue'
|
||||
import TestModal from '../components/TestModal.vue'
|
||||
import { points } from '../credits'
|
||||
|
||||
const models = ref([])
|
||||
const loading = ref(false)
|
||||
const showForm = ref(false)
|
||||
const editing = ref(null) // null = add, object = edit
|
||||
const testing = ref(null) // model being tested, or null
|
||||
|
||||
const kindFilter = ref('') // '' | 'image' | 'video'
|
||||
const statusFilter = ref('') // '' | 'enabled' | 'disabled'
|
||||
const search = ref('')
|
||||
|
||||
const TYPE_LABEL = { image: '生图', video: '生视频' }
|
||||
const REF_MODE_LABEL = { none: '无', frame: '首帧/首尾帧', asset: '参考图模式' }
|
||||
|
||||
async function loadModels() {
|
||||
loading.value = true
|
||||
const r = await api('/managed-models')
|
||||
models.value = r.data?.data || []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function openAdd() { editing.value = null; showForm.value = true }
|
||||
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
||||
function onSaved() { showForm.value = false; loadModels() }
|
||||
|
||||
async function toggleEnabled(m) {
|
||||
// Optimistic: flip the switch instantly, persist in the background, revert on
|
||||
// failure. Avoids the lag of awaiting the PATCH + a full table reload before
|
||||
// the toggle visibly moves.
|
||||
const cur = m.enabled !== false
|
||||
const next = !cur
|
||||
m.enabled = next
|
||||
const r = await api(`/managed-models/${encodeURIComponent(m.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
})
|
||||
if (!r.ok) m.enabled = cur
|
||||
}
|
||||
|
||||
async function remove(m) {
|
||||
if (!confirm(`确认删除模型 ${m.id}?`)) return
|
||||
await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' })
|
||||
loadModels()
|
||||
}
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = models.value.length
|
||||
const image = models.value.filter((m) => m.type === 'image').length
|
||||
const video = models.value.filter((m) => m.type === 'video').length
|
||||
const enabled = models.value.filter((m) => m.enabled !== false).length
|
||||
return { total, image, video, enabled, disabled: total - enabled }
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
return models.value.filter((m) => {
|
||||
if (kindFilter.value && m.type !== kindFilter.value) return false
|
||||
if (statusFilter.value === 'enabled' && m.enabled === false) return false
|
||||
if (statusFilter.value === 'disabled' && m.enabled !== false) return false
|
||||
if (q && !(m.id.toLowerCase().includes(q) || (m.provider || '').toLowerCase().includes(q))) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(loadModels)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — same shape as LogsView / InvitesAdminView -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/45">总数</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-indigo-300/80">图像</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-indigo-300">{{ stats.image }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">视频</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.video }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">启用</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.enabled }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="kindFilter = ''" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button>
|
||||
<button @click="kindFilter = 'image'" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button>
|
||||
<button @click="kindFilter = 'video'" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="statusFilter = ''" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="statusFilter = 'enabled'" class="fp" :class="statusFilter === 'enabled' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>启用
|
||||
</button>
|
||||
<button @click="statusFilter = 'disabled'" class="fp" :class="statusFilter === 'disabled' && 'fp-white'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>停用
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / Provider…" />
|
||||
</div>
|
||||
<button @click="loadModels" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button @click="openAdd" class="btn-primary">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 新增模型
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span>
|
||||
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,点右上角「新增模型」' }}</span>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col /> <!-- model id + provider -->
|
||||
<col class="w-20" /> <!-- type -->
|
||||
<col /> <!-- pricing -->
|
||||
<col /> <!-- capability -->
|
||||
<col class="w-20" /> <!-- weight -->
|
||||
<col class="w-24" /> <!-- generation count -->
|
||||
<col class="w-20" /> <!-- status switch -->
|
||||
<col class="w-36" /> <!-- actions -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-left px-5 py-3 font-medium">模型</th>
|
||||
<th class="text-left px-3 py-3 font-medium">类型</th>
|
||||
<th class="text-left px-3 py-3 font-medium">定价</th>
|
||||
<th class="text-left px-3 py-3 font-medium">能力</th>
|
||||
<th class="text-right px-3 py-3 font-medium">权重</th>
|
||||
<th class="text-right px-3 py-3 font-medium">生图次数</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-right px-5 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in filtered" :key="m.id"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<!-- Model id + provider underneath -->
|
||||
<td class="px-5 py-3.5 align-middle min-w-0">
|
||||
<div class="font-mono text-xs text-white/90 truncate" :title="m.id">{{ m.id }}</div>
|
||||
<div class="mt-1 text-[10px] text-white/45 capitalize truncate">{{ m.provider || '—' }}</div>
|
||||
</td>
|
||||
|
||||
<!-- Type chip with the same shape as Logs/Provider 健康 -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="m.type === 'video'
|
||||
? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30'
|
||||
: 'bg-indigo-500/10 text-indigo-300 ring-indigo-400/30'">
|
||||
{{ TYPE_LABEL[m.type] || m.type }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Pricing block — image shows 1K/2K/4K + price, video shows
|
||||
duration + price; each as a small price-chip. -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<!-- Each tier shows a pair: 普通价 (emerald) + 代理价 (amber).
|
||||
代理价未设置时回退普通价数值。 -->
|
||||
<div v-if="m.type === 'image'" class="flex flex-wrap gap-1">
|
||||
<span v-for="r in (m.resolutions || [])" :key="r" class="price-chip">
|
||||
<span class="text-white/85">{{ r }}</span>
|
||||
<span class="text-white/30 mx-1">普通</span>
|
||||
<span class="text-emerald-300 tabular-nums">{{ points(m.prices?.[r]) }}</span>
|
||||
<span class="text-amber-300/40 ml-1.5">代理</span>
|
||||
<span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span>
|
||||
</span>
|
||||
<span v-if="!(m.resolutions || []).length" class="text-white/30 text-xs">—</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-1">
|
||||
<!-- video charge = resolution price + duration price; both show 普通/代理 -->
|
||||
<span v-for="r in (m.resolutions || [])" :key="'r'+r" class="price-chip">
|
||||
<span class="text-white/85">{{ r }}</span>
|
||||
<span class="text-white/30 mx-1">普通</span>
|
||||
<span class="text-emerald-300 tabular-nums">{{ points(m.prices?.[r]) }}</span>
|
||||
<span class="text-amber-300/40 ml-1.5">代理</span>
|
||||
<span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span>
|
||||
</span>
|
||||
<span v-for="d in (m.durations || [])" :key="'d'+d" class="price-chip">
|
||||
<span class="text-white/85">{{ d }}</span>
|
||||
<span class="text-white/30 mx-1">普通</span>
|
||||
<span class="text-sky-300 tabular-nums">+{{ points(m.duration_prices?.[d]) }}</span>
|
||||
<span class="text-amber-300/40 ml-1.5">代理</span>
|
||||
<span class="text-amber-300 tabular-nums">+{{ points(m.duration_prices_agent?.[d] ?? m.duration_prices?.[d]) }}</span>
|
||||
</span>
|
||||
<span v-if="!(m.resolutions || []).length && !(m.durations || []).length" class="text-white/30 text-xs">—</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Capability — extras that aren't a price: image-to-image,
|
||||
video frame mode, supported resolutions for video. -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<div class="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<span v-if="m.type === 'image' && m.image_to_image"
|
||||
class="cap-chip cap-emerald">图生图</span>
|
||||
<!-- 参考图 is a capability like 图生图 — same emerald style, shown first -->
|
||||
<span v-if="m.type === 'video' && m.max_reference_images > 0"
|
||||
class="cap-chip cap-emerald"
|
||||
:title="REF_MODE_LABEL[m.reference_mode]">参考图 {{ m.max_reference_images }}</span>
|
||||
<span v-if="m.type === 'video'" v-for="r in (m.resolutions || [])" :key="'vr'+r"
|
||||
class="cap-chip cap-slate">{{ r }}</span>
|
||||
<span v-if="(m.type === 'image' && !(m.ratios || []).length && !m.image_to_image) ||
|
||||
(m.type === 'video' && !(m.resolutions || []).length && !m.max_reference_images)"
|
||||
class="text-white/30 text-xs">—</span>
|
||||
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap-chip cap-mono">{{ r }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Display weight — higher floats to the top of the dropdown/list -->
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="(m.weight || 0) !== 0 ? 'text-white/85' : 'text-white/30'"
|
||||
title="展示权重(越大越靠前)">
|
||||
{{ m.weight || 0 }}
|
||||
</td>
|
||||
|
||||
<!-- Successful generations to date, from event_log via /managed-models -->
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="m.generation_count > 0 ? 'text-white/85' : 'text-white/25'">
|
||||
{{ (m.generation_count || 0).toLocaleString('en-US') }}
|
||||
</td>
|
||||
|
||||
<!-- Status as a real toggle so admins read it as on/off, not a
|
||||
button to delete or whatever. -->
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<button class="sw" :class="m.enabled !== false && 'sw-on'"
|
||||
:aria-pressed="m.enabled !== false" @click="toggleEnabled(m)">
|
||||
<span class="sw-thumb"></span>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<!-- Actions — small soft buttons, danger variant for delete -->
|
||||
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button @click="testing = m" class="act" title="测试生成">
|
||||
<Icon name="test" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="openEdit(m)" class="act" title="编辑">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="remove(m)" class="act danger" title="删除">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ModelFormModal v-if="showForm" :model="editing" @close="showForm = false" @saved="onSaved" />
|
||||
<TestModal v-if="testing" :model="testing" @close="testing = null" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* --- filter pills (mirrors LogsView so the admin shell stays consistent) */
|
||||
.fp {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem; font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-white {
|
||||
background: rgb(255 255 255 / 0.18);
|
||||
color: white;
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.35);
|
||||
}
|
||||
|
||||
/* --- price chips: tight pill showing a key (resolution or duration) plus
|
||||
its emerald-tinted price. */
|
||||
.price-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.18rem 0.55rem;
|
||||
font-size: 0.7rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- capability chips: smaller, monochrome variants */
|
||||
.cap-chip {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.68rem; font-weight: 500;
|
||||
border-radius: 9999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cap-emerald {
|
||||
background: rgb(16 185 129 / 0.12);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.3);
|
||||
}
|
||||
.cap-amber {
|
||||
background: rgb(245 158 11 / 0.12);
|
||||
color: rgb(252 211 77);
|
||||
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.3);
|
||||
}
|
||||
.cap-slate {
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
}
|
||||
.cap-mono {
|
||||
background: transparent;
|
||||
color: rgb(255 255 255 / 0.5);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
/* --- iOS-style toggle for 启用/停用 */
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 2.25rem; height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.sw-thumb {
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw-on { background: rgb(16 185 129 / 0.7); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5); }
|
||||
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
|
||||
/* --- danger variant for 删除 (kept for any other .btn-soft callers) */
|
||||
.btn-soft.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.btn-soft.danger:hover {
|
||||
color: white;
|
||||
background: rgb(244 63 94 / 0.25);
|
||||
}
|
||||
|
||||
/* --- compact square icon button for the actions column. Predictable
|
||||
width (1.9rem × 3 + gaps) keeps the row inside its 9rem slot, so
|
||||
the last button never gets clipped. */
|
||||
.act {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.act.danger:hover {
|
||||
color: white;
|
||||
background: rgb(244 63 94 / 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,518 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { fmtTs, fmtSize } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const providers = ref([])
|
||||
const stats = ref({ generated_count: 0, generated_size_bytes: 0 })
|
||||
const userStats = ref({ total: 0, active: 0, disabled: 0, admins: 0, credits_total: 0, new_24h: 0, new_7d: 0, active_24h: 0 })
|
||||
// Everything time-windowed now comes pre-aggregated from /dashboard (server-side
|
||||
// SQL) instead of being recomputed in the browser from the last 200 logs — which
|
||||
// silently undercounted week / DAU / trend / top-N once volume grew past 200.
|
||||
const dash = ref(null)
|
||||
const logs = ref([]) // recent-activity feed only (small page)
|
||||
const models = ref([]) // managed models, for the model-count card
|
||||
const range = ref('day') // analytics window toggle: 'day' (24h) | 'week' (7d)
|
||||
let timer = null
|
||||
|
||||
async function refreshAll() {
|
||||
const [p, s, u, d, l, m] = await Promise.all([
|
||||
api('/providers'),
|
||||
api('/stats'),
|
||||
api('/users'),
|
||||
api('/dashboard'),
|
||||
api('/logs?limit=20'),
|
||||
api('/managed-models'),
|
||||
])
|
||||
providers.value = p.data?.data || []
|
||||
stats.value = s.data || {}
|
||||
userStats.value = u.data?.stats || {}
|
||||
dash.value = d.data || null
|
||||
logs.value = l.data?.data || []
|
||||
models.value = m.data?.data || []
|
||||
}
|
||||
|
||||
// ---- windowed event aggregates (from /dashboard) ----
|
||||
const EMPTY_WINDOW = { total: 0, success: 0, failed: 0, pending: 0, image: 0, video: 0, api: 0, web: 0, spent: 0 }
|
||||
const day = computed(() => dash.value?.day || EMPTY_WINDOW)
|
||||
const week = computed(() => dash.value?.week || EMPTY_WINDOW)
|
||||
const successRate = computed(() => (day.value.total ? Math.round((day.value.success / day.value.total) * 100) : 0))
|
||||
|
||||
// Direction vs the previous 24h (24–48h ago) — a quiet day after a busy week is
|
||||
// worth seeing. prev_day_total is computed server-side.
|
||||
const dayDelta = computed(() => {
|
||||
const cur = day.value.total
|
||||
const prev = dash.value?.prev_day_total || 0
|
||||
if (!prev) return cur ? { pct: null, dir: 'up' } : { pct: null, dir: 'flat' }
|
||||
const pct = Math.round(((cur - prev) / prev) * 100)
|
||||
return { pct, dir: cur > prev ? 'up' : cur < prev ? 'down' : 'flat' }
|
||||
})
|
||||
|
||||
const dau = computed(() => dash.value?.dau || 0)
|
||||
const avg24hMs = computed(() => stats.value?.avg_elapsed_ms_24h ?? null)
|
||||
|
||||
// ---- range-toggled top-N analytics (both windows ship in the payload, so the
|
||||
// 24h/7d switch is instant — no re-fetch) ----
|
||||
const rangeLabel = computed(() => (range.value === 'week' ? '近 7 天' : '近 24h'))
|
||||
const analytics = computed(() => dash.value?.analytics?.[range.value] || { models: [], failures: [], top_users: [] })
|
||||
const modelUsage = computed(() => analytics.value.models || [])
|
||||
const usageMax = computed(() => Math.max(1, ...modelUsage.value.map((m) => m.count)))
|
||||
const failures = computed(() => analytics.value.failures || [])
|
||||
const topUsers = computed(() => analytics.value.top_users || [])
|
||||
const topUserMax = computed(() => Math.max(1, ...topUsers.value.map((u) => u.spent)))
|
||||
|
||||
// ---- 24h trend (always 24h) ----
|
||||
const hourBuckets = computed(() => dash.value?.hourly || Array.from({ length: 24 }, () => ({ image: 0, video: 0 })))
|
||||
const hourMax = computed(() => Math.max(1, ...hourBuckets.value.map((b) => b.image + b.video)))
|
||||
|
||||
// ---- operations cards ----
|
||||
const cdk = computed(() => dash.value?.cdk || {})
|
||||
const invites = computed(() => dash.value?.invites || {})
|
||||
const checkin = computed(() => dash.value?.checkin || {})
|
||||
|
||||
// ---- token / model / provider summaries (from /providers + /managed-models) ----
|
||||
const tokens = computed(() => {
|
||||
let active = 0, total = 0
|
||||
for (const p of providers.value) {
|
||||
active += p.tokens_active || 0
|
||||
total += (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
|
||||
}
|
||||
return { active, total }
|
||||
})
|
||||
|
||||
const modelTypes = computed(() => {
|
||||
const image = models.value.filter((m) => m.type === 'image').length
|
||||
const video = models.value.filter((m) => m.type === 'video').length
|
||||
return { image, video, total: models.value.length }
|
||||
})
|
||||
|
||||
const providerHealth = computed(() =>
|
||||
providers.value.map((p) => {
|
||||
const total = (p.tokens_active || 0) + (p.tokens_disabled || 0) + (p.tokens_quota || 0)
|
||||
let status = 'down'
|
||||
if ((p.tokens_active || 0) > 0) status = 'healthy'
|
||||
else if (total > 0) status = 'warning'
|
||||
return { ...p, status, total }
|
||||
})
|
||||
)
|
||||
|
||||
// One-glance system health badge derived from provider token availability.
|
||||
const overallHealth = computed(() => {
|
||||
const list = providerHealth.value
|
||||
if (!list.length) return { label: '未配置 Provider', tone: 'down' }
|
||||
if (list.some((p) => p.status === 'down')) return { label: 'Provider 异常', tone: 'down' }
|
||||
if (list.some((p) => p.status === 'warning')) return { label: 'Provider 告警', tone: 'warning' }
|
||||
return { label: '系统健康', tone: 'healthy' }
|
||||
})
|
||||
|
||||
const recentLogs = computed(() => logs.value.slice(0, 12))
|
||||
|
||||
// ---- formatters ----
|
||||
function statusLabel(s) { return s === 'healthy' ? '健康' : s === 'warning' ? '告警' : '未配置' }
|
||||
function statusDot(s) { return s === 'healthy' ? 'bg-emerald-400' : s === 'warning' ? 'bg-amber-400' : 'bg-rose-500' }
|
||||
function statusPill(s) {
|
||||
if (s === 'healthy') return 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||
if (s === 'warning') return 'bg-amber-500/10 text-amber-300 ring-amber-400/30'
|
||||
return 'bg-rose-500/10 text-rose-300 ring-rose-400/30'
|
||||
}
|
||||
function logDot(status) {
|
||||
if (status === 'success') return 'bg-emerald-400'
|
||||
if (status === 'pending') return 'bg-amber-400'
|
||||
return 'bg-rose-500'
|
||||
}
|
||||
function fmtMs(ms) {
|
||||
if (!ms) return '—'
|
||||
if (ms < 1000) return ms + 'ms'
|
||||
return (ms / 1000).toFixed(1) + 's'
|
||||
}
|
||||
function fmtInt(n) { return (n ?? 0).toLocaleString('zh-CN') }
|
||||
function fmtCredits(n) {
|
||||
const v = Number(n || 0)
|
||||
if (v >= 10000) return (v / 10000).toFixed(1) + ' 万'
|
||||
return fmtInt(Math.round(v))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshAll()
|
||||
timer = setInterval(refreshAll, 10000)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- ===== Toolbar: overall health + refresh ===== -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium ring-1 tabular-nums"
|
||||
:class="statusPill(overallHealth.tone)">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
|
||||
{{ overallHealth.label }}
|
||||
</span>
|
||||
<button @click="refreshAll" class="btn-ghost">刷新</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== KPI strip ===== -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<!-- 用户 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">用户</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-indigo-500/15 text-indigo-300 grid place-items-center ring-1 ring-indigo-400/20">
|
||||
<Icon name="accounts" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtInt(userStats.total) }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">{{ userStats.active }} 活跃 · {{ userStats.admins }} 管理员</div>
|
||||
<div class="text-[11px] text-emerald-300/80 mt-0.5">
|
||||
今日新增 <span class="tabular-nums font-medium">{{ fmtInt(userStats.new_24h) }}</span>
|
||||
· 7日 <span class="tabular-nums font-medium">{{ fmtInt(userStats.new_7d) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 24h -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">近 24 小时生成</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-violet-500/15 text-violet-300 grid place-items-center ring-1 ring-violet-400/20">
|
||||
<Icon name="spark" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-2 flex items-baseline gap-2">
|
||||
<span>{{ fmtInt(day.total) }}</span>
|
||||
<span v-if="dayDelta.pct != null"
|
||||
class="text-[11px] font-medium tabular-nums"
|
||||
:class="dayDelta.dir === 'up' ? 'text-emerald-300' : dayDelta.dir === 'down' ? 'text-rose-300' : 'text-white/45'">
|
||||
{{ dayDelta.dir === 'up' ? '↑' : dayDelta.dir === 'down' ? '↓' : '·' }}{{ Math.abs(dayDelta.pct) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] mt-1 flex flex-wrap gap-x-2">
|
||||
<span class="text-emerald-300 tabular-nums">{{ day.success }} 成功</span>
|
||||
<span v-if="day.failed" class="text-rose-300 tabular-nums">{{ day.failed }} 失败</span>
|
||||
<span v-if="day.pending" class="text-amber-300 tabular-nums">{{ day.pending }} 进行中</span>
|
||||
</div>
|
||||
<div v-if="day.total" class="text-[10px] text-white/40 mt-1 tabular-nums">
|
||||
Web {{ day.web }} · API {{ day.api }} · 图 {{ day.image }} · 视 {{ day.video }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 平均耗时 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">平均耗时</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-emerald-500/15 text-emerald-300 grid place-items-center ring-1 ring-emerald-400/20">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtMs(avg24hMs) }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">{{ successRate }}% 成功率 · 24h</div>
|
||||
</div>
|
||||
|
||||
<!-- 存储 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">产物存储</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-amber-500/15 text-amber-300 grid place-items-center ring-1 ring-amber-400/20">
|
||||
<Icon name="files" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtSize(stats.generated_size_bytes || 0) }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">{{ fmtInt(stats.generated_count) }} 个文件</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Secondary stat row ===== -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-white/55">系统积分总和</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2">{{ fmtCredits(userStats.credits_total) }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">
|
||||
所有用户余额累加 · <span class="text-amber-300">24h 消耗 {{ fmtCredits(day.spent) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-white/55">Token</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
|
||||
<span class="text-emerald-300">{{ tokens.active }}</span>
|
||||
<span class="text-white/30">/</span>
|
||||
<span>{{ tokens.total }}</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">活跃 / 已配置</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-white/55">模型</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2">{{ modelTypes.total }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">图像 {{ modelTypes.image }} · 视频 {{ modelTypes.video }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-xs text-white/55">活跃用户 · 24h</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2">{{ fmtInt(dau) }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">近 7 天累计生成 {{ fmtInt(week.total) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Operations row: CDK / 邀请 / 签到 ===== -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<!-- 兑换码 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">兑换码 CDK</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-sky-500/15 text-sky-300 grid place-items-center ring-1 ring-sky-400/20">
|
||||
<Icon name="spark" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
|
||||
<span class="text-emerald-300">{{ fmtInt(cdk.active) }}</span>
|
||||
<span class="text-white/30 text-sm">未用 /</span>
|
||||
<span class="text-white/70 text-base">{{ fmtInt(cdk.redeemed) }} 已兑</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">
|
||||
待兑积分 <span class="text-amber-300 tabular-nums">{{ fmtCredits(cdk.active_amount) }}</span>
|
||||
· 已发出 <span class="tabular-nums">{{ fmtCredits(cdk.redeemed_amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">邀请</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-fuchsia-500/15 text-fuchsia-300 grid place-items-center ring-1 ring-fuchsia-400/20">
|
||||
<Icon name="accounts" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
|
||||
<span>{{ fmtInt(invites.total) }}</span>
|
||||
<span class="text-white/40 text-sm">邀请注册</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">
|
||||
<span class="text-emerald-300 tabular-nums">{{ fmtInt(invites.completed) }}</span> 已达成奖励
|
||||
· 已发 <span class="text-amber-300 tabular-nums">{{ fmtCredits(invites.reward_paid) }}</span> 积分
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 签到 -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/55">今日签到</span>
|
||||
<span class="w-7 h-7 rounded-lg bg-teal-500/15 text-teal-300 grid place-items-center ring-1 ring-teal-400/20">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
|
||||
<span>{{ fmtInt(checkin.today) }}</span>
|
||||
<span class="text-white/40 text-sm">人</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-white/45 mt-1">
|
||||
发放 <span class="text-amber-300 tabular-nums">{{ fmtCredits(checkin.awarded_today) }}</span> 积分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Provider health + 24h trend ===== -->
|
||||
<div class="grid lg:grid-cols-2 gap-3">
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">Provider 健康</h2>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||
:class="statusPill(overallHealth.tone)">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
|
||||
{{ overallHealth.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div v-if="!providerHealth.length" class="text-center text-xs text-white/40 py-8">未注册 Provider</div>
|
||||
<div v-else class="space-y-0.5">
|
||||
<div v-for="p in providerHealth" :key="p.name"
|
||||
class="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04] transition-colors">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium capitalize truncate">{{ p.name }}</div>
|
||||
<div class="text-[11px] text-white/45 mt-0.5">
|
||||
{{ p.model_count }} 模型 · token {{ p.tokens_active }}/{{ p.total || 0 }} 活跃
|
||||
<span v-if="p.tokens_quota"> · {{ p.tokens_quota }} 限额</span>
|
||||
<span v-if="p.tokens_disabled"> · {{ p.tokens_disabled }} 停用</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 tabular-nums"
|
||||
:class="statusPill(p.status)">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(p.status)"></span>
|
||||
{{ statusLabel(p.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold">24 小时生成趋势</h2>
|
||||
<div class="text-[11px] text-white/45 flex items-center gap-3">
|
||||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-indigo-400/80"></span>图像</span>
|
||||
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-fuchsia-400/80"></span>视频</span>
|
||||
<span class="tabular-nums">峰值 {{ hourMax }}/h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex items-end gap-[3px] h-32">
|
||||
<div v-for="(b, i) in hourBuckets" :key="i"
|
||||
class="group/bar relative flex-1 flex flex-col justify-end rounded-t overflow-visible"
|
||||
:style="{ height: Math.max(4, ((b.image + b.video) / hourMax) * 100) + '%' }">
|
||||
<!-- hover tooltip -->
|
||||
<div class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 z-10 hidden group-hover/bar:block
|
||||
whitespace-nowrap rounded-md bg-black/90 ring-1 ring-white/10 px-2 py-1 text-[10px] text-white/90 tabular-nums">
|
||||
{{ 23 - i }}h 前 · 图 {{ b.image }} / 视 {{ b.video }}
|
||||
</div>
|
||||
<div v-if="b.video" class="bg-fuchsia-400/80 group-hover/bar:bg-fuchsia-400" :style="{ flex: b.video }"></div>
|
||||
<div v-if="b.image" class="bg-indigo-400/80 group-hover/bar:bg-indigo-400" :style="{ flex: b.image }"></div>
|
||||
<div v-if="!b.image && !b.video" class="bg-white/[0.06] group-hover/bar:bg-white/15 flex-1 rounded-t"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-[10px] text-white/40 mt-2 tabular-nums">
|
||||
<span>-24h</span><span>-18h</span><span>-12h</span><span>-6h</span><span>现在</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Analytics (range-toggled): top models / failures / spenders ===== -->
|
||||
<div class="flex items-center justify-between gap-3 pt-1">
|
||||
<h2 class="text-sm font-semibold text-white/80">使用分析 · <span class="text-white/45 font-normal">{{ rangeLabel }}</span></h2>
|
||||
<div class="inline-flex rounded-lg bg-white/[0.04] ring-1 ring-white/[0.08] p-0.5 text-xs">
|
||||
<button @click="range = 'day'"
|
||||
class="px-3 py-1 rounded-md transition-colors"
|
||||
:class="range === 'day' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||
近 24h
|
||||
</button>
|
||||
<button @click="range = 'week'"
|
||||
class="px-3 py-1 rounded-md transition-colors"
|
||||
:class="range === 'week' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'">
|
||||
近 7d
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-3">
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06]">
|
||||
<h2 class="text-sm font-semibold">热门模型</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div v-if="!modelUsage.length" class="text-center text-xs text-white/40 py-6">尚无生成记录</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="m in modelUsage" :key="m.model" class="text-sm">
|
||||
<div class="flex items-baseline justify-between gap-3 mb-1">
|
||||
<span class="font-mono text-[12px] text-white/85 truncate">{{ m.model }}</span>
|
||||
<span class="flex items-baseline gap-2 shrink-0">
|
||||
<span v-if="m.avg_ms" class="text-[10px] text-white/40 tabular-nums">{{ fmtMs(m.avg_ms) }}</span>
|
||||
<span class="tabular-nums text-xs font-semibold">{{ m.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-violet-400 to-fuchsia-500"
|
||||
:style="{ width: ((m.count / usageMax) * 100) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06]">
|
||||
<h2 class="text-sm font-semibold">失败原因 Top</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div v-if="!failures.length" class="text-center text-xs text-white/40 py-6">{{ rangeLabel }}内没有失败 — 一切正常</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="f in failures" :key="f.reason"
|
||||
class="flex items-start gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04]">
|
||||
<span class="w-1.5 h-1.5 mt-1.5 rounded-full bg-rose-400 shrink-0"></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-white/85 break-all leading-snug">{{ f.reason }}</div>
|
||||
</div>
|
||||
<span class="tabular-nums text-xs font-semibold text-rose-300 shrink-0">×{{ f.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== User consumption + spend summary ===== -->
|
||||
<div class="grid lg:grid-cols-2 gap-3">
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06]">
|
||||
<h2 class="text-sm font-semibold">用户消耗 Top · {{ rangeLabel }}</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div v-if="!topUsers.length" class="text-center text-xs text-white/40 py-6">{{ rangeLabel }}内无消耗记录</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="u in topUsers" :key="u.user_id || u.name" class="text-sm">
|
||||
<div class="flex items-baseline justify-between gap-3 mb-1">
|
||||
<span class="text-[12px] text-white/85 truncate">{{ u.name }}</span>
|
||||
<span class="flex items-baseline gap-2 shrink-0">
|
||||
<span class="text-[10px] text-white/40 tabular-nums">{{ u.count }} 次</span>
|
||||
<span class="tabular-nums text-xs font-semibold text-amber-300">{{ fmtCredits(u.spent) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500"
|
||||
:style="{ width: ((u.spent / topUserMax) * 100) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06]">
|
||||
<h2 class="text-sm font-semibold">积分消耗概览</h2>
|
||||
</div>
|
||||
<div class="p-5 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-xs text-white/55">近 24 小时</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(day.spent) }}</div>
|
||||
<div class="text-[11px] text-white/40 mt-1">{{ day.success }} 次成功生成</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-white/55">近 7 天</div>
|
||||
<div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(week.spent) }}</div>
|
||||
<div class="text-[11px] text-white/40 mt-1">{{ week.success }} 次成功生成</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-white/55">单次均价</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-1">{{ day.success ? fmtCredits(Math.round(day.spent / day.success)) : '—' }}</div>
|
||||
<div class="text-[11px] text-white/40 mt-1">24h 平均</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-white/55">消耗用户数</div>
|
||||
<div class="text-xl font-semibold tabular-nums mt-1">{{ topUsers.length }}</div>
|
||||
<div class="text-[11px] text-white/40 mt-1">{{ rangeLabel }}有消耗</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Recent activity (full width) ===== -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold">最近活动</h2>
|
||||
<router-link to="/admin/logs" class="text-[11px] text-white/55 hover:text-white">查看全部 →</router-link>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div v-if="!recentLogs.length" class="text-center text-xs text-white/40 py-6">尚无活动</div>
|
||||
<div v-else>
|
||||
<div v-for="e in recentLogs" :key="e.id"
|
||||
class="flex items-center gap-3 px-2 py-2 text-xs rounded-lg hover:bg-white/[0.03]">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0" :class="logDot(e.status)"></span>
|
||||
<span class="text-[10px] uppercase tracking-wider font-medium w-9"
|
||||
:class="e.kind === 'video' ? 'text-fuchsia-300' : 'text-indigo-300'">
|
||||
{{ e.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
<span class="font-mono text-white/85 truncate w-40 shrink-0">{{ e.model }}</span>
|
||||
<span class="text-white/55 truncate flex-1 min-w-0">{{ e.prompt }}</span>
|
||||
<span class="text-white/40 tabular-nums whitespace-nowrap w-12 text-right">{{ fmtMs(e.elapsed_ms) }}</span>
|
||||
<span class="text-white/40 whitespace-nowrap text-right tabular-nums">{{ fmtTs(e.ts) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,648 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { auth, refreshMe } from '../auth'
|
||||
import { draft, applyJobToDraft } from '../playground'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import SelectMenu from '../components/SelectMenu.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
import { points, pointsLabel } from '../credits'
|
||||
import { sortResolutions } from '../utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// ---- credits: the logged-in user's REAL server-side balance ----
|
||||
const credits = computed(() => Number(auth.user?.credits || 0))
|
||||
|
||||
const allModels = ref([]) // managed-models list
|
||||
const presets = ref([]) // video family presets
|
||||
|
||||
// Seed every form field from the shared draft (module-level) so navigating
|
||||
// away from the page and coming back keeps the prompt + selected model +
|
||||
// params. Each local ref then syncs back into the draft on change.
|
||||
const mode = ref(draft.mode || 'image')
|
||||
const modelId = ref(draft.modelId || '')
|
||||
const prompt = ref(draft.prompt || '')
|
||||
const ratio = ref(draft.ratio || '')
|
||||
const resolution = ref(draft.resolution || '')
|
||||
const duration = ref(draft.duration || '')
|
||||
|
||||
watch(mode, (v) => { draft.mode = v })
|
||||
watch(modelId, (v) => { draft.modelId = v })
|
||||
watch(prompt, (v) => { draft.prompt = v })
|
||||
watch(ratio, (v) => { draft.ratio = v })
|
||||
watch(resolution, (v) => { draft.resolution = v })
|
||||
watch(duration, (v) => { draft.duration = v })
|
||||
|
||||
const refImages = ref([]) // [{ name, dataUrl }]
|
||||
const fileInput = ref(null)
|
||||
|
||||
const busy = ref(false)
|
||||
// submitting = run() owns the busy/current state end-to-end while a /generate
|
||||
// call is in flight. The 2s poll() must NOT touch busy or current during this
|
||||
// window, or it races run() and the controls flicker unlocked mid-generation.
|
||||
const submitting = ref(false)
|
||||
// Gateway-timeout statuses our BACKEND never emits — they mean a CDN/proxy
|
||||
// (e.g. EdgeOne 524) gave up waiting while the synchronous /generate is STILL
|
||||
// rendering server-side. Treat these as "still running", NOT a failure: keep the
|
||||
// controls locked and let poll() follow the live job to completion (出图).
|
||||
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
||||
const error = ref('')
|
||||
const statusText = ref('')
|
||||
|
||||
// Only ever show the latest generation on the right side. Each new run
|
||||
// replaces it; the persistent history lives at /logs (UserLogsView).
|
||||
// `current` is restored from the server on mount and refreshed via /jobs/mine
|
||||
// polling, so a reload, a parallel tab, or a different browser sees the same
|
||||
// in-flight job and the same final result without re-running anything.
|
||||
const current = ref(null)
|
||||
const lightbox = ref(null)
|
||||
const toast = ref('')
|
||||
let pollTimer = null
|
||||
|
||||
// ---- derived ----
|
||||
const models = computed(() =>
|
||||
allModels.value.filter((m) => m.enabled !== false && m.type === mode.value),
|
||||
)
|
||||
const modelOptions = computed(() =>
|
||||
models.value.map((m) => ({ value: m.id, label: m.name || m.id })),
|
||||
)
|
||||
const model = computed(() => allModels.value.find((m) => m.id === modelId.value) || null)
|
||||
const familyPreset = computed(() => {
|
||||
if (mode.value !== 'video' || !model.value) return null
|
||||
return presets.value.find((p) => p.key === model.value.id) || null
|
||||
})
|
||||
|
||||
const ratios = computed(() => {
|
||||
const fromModel = model.value?.ratios || []
|
||||
if (fromModel.length) return fromModel
|
||||
return mode.value === 'video' ? ['16x9'] : ['1:1']
|
||||
})
|
||||
// Firefly Image 5 instruct-edit derives the aspect ratio from the reference
|
||||
// image — hide the ratio picker (backend also omits aspectRatio) when a ref is
|
||||
// attached, otherwise the request is rejected with a validation error.
|
||||
const showRatio = computed(() => !(modelId.value === 'firefly-image-5' && refImages.value.length > 0))
|
||||
const resolutions = computed(() => {
|
||||
const fromModel = model.value?.resolutions || []
|
||||
if (fromModel.length) return sortResolutions(fromModel)
|
||||
// Legacy record with no declared tiers: fall back to the priced tiers so we
|
||||
// never offer (or default to) a resolution the server has no price for.
|
||||
const priced = Object.keys(model.value?.prices || {})
|
||||
if (priced.length) return sortResolutions(priced)
|
||||
return mode.value === 'video' ? ['720p'] : ['1K']
|
||||
})
|
||||
const durations = computed(() => {
|
||||
// duration_prices arrives as a JSON object whose keys Go sorts alphabetically
|
||||
// ("10s" before "5s"). Re-sort by the numeric seconds so the shortest is first.
|
||||
const keys = Object.keys(model.value?.duration_prices || {})
|
||||
.sort((a, b) => parseFloat(a) - parseFloat(b))
|
||||
if (keys.length) return keys
|
||||
return familyPreset.value?.durations || ['5s']
|
||||
})
|
||||
|
||||
const maxRefs = computed(() => {
|
||||
if (mode.value === 'video') {
|
||||
const a = Number(familyPreset.value?.max_reference_images || 0)
|
||||
const b = Number(model.value?.max_reference_images || 0)
|
||||
return Math.max(a, b)
|
||||
}
|
||||
// Image-to-image: honor the model's configured max (gpt-image-2=3,
|
||||
// seedream-4.5=6, flux-klein-2=4 …). Fall back to 1 when image_to_image is on
|
||||
// but no count was set.
|
||||
const m = Number(model.value?.max_reference_images || 0)
|
||||
if (m > 0) return m
|
||||
return model.value?.image_to_image ? 1 : 0
|
||||
})
|
||||
const refMode = computed(() => familyPreset.value?.reference_mode || model.value?.reference_mode || 'none')
|
||||
// Most video models (veo31, luma) support pure text2video, so refs are optional.
|
||||
// A model can opt into strict image-to-video by declaring `requires_reference`
|
||||
// in its preset (e.g. runway-gen4-turbo) — then a first-frame image is mandatory.
|
||||
const refsRequired = computed(() =>
|
||||
mode.value === 'video' && !!familyPreset.value?.requires_reference)
|
||||
|
||||
// ---- price (per generation, derived from selected model + params) ----
|
||||
// 代理用户走代理价:某档设了代理价就用它,否则回退普通价(支持的档位始终由普通价决定)。
|
||||
const isAgent = computed(() => auth.user?.role === 'agent')
|
||||
function tierPrice(normalMap, agentMap, key) {
|
||||
const n = (normalMap || {})[key]
|
||||
if (n == null) return null // 不支持该档(由普通价决定)
|
||||
if (isAgent.value) {
|
||||
const a = (agentMap || {})[key]
|
||||
if (a != null) return Number(a)
|
||||
}
|
||||
return Number(n)
|
||||
}
|
||||
const price = computed(() => {
|
||||
if (!model.value) return null
|
||||
const m = model.value
|
||||
if (mode.value === 'video') {
|
||||
const rp = tierPrice(m.prices, m.prices_agent, resolution.value)
|
||||
const dp = tierPrice(m.duration_prices, m.duration_prices_agent, duration.value)
|
||||
if (rp == null || dp == null) return null
|
||||
return rp + dp
|
||||
}
|
||||
return tierPrice(m.prices, m.prices_agent, resolution.value)
|
||||
})
|
||||
const priceLabel = computed(() => price.value == null ? '—' : pointsLabel(price.value))
|
||||
const canAfford = computed(() => price.value == null || credits.value >= price.value)
|
||||
|
||||
// ---- helpers ----
|
||||
function firstOf(arr) {
|
||||
return (arr && arr.length) ? arr[0] : ''
|
||||
}
|
||||
// Selecting a model (or switching image/video) resets each picker to that
|
||||
// model's FIRST tier — the default should always be the first option, not
|
||||
// whatever was carried over from the previously-selected model.
|
||||
function applyModelDefaults() {
|
||||
ratio.value = firstOf(ratios.value)
|
||||
resolution.value = firstOf(resolutions.value)
|
||||
duration.value = firstOf(durations.value)
|
||||
// 切换模型保留已上传的参考图,只按新模型的上限裁剪(上限为 0 则清空)。
|
||||
if (refImages.value.length > maxRefs.value) {
|
||||
refImages.value = refImages.value.slice(0, maxRefs.value)
|
||||
}
|
||||
}
|
||||
function selectModel(id) {
|
||||
modelId.value = id
|
||||
applyModelDefaults()
|
||||
}
|
||||
|
||||
function setMode(m) {
|
||||
if (mode.value === m) return
|
||||
mode.value = m
|
||||
// pick a default model of the new kind, if any
|
||||
const first = allModels.value.find((x) => x.enabled !== false && x.type === m)
|
||||
modelId.value = first?.id || ''
|
||||
applyModelDefaults()
|
||||
}
|
||||
|
||||
function openPicker() { fileInput.value && fileInput.value.click() }
|
||||
// Backend rejects reference images over 8MB (maxReferenceImageBytes). Enforce it
|
||||
// here at pick time so an oversized image fails fast with a clear message instead
|
||||
// of charging + failing upstream after the upload.
|
||||
const MAX_REF_BYTES = 8 * 1024 * 1024
|
||||
function onFiles(ev) {
|
||||
const files = Array.from(ev.target.files || [])
|
||||
const room = Math.max(0, maxRefs.value - refImages.value.length)
|
||||
const tooBig = []
|
||||
let added = 0
|
||||
for (const f of files) {
|
||||
if (added >= room) break
|
||||
if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue }
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => refImages.value.push({ name: f.name, dataUrl: reader.result })
|
||||
reader.readAsDataURL(f)
|
||||
added++
|
||||
}
|
||||
error.value = tooBig.length
|
||||
? `图片超过 8MB 已跳过:${tooBig.join('、')}(请压缩后再传)`
|
||||
: ''
|
||||
if (ev.target) ev.target.value = ''
|
||||
}
|
||||
function removeRef(i) { refImages.value.splice(i, 1) }
|
||||
|
||||
// Re-hydrate reference thumbnails from server URLs (after a reload). Fetches
|
||||
// each /images URL (same-origin, cookie-authed) and converts to a data URL so
|
||||
// the thumbnail renders AND the ref can be re-submitted unchanged. Shared by
|
||||
// image and video — both persist their refs the same way server-side.
|
||||
// Re-display refs by URL only — the thumbnail renders straight from /images/<ref>.
|
||||
// We DON'T fetch+convert here; conversion to base64 happens lazily at submit time
|
||||
// (refToBase64), so a ref that's only viewed never needs a network round-trip.
|
||||
function restoreRefs(urls) {
|
||||
if (!Array.isArray(urls) || !urls.length) return
|
||||
if (refImages.value.length) return // don't clobber refs the user already added
|
||||
refImages.value = urls.map((u) => ({ name: 'ref', url: u }))
|
||||
}
|
||||
|
||||
// refToBase64 yields the raw base64 the backend expects, from either a freshly
|
||||
// uploaded ref (dataUrl) or a restored one (url → fetch). Returns '' on failure.
|
||||
async function refToBase64(r) {
|
||||
try {
|
||||
if (r.dataUrl) return r.dataUrl.replace(/^data:[^,]*,/, '')
|
||||
if (r.url) {
|
||||
const blob = await (await fetch(r.url)).blob()
|
||||
const dataUrl = await new Promise((res, rej) => {
|
||||
const fr = new FileReader()
|
||||
fr.onload = () => res(fr.result)
|
||||
fr.onerror = rej
|
||||
fr.readAsDataURL(blob)
|
||||
})
|
||||
return dataUrl.replace(/^data:[^,]*,/, '')
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return ''
|
||||
}
|
||||
|
||||
let toastTimer = null
|
||||
function flash(msg) {
|
||||
toast.value = msg
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toast.value = ''), 1800)
|
||||
}
|
||||
|
||||
async function copyLink(url) {
|
||||
try {
|
||||
const abs = url.startsWith('http') ? url : location.origin + url
|
||||
await navigator.clipboard.writeText(abs)
|
||||
flash('链接已复制')
|
||||
} catch {
|
||||
flash('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- generate ----
|
||||
async function run() {
|
||||
if (!modelId.value) { error.value = '请选择模型'; return }
|
||||
if (!prompt.value.trim()) { error.value = '请输入提示词'; return }
|
||||
if (refsRequired.value && refImages.value.length < 1) {
|
||||
error.value = '该视频模型需要至少 1 张参考图 (首帧)'
|
||||
return
|
||||
}
|
||||
if (price.value == null) {
|
||||
error.value = '该参数组合未定价 (留空 = 不支持)'
|
||||
return
|
||||
}
|
||||
if (!canAfford.value) {
|
||||
error.value = `积分不足 — 需要 ${pointsLabel(price.value)},余额 ${pointsLabel(credits.value)}`
|
||||
return
|
||||
}
|
||||
const job = {
|
||||
id: Math.random().toString(36).slice(2, 10),
|
||||
model: modelId.value,
|
||||
kind: mode.value,
|
||||
prompt: prompt.value,
|
||||
ratio: ratio.value,
|
||||
resolution: resolution.value,
|
||||
duration: mode.value === 'video' ? duration.value : '',
|
||||
refs: refImages.value.length,
|
||||
status: 'pending',
|
||||
url: '',
|
||||
error: '',
|
||||
elapsed_ms: 0,
|
||||
charged: price.value,
|
||||
ts: Date.now(),
|
||||
}
|
||||
current.value = job
|
||||
|
||||
busy.value = true
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
statusText.value = mode.value === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
|
||||
try {
|
||||
// Optimistically deduct the price from the displayed balance right away. The
|
||||
// server debits BEFORE generating (which can take minutes for video), so
|
||||
// otherwise 余额 looks unchanged the whole time. The success response carries
|
||||
// the authoritative balance (reconciled below); a failure refunds + refreshMe.
|
||||
if (auth.user && price.value != null) {
|
||||
auth.user.credits = Math.max(0, Number(auth.user.credits || 0) - price.value)
|
||||
}
|
||||
|
||||
const payload = {
|
||||
model: modelId.value,
|
||||
prompt: prompt.value,
|
||||
ratio: ratio.value,
|
||||
resolution: resolution.value,
|
||||
}
|
||||
if (mode.value === 'video') payload.duration = duration.value
|
||||
if (refImages.value.length) {
|
||||
// Backend accepts raw base64 only — convert each ref (uploaded dataUrl or
|
||||
// restored /images URL) to base64 at submit time.
|
||||
const refs = await Promise.all(refImages.value.map(refToBase64))
|
||||
payload.reference_images = refs.filter(Boolean)
|
||||
}
|
||||
|
||||
// Single charged call: the server debits the price atomically BEFORE
|
||||
// generating and refunds on failure, so the client can't skip the charge.
|
||||
const r = await api('/generate', jsonBody('POST', payload))
|
||||
|
||||
if (r.ok && r.data?.url) {
|
||||
job.status = 'done'
|
||||
job.url = r.data.url
|
||||
job.elapsed_ms = r.data.elapsed_ms
|
||||
job.charged = r.data.charged ?? price.value
|
||||
if (auth.user && r.data.credits != null) auth.user.credits = r.data.credits
|
||||
statusText.value = `完成 · 扣费 ${pointsLabel(job.charged)} · ${(r.data.elapsed_ms / 1000).toFixed(1)}s · 余额 ${pointsLabel(credits.value)}`
|
||||
busy.value = false // 出图 → 解锁
|
||||
} else if (GATEWAY_TIMEOUT.has(r.status)) {
|
||||
// CDN/代理回源超时(如 EdgeOne 524)—— 后端仍在生成。不当失败、不解锁:
|
||||
// 保持 busy=true,交给下面的 poll() + 2s 轮询跟到出图("不出图就不闪")。
|
||||
statusText.value = mode.value === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
} else {
|
||||
// 真失败:服务端已退款,resync 余额并解锁。
|
||||
await refreshMe()
|
||||
job.status = 'failed'
|
||||
job.error = r.data?.detail || `失败 (${r.status})`
|
||||
statusText.value = ''
|
||||
busy.value = false // 真失败 → 解锁
|
||||
}
|
||||
} finally {
|
||||
// Hand control back to poll(); busy is left as set above (locked when the
|
||||
// job is still rendering after a gateway timeout).
|
||||
submitting.value = false
|
||||
}
|
||||
// Sync real server state: poll() picks up the live pending job (replacing our
|
||||
// optimistic one with the real id) and will unlock + show the result the
|
||||
// moment it finishes — so a 524 mid-flight never leaves the UI unlocked.
|
||||
poll()
|
||||
}
|
||||
|
||||
// Recover the current generation from the server: any pending job for this
|
||||
// user lives in event_log, so reload / parallel tab / parallel browser can
|
||||
// all see the same in-flight state and the same final result.
|
||||
async function poll() {
|
||||
// While run() is mid-submit it fully owns busy/current — don't race it.
|
||||
if (submitting.value) return
|
||||
const r = await api('/jobs/mine')
|
||||
if (!r.ok) return
|
||||
const { pending, latest } = r.data || {}
|
||||
if (pending) {
|
||||
busy.value = true
|
||||
if (!statusText.value) {
|
||||
statusText.value = pending.kind === 'video' ? '生成视频中 (约 1–3 分钟)…' : '生成中…'
|
||||
}
|
||||
if (!current.value || current.value.id !== pending.id) {
|
||||
current.value = { ...pending }
|
||||
// Replay the pending job's params onto the form so a fresh tab shows
|
||||
// what's cooking — and writes them into the cross-component draft.
|
||||
applyJobToDraft(pending)
|
||||
mode.value = pending.kind === 'video' ? 'video' : 'image'
|
||||
modelId.value = pending.model || modelId.value
|
||||
prompt.value = pending.prompt || prompt.value
|
||||
ratio.value = pending.ratio || ratio.value
|
||||
resolution.value = pending.resolution || resolution.value
|
||||
duration.value = pending.duration || duration.value
|
||||
// Re-display the uploaded reference image(s) after a reload. They're
|
||||
// served (cookie-authed) from /images; re-fetch into data URLs so the
|
||||
// thumbnails show AND the refs stay re-submittable if the user regenerates.
|
||||
restoreRefs(pending.reference_urls)
|
||||
}
|
||||
return
|
||||
}
|
||||
// No pending. If our locally-shown job just finished on the server (same id,
|
||||
// status flipped to success/failed), promote it to the result view — this is
|
||||
// the live "I'm watching my own generation finish" case and stays.
|
||||
if (current.value && current.value.status === 'pending' && latest && latest.id === current.value.id) {
|
||||
current.value = { ...latest, status: latest.status === 'success' ? 'done' : latest.status }
|
||||
if (latest.url) current.value.url = latest.url
|
||||
busy.value = false
|
||||
statusText.value = ''
|
||||
refreshMe()
|
||||
return
|
||||
}
|
||||
// Intentionally NO restore of an already-finished result on first paint /
|
||||
// navigation: the playground only ever shows an in-progress job (or the one
|
||||
// that just completed while watched). Past results live in /记录 (logs), not
|
||||
// re-echoed onto a freshly opened workspace.
|
||||
}
|
||||
|
||||
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
||||
|
||||
onMounted(async () => {
|
||||
refreshMe() // pull the latest real balance
|
||||
const [mm, pp] = await Promise.all([api('/managed-models'), api('/video-presets')])
|
||||
allModels.value = mm.data?.data || []
|
||||
presets.value = pp.data?.data || []
|
||||
// Pre-fill from query string (?prompt=...&model=...) — used by the home
|
||||
// page's example cards to seed the form in one click.
|
||||
const qPrompt = String(route.query.prompt || '')
|
||||
const qModel = String(route.query.model || '')
|
||||
if (qPrompt) prompt.value = qPrompt
|
||||
let selected = null
|
||||
if (qModel) {
|
||||
selected = allModels.value.find((m) => m.id === qModel && m.enabled !== false)
|
||||
}
|
||||
// If the draft already points at a still-available model AND no fresher
|
||||
// intent came in from the URL, keep the draft as-is. Otherwise fall back to
|
||||
// the first usable model.
|
||||
const draftModel = !qModel && modelId.value
|
||||
? allModels.value.find((m) => m.id === modelId.value && m.enabled !== false)
|
||||
: null
|
||||
if (!selected && !draftModel) {
|
||||
selected = allModels.value.find((m) => m.enabled !== false && m.type === 'image')
|
||||
|| allModels.value.find((m) => m.enabled !== false)
|
||||
}
|
||||
// Always re-apply defaults — even when restoring the persisted draft model —
|
||||
// so a stale ratio/resolution that the model no longer supports (e.g. a saved
|
||||
// "2K" for a model that's now 1K-only) is normalized to a valid, priced tier
|
||||
// instead of being sent as-is and rejected with "unsupported or unpriced".
|
||||
const chosen = selected || draftModel
|
||||
if (chosen) {
|
||||
mode.value = chosen.type
|
||||
modelId.value = chosen.id
|
||||
applyModelDefaults()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
// Restore any in-flight or recently-finished job for this user, then poll
|
||||
// every 2s so a parallel tab / device sees changes within one tick.
|
||||
poll()
|
||||
pollTimer = setInterval(poll, 2000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="theme-text grid lg:grid-cols-[420px_1fr] gap-6">
|
||||
<!-- LEFT: controls — every interactive element accepts :disabled="busy"
|
||||
so the form locks the moment a generation kicks off. Reload, parallel
|
||||
tab and tab-switch all see the same locked state via poll(). -->
|
||||
<div class="card p-5 space-y-5 lg:sticky lg:top-24 self-start">
|
||||
<!-- mode switch -->
|
||||
<div class="grid grid-cols-2 gap-2 p-1 bg-slate-100 rounded-xl">
|
||||
<button @click="setMode('image')" type="button" :disabled="busy"
|
||||
class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed"
|
||||
:class="mode === 'image' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
|
||||
<Icon name="files" class="w-4 h-4 inline -mt-0.5" /> 生图
|
||||
</button>
|
||||
<button @click="setMode('video')" type="button" :disabled="busy"
|
||||
class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed"
|
||||
:class="mode === 'video' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
|
||||
<Icon name="video" class="w-4 h-4 inline -mt-0.5" /> 生视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- model -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">模型</label>
|
||||
<SelectMenu v-if="models.length" :model-value="modelId" @update:model-value="selectModel"
|
||||
:options="modelOptions" placeholder="选择模型" mono :disabled="busy" />
|
||||
<div v-else class="rounded-lg border border-dashed border-slate-200 px-3 py-4 text-xs text-slate-400 text-center">
|
||||
还没有可用的{{ mode === 'video' ? '视频' : '图像' }}模型 ·
|
||||
<router-link to="/admin/models" class="text-slate-700 underline">去添加</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- prompt -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">提示词</label>
|
||||
<textarea v-model="prompt" rows="4" :disabled="busy" class="field resize-none disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
placeholder="描述想要的画面…如:黄昏时分,金色麦田里奔跑的金毛猎犬,电影感"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- ratio + res + duration. Single-option controls are hidden — the
|
||||
value is still set from the model's defaults and sent to the API,
|
||||
so the user doesn't have to acknowledge a choice they don't have. -->
|
||||
<div v-if="ratios.length > 0 && showRatio">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">比例</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="r in ratios" :key="r" type="button" @click="ratio = r" :disabled="busy"
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="ratio === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="resolutions.length > 0">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">{{ mode === 'video' ? '分辨率' : '画质' }}</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="r in resolutions" :key="r" type="button" @click="resolution = r" :disabled="busy"
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="resolution === r ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'video' && durations.length > 0">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">时长</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="d in durations" :key="d" type="button" @click="duration = d" :disabled="busy"
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="duration === d ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
|
||||
{{ d }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- reference images -->
|
||||
<div v-if="maxRefs > 0">
|
||||
<label class="block text-xs font-medium text-slate-500 mb-1.5">
|
||||
参考图
|
||||
<span class="text-slate-400 font-normal">
|
||||
(最多 {{ maxRefs }} 张{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单张 ≤8MB)
|
||||
</span>
|
||||
<span v-if="refsRequired" class="text-rose-500">*</span>
|
||||
</label>
|
||||
<div class="flex gap-2 flex-wrap items-start">
|
||||
<div v-for="(img, i) in refImages" :key="i"
|
||||
class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all"
|
||||
:class="busy ? 'opacity-60 grayscale pointer-events-none' : ''">
|
||||
<img :src="img.dataUrl || img.url" class="w-full h-full object-cover" />
|
||||
<button type="button" @click="removeRef(i)" :disabled="busy"
|
||||
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-slate-900/70 text-white hover:bg-rose-500 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<Icon name="close" class="w-3 h-3" />
|
||||
</button>
|
||||
<div v-if="refMode === 'frame' && mode === 'video' && maxRefs >= 2"
|
||||
class="absolute bottom-0 inset-x-0 text-[10px] text-white bg-slate-900/60 text-center py-0.5">
|
||||
{{ i === 0 ? '首帧' : (i === 1 ? '末帧' : '') }}
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="refImages.length < maxRefs" type="button" @click="openPicker" :disabled="busy"
|
||||
class="w-20 h-20 rounded-lg border-2 border-dashed border-slate-200 text-slate-400 hover:bg-slate-50 hover:border-slate-300 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<Icon name="plus" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="onFiles" />
|
||||
</div>
|
||||
|
||||
<button @click="run" :disabled="busy || !models.length || price == null || !canAfford"
|
||||
class="btn-primary w-full !py-3 flex items-center justify-center gap-2 leading-none">
|
||||
<Icon name="spark" class="w-4 h-4 shrink-0" />
|
||||
<span class="leading-none">{{ busy ? (mode === 'video' ? '生成中…请耐心等待' : '生成中…') : '生成' }}</span>
|
||||
<span v-if="!busy && price != null" class="text-xs opacity-70 tabular-nums leading-none">· {{ priceLabel }}</span>
|
||||
<span v-if="!busy && price != null && !canAfford" class="text-xs text-rose-200 leading-none">积分不足</span>
|
||||
</button>
|
||||
|
||||
<!-- Validation / upload errors (model/prompt/ref/price/credits/oversized
|
||||
image). The `error` ref had no render target before, so these messages
|
||||
were silently swallowed. -->
|
||||
<p v-if="error" class="text-xs text-rose-500 break-all">{{ error }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: single latest result (replaces on each new generation).
|
||||
min-w-0: the 1fr grid track defaults to min-width:auto, so a long
|
||||
unbroken prompt would otherwise blow the column wider than the page
|
||||
(truncate can't shrink a track that won't shrink). -->
|
||||
<div class="space-y-4 min-w-0">
|
||||
<div v-if="!current && !busy"
|
||||
class="card p-14 grid place-items-center text-slate-400 text-center">
|
||||
<span class="w-16 h-16 rounded-2xl bg-slate-100 grid place-items-center mb-4">
|
||||
<Icon name="spark" class="w-7 h-7 text-slate-400" />
|
||||
</span>
|
||||
<p class="text-sm">还没有生成过 — 在左侧写提示词,点击「生成」</p>
|
||||
<router-link to="/logs" class="text-xs text-slate-500 hover:text-white mt-3 transition-colors">查看历史记录 →</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else-if="current" class="card overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-slate-100 flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium line-clamp-2 break-words">{{ current.prompt }}</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5 font-mono">
|
||||
{{ current.model }} · {{ current.ratio }} · {{ current.resolution }}
|
||||
<span v-if="current.kind === 'video'"> · {{ current.duration }}</span>
|
||||
<span v-if="current.elapsed_ms"> · {{ (current.elapsed_ms / 1000).toFixed(1) }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- only when a finished result exists — hidden while pending/failed -->
|
||||
<div v-if="current.url && current.status !== 'pending' && current.status !== 'failed'"
|
||||
class="flex items-center gap-1.5 shrink-0">
|
||||
<a :href="current.url" :download="''" class="btn-soft" title="下载">
|
||||
<Icon name="download" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<button @click="copyLink(current.url)" class="btn-soft" title="复制链接">
|
||||
<Icon name="copy" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 grid place-items-center min-h-[260px]">
|
||||
<div v-if="current.status === 'pending'" class="text-sm text-slate-400 py-12 flex flex-col items-center gap-2">
|
||||
<span class="w-10 h-10 rounded-xl bg-white grid place-items-center animate-pulse">
|
||||
<Icon name="spark" class="w-4 h-4 text-slate-400" />
|
||||
</span>
|
||||
{{ statusText || '生成中…' }}
|
||||
</div>
|
||||
<div v-else-if="current.status === 'failed'" class="text-sm text-rose-600 py-12 px-5 max-w-xl text-center">
|
||||
<div class="font-medium mb-1">生成失败</div>
|
||||
<div class="text-xs text-rose-500 break-all">{{ current.error }}</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<video v-if="current.kind === 'video'" :src="current.url" controls
|
||||
class="max-w-full max-h-[600px] object-contain" />
|
||||
<img v-else :src="current.url" @click="lightbox = current"
|
||||
class="max-w-full max-h-[600px] object-contain cursor-zoom-in" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox — shared component, consistent with 图片管理 / 日志 -->
|
||||
<MediaLightbox
|
||||
v-if="lightbox"
|
||||
:src="lightbox.url"
|
||||
:kind="lightbox.kind"
|
||||
:prompt="lightbox.prompt"
|
||||
:meta="[lightbox.model, lightbox.ratio, lightbox.resolution, (lightbox.kind === 'video' ? lightbox.duration : '')].filter(Boolean).join(' · ')"
|
||||
:download-name="(lightbox.url || '').split('/').pop()"
|
||||
@close="lightbox = null" />
|
||||
|
||||
<!-- Toast -->
|
||||
<transition name="fade">
|
||||
<div v-if="toast"
|
||||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg">
|
||||
{{ toast }}
|
||||
</div>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { auth, refreshMe, logout as authLogout } from '../auth'
|
||||
import { api, jsonBody } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import { points, pointsLabel } from '../credits'
|
||||
import { site } from '../site'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Pull the latest server-side credits + check-in state when the page opens
|
||||
// (so an admin adjustment, or a check-in from another tab, shows up).
|
||||
onMounted(refreshMe)
|
||||
|
||||
// Hide the check-in card when the admin has disabled the feature, and read the
|
||||
// daily reward amount from the same config (admin-configurable, not hardcoded).
|
||||
const checkinEnabled = ref(true)
|
||||
const checkinReward = ref(3) // fallback until /auth/config loads
|
||||
onMounted(async () => {
|
||||
const r = await api('/auth/config')
|
||||
if (r.ok) {
|
||||
checkinEnabled.value = r.data.checkin_enabled !== false
|
||||
checkinReward.value = Number(r.data.checkin_reward) || 0
|
||||
}
|
||||
})
|
||||
|
||||
// ---- API Key (REAL: minted + verified server-side; OpenAI-compatible) ----
|
||||
// The full plaintext is returned only once, right after minting. On reload we
|
||||
// only have the server's masked preview (the plaintext is never stored).
|
||||
const apiKey = ref('') // full plaintext — present only right after minting
|
||||
const keyPreview = ref('') // masked preview from the server (persists)
|
||||
const apiKeyRevealed = ref(false)
|
||||
const hasKey = computed(() => !!apiKey.value || !!keyPreview.value)
|
||||
|
||||
async function loadKey() {
|
||||
const r = await api('/auth/api-key')
|
||||
if (r.ok) keyPreview.value = r.data?.key?.key_preview || ''
|
||||
}
|
||||
onMounted(loadKey)
|
||||
|
||||
// Uniform mask for the hidden / persisted state — always "***" so the just-minted
|
||||
// preview and the server-stored preview read the same (no sk-xxx…yyyy mismatch).
|
||||
const maskedKey = computed(() => (apiKey.value || keyPreview.value) ? '***' : '')
|
||||
|
||||
async function generateKey() {
|
||||
if (hasKey.value && !confirm('已有 Key — 重新生成会让旧 Key 立刻失效,确认?')) return
|
||||
const r = await api('/auth/api-key', jsonBody('POST', {}))
|
||||
if (!r.ok) { toast(r.data?.detail || '生成失败'); return }
|
||||
apiKey.value = r.data.key
|
||||
keyPreview.value = r.data.preview || ''
|
||||
apiKeyRevealed.value = true
|
||||
toast('新 Key 已生成 — 请立刻复制保存(只显示这一次)')
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
if (!hasKey.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiKey.value || keyPreview.value)
|
||||
toast(apiKey.value ? '已复制完整 Key' : '完整 Key 仅生成时显示一次,这里只能复制预览')
|
||||
} catch { toast('复制失败') }
|
||||
}
|
||||
|
||||
async function clearKey() {
|
||||
if (!confirm('清除 Key? 之后用该 Key 的调用都会失败。')) return
|
||||
const r = await api('/auth/api-key', { method: 'DELETE' })
|
||||
if (!r.ok) { toast(r.data?.detail || '清除失败'); return }
|
||||
apiKey.value = ''; keyPreview.value = ''; apiKeyRevealed.value = false
|
||||
toast('已清除 API Key')
|
||||
}
|
||||
|
||||
// ---- Password (real: verifies current password server-side) ----
|
||||
const pwdForm = ref({ current: '', next: '', confirm: '' })
|
||||
const pwdSubmitting = ref(false)
|
||||
const pwdError = ref('')
|
||||
async function changePwd() {
|
||||
pwdError.value = ''
|
||||
if (!pwdForm.value.current) { pwdError.value = '请输入当前密码'; return }
|
||||
const next = pwdForm.value.next || ''
|
||||
if (next.length < 8 || next.length > 24) { pwdError.value = '新密码长度需为 8-24 位'; return }
|
||||
if (!/[A-Z]/.test(next) || !/[a-z]/.test(next) || !/\d/.test(next) || !/[()~!@#$%^&*\-_=|{}\[\]:;'<>.,?/]/.test(next)) {
|
||||
pwdError.value = '新密码必须包含大写字母、小写字母、数字和符号'
|
||||
return
|
||||
}
|
||||
if (pwdForm.value.next !== pwdForm.value.confirm) { pwdError.value = '两次输入的新密码不一致'; return }
|
||||
pwdSubmitting.value = true
|
||||
const r = await api('/auth/change-password', jsonBody('POST', {
|
||||
current_password: pwdForm.value.current,
|
||||
new_password: pwdForm.value.next,
|
||||
}))
|
||||
pwdSubmitting.value = false
|
||||
if (!r.ok) { pwdError.value = r.data?.detail || '修改失败'; return }
|
||||
pwdForm.value = { current: '', next: '', confirm: '' }
|
||||
toast('密码已更新')
|
||||
}
|
||||
|
||||
// ---- Credits: the REAL server-side balance of the logged-in user ----
|
||||
// (admin adjustments in 用户管理 write this same field). Top-up is via CDK.
|
||||
const balance = computed(() => Number(auth.user?.credits || 0))
|
||||
|
||||
const cdkCode = ref('')
|
||||
const cdkBusy = ref(false)
|
||||
const cdkError = ref('')
|
||||
async function redeemCdk() {
|
||||
cdkError.value = ''
|
||||
const code = cdkCode.value.trim()
|
||||
if (!code) { cdkError.value = '请输入兑换码'; return }
|
||||
cdkBusy.value = true
|
||||
const r = await api('/auth/redeem-cdk', jsonBody('POST', { code }))
|
||||
cdkBusy.value = false
|
||||
if (!r.ok) { cdkError.value = r.data?.detail || '兑换失败'; return }
|
||||
if (auth.user) auth.user.credits = r.data.credits
|
||||
cdkCode.value = ''
|
||||
toast(`兑换成功 +${Number(r.data.amount).toLocaleString('en-US')} 积分`)
|
||||
}
|
||||
|
||||
// ---- Daily check-in ----
|
||||
// Reward amount lives in `checkinReward` (loaded from /auth/config above).
|
||||
|
||||
// Streak + "checked today" come from the server account (date authority is
|
||||
// the backend, so timezone quirks can't desync the disabled state).
|
||||
const streak = computed(() => Number(auth.user?.checkin_streak || 0))
|
||||
const checkedToday = computed(() => !!auth.user?.checkin_today)
|
||||
const last7 = computed(() => {
|
||||
const out = []
|
||||
const today = new Date()
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(today); d.setDate(today.getDate() - i)
|
||||
const ds = d.toISOString().slice(0, 10)
|
||||
const isToday = i === 0
|
||||
// if streak >= i+1 and today checked, then day (today - i) was within streak
|
||||
const lit = checkedToday.value && i < streak.value
|
||||
out.push({ ds, day: d.getDate(), isToday, lit })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
async function checkin() {
|
||||
if (checkedToday.value) return
|
||||
const r = await api('/auth/checkin', jsonBody('POST', {}))
|
||||
if (!r.ok) { toast(r.data?.detail || '签到失败'); return }
|
||||
await refreshMe() // refresh real balance + streak + checkin_today
|
||||
const d = r.data || {}
|
||||
if (d.already) { toast('今日已签到'); return }
|
||||
toast(`签到成功 +${pointsLabel(d.awarded)} → 余额 ${pointsLabel(balance.value)}`)
|
||||
}
|
||||
|
||||
// ---- Logout (real: ends the server session, then returns home) ----
|
||||
async function logout() {
|
||||
if (!confirm('确定要退出登录?')) return
|
||||
await authLogout()
|
||||
// Clear local-only leftovers (API key + legacy demo keys). Credits/streak are
|
||||
// computed from auth.user, which authLogout() has already cleared.
|
||||
const stale = ['gw_api_key', 'gw_credits', 'gw_checkin_last', 'gw_checkin_streak',
|
||||
'gw_invite_code', 'gw_invite_count', 'gw_invite_earned']
|
||||
stale.forEach((k) => localStorage.removeItem(k))
|
||||
apiKey.value = ''
|
||||
toast('已退出登录')
|
||||
setTimeout(() => router.push('/'), 600)
|
||||
}
|
||||
|
||||
// ---- Toast ----
|
||||
const toastMsg = ref('')
|
||||
let toastTimer = null
|
||||
function toast(m) {
|
||||
toastMsg.value = m
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toastMsg.value = ''), 2200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-text space-y-12">
|
||||
<!-- header -->
|
||||
<header class="flex items-end justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-[0.3em] text-violet-300/70 font-medium">账户</div>
|
||||
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">设置</h1>
|
||||
<p class="text-white/45 mt-2">API Key、密码、积分、登录状态 — 都在这里。</p>
|
||||
</div>
|
||||
<button @click="logout"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-rose-500/15 text-rose-300 hover:bg-rose-500/25 hover:text-rose-200 ring-1 ring-rose-500/30 px-4 py-2 text-sm font-medium transition-all">
|
||||
<Icon name="close" class="w-3.5 h-3.5" /> 退出登录
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- ===== Two-column grid ===== -->
|
||||
<div class="grid lg:grid-cols-2 gap-5">
|
||||
|
||||
<!-- API KEY (full width) -->
|
||||
<section class="lg:col-span-2 card overflow-hidden">
|
||||
<div class="p-7 md:p-8 relative">
|
||||
<div class="absolute -top-16 -right-16 w-48 h-48 rounded-full opacity-30 blur-3xl"
|
||||
style="background: radial-gradient(circle,#a855f7,transparent 60%)"></div>
|
||||
<div class="relative grid md:grid-cols-[260px_1fr] gap-8 items-start">
|
||||
<div>
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-violet-500/15 ring-1 ring-violet-400/30 grid place-items-center text-violet-300">
|
||||
<Icon name="plug" class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold mt-4">API Key</h2>
|
||||
<p class="text-sm text-white/50 mt-2 leading-relaxed">
|
||||
调用 <code class="bg-white/10 text-white/90 px-1 py-0.5 rounded text-xs">/v1/*</code> 接口需要的访问密钥。会保存在浏览器本地。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/50 mb-2">当前密钥</label>
|
||||
|
||||
<!-- has a key -->
|
||||
<div v-if="hasKey" class="rounded-xl bg-white/[0.05] ring-1 ring-white/10 px-4 py-3 flex items-center gap-3">
|
||||
<code class="flex-1 font-mono text-sm text-white/90 break-all">{{ apiKeyRevealed && apiKey ? apiKey : maskedKey }}</code>
|
||||
<button v-if="apiKey" @click="apiKeyRevealed = !apiKeyRevealed"
|
||||
class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 hover:bg-white/[0.06] hover:ring-white/20 transition-all whitespace-nowrap">
|
||||
{{ apiKeyRevealed ? '隐藏' : '显示' }}
|
||||
</button>
|
||||
<button @click="copyKey"
|
||||
class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 hover:bg-white/[0.06] hover:ring-white/20 transition-all">
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- no key -->
|
||||
<div v-else class="rounded-xl border border-dashed border-white/15 px-4 py-5 text-center text-xs text-white/40">
|
||||
还没有 Key — 点下面的「生成」按钮自动生成
|
||||
</div>
|
||||
|
||||
<p v-if="apiKey" class="text-[11px] text-amber-300/80 mt-2">⚠ 完整 Key 仅显示这一次,请立刻复制保存。</p>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button @click="generateKey"
|
||||
class="rounded-xl bg-white text-black hover:bg-white/90 px-5 py-2.5 text-sm font-semibold transition-colors">
|
||||
{{ hasKey ? '重新生成' : '生成 Key' }}
|
||||
</button>
|
||||
<button v-if="hasKey" @click="clearKey"
|
||||
class="rounded-xl ring-1 ring-rose-500/30 text-rose-300 hover:bg-rose-500/15 px-4 py-2.5 text-sm transition-colors">
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-white/40 mt-3">
|
||||
Key 由系统随机生成,不能手动填写。重新生成会让旧 Key 立刻失效。完整调用示例见
|
||||
<router-link to="/docs" class="text-violet-300 underline">接口文档</router-link>。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CHECK-IN -->
|
||||
<section v-if="checkinEnabled" class="relative card p-7 md:p-8 overflow-hidden">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-sky-500/15 ring-1 ring-sky-400/30 grid place-items-center text-sky-300">
|
||||
<Icon name="refresh" class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold mt-4">每日签到</h2>
|
||||
<p class="text-sm text-white/50 mt-2">每天签到 +{{ checkinReward }} 积分。</p>
|
||||
|
||||
<!-- streak -->
|
||||
<div class="mt-5 flex items-baseline gap-2">
|
||||
<span class="text-3xl font-bold tabular-nums">{{ streak }}</span>
|
||||
<span class="text-xs text-white/40 uppercase tracking-widest">天连续</span>
|
||||
</div>
|
||||
|
||||
<!-- 7-day dots -->
|
||||
<div class="mt-5 flex items-center gap-1.5">
|
||||
<div v-for="(d, i) in last7" :key="d.ds"
|
||||
class="flex-1 h-12 rounded-xl ring-1 transition-all flex flex-col items-center justify-center"
|
||||
:class="d.lit
|
||||
? 'bg-sky-400/25 ring-sky-300/50 text-sky-100'
|
||||
: d.isToday
|
||||
? (checkedToday ? 'bg-sky-400/25 ring-sky-300/50 text-sky-100' : 'bg-white/[0.05] ring-white/15 text-white/60')
|
||||
: 'bg-white/[0.02] ring-white/[0.06] text-white/30'">
|
||||
<Icon v-if="d.lit || (d.isToday && checkedToday)" name="spark" class="w-3 h-3" />
|
||||
<span v-else class="text-[10px] uppercase">{{ d.isToday ? '今' : i + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="checkin" :disabled="checkedToday"
|
||||
class="mt-5 w-full rounded-xl bg-white text-black hover:bg-white/90 disabled:bg-white/10 disabled:text-white/40 disabled:cursor-not-allowed py-3 text-sm font-semibold transition-colors">
|
||||
{{ checkedToday ? `今日已签到 · 明天再来` : `立即签到 +${checkinReward} 积分` }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- CDK REDEEM -->
|
||||
<section class="relative card p-7 md:p-8 overflow-hidden">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 grid place-items-center text-emerald-300">
|
||||
<Icon name="spark" class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold mt-4">兑换码充值</h2>
|
||||
<div class="mt-2 flex items-baseline gap-2">
|
||||
<span class="text-3xl font-bold tabular-nums">{{ points(balance).toLocaleString('en-US') }}</span>
|
||||
<span class="text-xs text-white/40 uppercase tracking-widest">积分余额</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-2">
|
||||
<input v-model="cdkCode" @keyup.enter="redeemCdk" placeholder="输入兑换码 (CDK)"
|
||||
class="flex-1 rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm font-mono uppercase tracking-wider outline-none transition-colors placeholder:text-white/30 placeholder:normal-case placeholder:tracking-normal" />
|
||||
<button @click="redeemCdk" :disabled="cdkBusy"
|
||||
class="rounded-xl bg-white text-black hover:bg-white/90 disabled:opacity-40 px-5 py-3 text-sm font-semibold transition-colors">
|
||||
{{ cdkBusy ? '兑换中…' : '兑换' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="cdkError" class="text-xs text-rose-400 mt-2">{{ cdkError }}</p>
|
||||
<p class="text-[11px] text-white/35 mt-3">兑换码每个仅可使用一次。还没有兑换码?到商店购买后回此处充值。</p>
|
||||
<a v-if="site.contact?.shop" :href="site.contact.shop" target="_blank" rel="noopener"
|
||||
class="mt-3 group inline-flex items-center gap-2 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 text-emerald-300 hover:bg-emerald-500/25 px-4 py-2.5 text-sm font-semibold transition-colors">
|
||||
<Icon name="spark" class="w-4 h-4" />
|
||||
前往商店购买兑换码
|
||||
<span class="group-hover:translate-x-0.5 transition-transform">→</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- PASSWORD -->
|
||||
<section class="card p-7 md:p-8">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-amber-500/15 ring-1 ring-amber-400/30 grid place-items-center text-amber-300">
|
||||
<Icon name="refresh" class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold mt-4">修改密码</h2>
|
||||
<p class="text-sm text-white/50 mt-2">8-24 位,必须包含大写字母、小写字母、数字和符号。</p>
|
||||
|
||||
<div class="mt-6 space-y-3">
|
||||
<input v-model="pwdForm.current" type="password" placeholder="当前密码" autocomplete="current-password"
|
||||
class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" />
|
||||
<input v-model="pwdForm.next" type="password" placeholder="新密码(8-24位,含大小写/数字/符号)" autocomplete="new-password"
|
||||
class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" />
|
||||
<input v-model="pwdForm.confirm" type="password" placeholder="再次输入新密码" autocomplete="new-password"
|
||||
class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" />
|
||||
<p v-if="pwdError" class="text-xs text-rose-400">{{ pwdError }}</p>
|
||||
<button @click="changePwd" :disabled="pwdSubmitting"
|
||||
class="w-full rounded-xl bg-white text-black hover:bg-white/90 disabled:opacity-40 py-3 text-sm font-semibold transition-colors">
|
||||
{{ pwdSubmitting ? '提交中…' : '更新密码' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SIGN OUT (full width) -->
|
||||
<section class="lg:col-span-2 card p-6 md:p-7 flex items-center gap-5 flex-wrap">
|
||||
<div class="inline-grid w-10 h-10 rounded-xl bg-rose-500/15 ring-1 ring-rose-400/30 grid place-items-center text-rose-300">
|
||||
<Icon name="close" class="w-4 h-4" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-base font-semibold">退出登录</div>
|
||||
<p class="text-sm text-white/50 mt-1">会清除本地 API Key、余额缓存。下次进来要重新设置。</p>
|
||||
</div>
|
||||
<button @click="logout"
|
||||
class="rounded-xl bg-rose-500/20 text-rose-200 hover:bg-rose-500/30 ring-1 ring-rose-500/30 px-5 py-2.5 text-sm font-semibold transition-colors">
|
||||
退出
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- toast -->
|
||||
<transition name="fade">
|
||||
<div v-if="toastMsg"
|
||||
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">
|
||||
{{ toastMsg }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(8px); }
|
||||
</style>
|
||||
@@ -0,0 +1,417 @@
|
||||
<script setup>
|
||||
// Admin → 首页内容
|
||||
// CRUD for the cards rendered on the public home page:
|
||||
// - hero : top-3-by-weight stacked deck in the hero
|
||||
// - bento : "从一个起点开始" grid
|
||||
// - work : "我们的作品" marquee — admin-curated featured outputs
|
||||
// All three kinds use a real image as the background; admins pick one from
|
||||
// the already-generated files or paste an external URL.
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { api, jsonBody, generatedUrl } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const items = ref([])
|
||||
const filter = ref('all') // 'all' | 'hero' | 'bento' | 'work'
|
||||
const loading = ref(false)
|
||||
const editing = ref(null) // truthy when the form modal is open
|
||||
const picking = ref(false) // truthy when the image-picker modal is open
|
||||
const recentFiles = ref([]) // populated from /stats.recent for the picker
|
||||
const page = ref(1)
|
||||
const pageSize = ref(12)
|
||||
const form = reactive({
|
||||
id: '', kind: 'hero', title: '', subtitle: '', prompt: '',
|
||||
image: '', weight: 100, span: '',
|
||||
})
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
const r = await api('/showcase')
|
||||
const grouped = r.data?.data || {}
|
||||
// Guard every group — a payload missing hero/bento would throw on spread of
|
||||
// undefined and freeze the page on "加载中…".
|
||||
items.value = [...(grouped.hero || []), ...(grouped.bento || []), ...(grouped.work || [])]
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (filter.value === 'all') return items.value
|
||||
return items.value.filter((x) => x.kind === filter.value)
|
||||
})
|
||||
|
||||
// Client-side pagination over the filtered set. The showcase store is small
|
||||
// (admin curates manually) so paging client-side is fine — no extra API calls
|
||||
// when the admin flips pages.
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filtered.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
function setFilter(v) { filter.value = v; page.value = 1 }
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target !== page.value) page.value = target
|
||||
}
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const KIND_DEFAULT_WEIGHT = { hero: 200, bento: 300, work: 100 }
|
||||
|
||||
function openNew(kind) {
|
||||
editing.value = { kind }
|
||||
Object.assign(form, {
|
||||
id: '', kind, title: '', subtitle: '', prompt: '',
|
||||
image: '', weight: KIND_DEFAULT_WEIGHT[kind], span: '',
|
||||
})
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function openEdit(rec) {
|
||||
editing.value = rec
|
||||
Object.assign(form, {
|
||||
id: rec.id, kind: rec.kind, title: rec.title || '', subtitle: rec.subtitle || '',
|
||||
prompt: rec.prompt || '', image: rec.image || '',
|
||||
weight: rec.weight ?? 100, span: rec.span || '',
|
||||
})
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function closeForm() { editing.value = null }
|
||||
|
||||
async function save() {
|
||||
if (!form.image.trim()) { error.value = '请选择底图'; return }
|
||||
if (form.kind !== 'work') {
|
||||
if (!form.title.trim()) { error.value = '请输入标题'; return }
|
||||
if (!form.prompt.trim()) { error.value = '请输入提示词'; return }
|
||||
}
|
||||
saving.value = true; error.value = ''
|
||||
const payload = {
|
||||
kind: form.kind,
|
||||
title: form.title.trim(),
|
||||
subtitle: form.subtitle.trim(),
|
||||
prompt: form.prompt.trim(),
|
||||
image: form.image.trim(),
|
||||
weight: Number(form.weight) || 0,
|
||||
span: form.span.trim(),
|
||||
}
|
||||
const r = form.id
|
||||
? await api(`/showcase/${form.id}`, jsonBody('PATCH', payload))
|
||||
: await api('/showcase', jsonBody('POST', payload))
|
||||
saving.value = false
|
||||
if (r.ok) { closeForm(); refresh() }
|
||||
else error.value = r.data?.detail || `保存失败 (${r.status})`
|
||||
}
|
||||
|
||||
async function remove(rec) {
|
||||
if (!confirm(`删除「${rec.title || rec.image}」?`)) return
|
||||
const r = await api(`/showcase/${rec.id}`, { method: 'DELETE' })
|
||||
if (r.ok) refresh()
|
||||
}
|
||||
|
||||
// Image picker — show the admin's OWN recently generated images (scoped to their
|
||||
// owner directory, not everyone's). The admin clicks one to fill `form.image`,
|
||||
// or pastes a URL into the text field.
|
||||
async function openPicker() {
|
||||
picking.value = true
|
||||
if (!recentFiles.value.length) {
|
||||
const r = await api('/my-images')
|
||||
const files = r.data?.data || []
|
||||
recentFiles.value = files.filter((f) =>
|
||||
/\.(png|jpe?g|webp|gif)$/i.test(f.name)
|
||||
)
|
||||
}
|
||||
}
|
||||
function closePicker() { picking.value = false }
|
||||
function pickImage(file) {
|
||||
form.image = file.name
|
||||
picking.value = false
|
||||
}
|
||||
|
||||
function bgFor(image) {
|
||||
if (!image) return {}
|
||||
const src = /^https?:\/\//i.test(image) ? image : generatedUrl(image)
|
||||
return {
|
||||
backgroundImage: `url("${src}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
}
|
||||
|
||||
const SPAN_PRESETS = ['', 'md:col-span-2', 'md:row-span-2', 'md:col-span-2 md:row-span-2']
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- header / filter / new -->
|
||||
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button @click="setFilter('all')" class="filter-pill" :class="filter === 'all' && 'on'">全部</button>
|
||||
<button @click="setFilter('hero')" class="filter-pill" :class="filter === 'hero' && 'on'">Hero 卡片</button>
|
||||
<button @click="setFilter('bento')" class="filter-pill" :class="filter === 'bento' && 'on'">Bento 灵感</button>
|
||||
<button @click="setFilter('work')" class="filter-pill" :class="filter === 'work' && 'on'">我们的作品</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openNew('hero')" class="btn-soft">+ Hero</button>
|
||||
<button @click="openNew('bento')" class="btn-soft">+ Bento</button>
|
||||
<button @click="openNew('work')" class="btn-soft">+ Work</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- grid -->
|
||||
<div v-if="loading" class="text-center text-xs text-white/40 py-12">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="text-center text-xs text-white/40 py-12">没有条目</div>
|
||||
<div v-else class="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<div v-for="rec in pagedItems" :key="rec.id"
|
||||
class="media-card relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[4/3] group bg-white/[0.04]"
|
||||
:style="bgFor(rec.image)">
|
||||
<!-- Fallback gradient for legacy entries that haven't been migrated yet. -->
|
||||
<div v-if="!rec.image" class="absolute inset-0" :style="{ background: rec.gradient }"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
|
||||
<div class="absolute top-3 left-3 flex items-center gap-1.5">
|
||||
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded ring-1"
|
||||
:class="rec.kind === 'hero' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30'
|
||||
: rec.kind === 'bento' ? 'bg-violet-500/20 text-violet-200 ring-violet-400/30'
|
||||
: 'bg-sky-500/20 text-sky-200 ring-sky-400/30'">
|
||||
{{ rec.kind }}
|
||||
</span>
|
||||
<span class="text-[10px] text-white/65 tabular-nums px-1.5 py-0.5 rounded bg-black/40 ring-1 ring-white/10">w={{ rec.weight }}</span>
|
||||
</div>
|
||||
<div class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button @click="openEdit(rec)" class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center" title="编辑">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="remove(rec)" class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-rose-500/80 text-white grid place-items-center" title="删除">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-4">
|
||||
<div v-if="rec.subtitle" class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ rec.subtitle }}</div>
|
||||
<div v-if="rec.title" class="text-base font-bold text-white mt-1">{{ rec.title }}</div>
|
||||
<div v-if="rec.prompt" class="text-[11px] text-white/65 mt-1 line-clamp-2">{{ rec.prompt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- pagination — shown when there's more than one page worth of entries -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
class="card !p-3 flex items-center justify-between gap-3">
|
||||
<div class="text-xs text-white/55 tabular-nums px-2">
|
||||
<span class="text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
||||
/ {{ filtered.length }} 条
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-white/35">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======= form modal ======= -->
|
||||
<transition name="fade">
|
||||
<div v-if="editing"
|
||||
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm grid place-items-center p-4"
|
||||
@click.self="closeForm">
|
||||
<div class="card w-full max-w-2xl !shadow-2xl">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ form.id ? '编辑' : '新增' }} ·
|
||||
{{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }}
|
||||
</h2>
|
||||
<button @click="closeForm" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<!-- live preview -->
|
||||
<div class="relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[5/2] bg-white/[0.04]"
|
||||
:style="bgFor(form.image)">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
|
||||
<div v-if="!form.image" class="absolute inset-0 grid place-items-center text-xs text-white/40">
|
||||
未选择底图
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-5">
|
||||
<div v-if="form.subtitle" class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ form.subtitle }}</div>
|
||||
<div v-if="form.title" class="text-xl font-bold text-white mt-1">{{ form.title }}</div>
|
||||
<div v-if="form.prompt" class="text-xs text-white/65 mt-1 line-clamp-2">{{ form.prompt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">类型</label>
|
||||
<div class="flex gap-1.5">
|
||||
<button type="button" @click="form.kind = 'hero'" class="kind-btn" :class="form.kind === 'hero' && 'on'">Hero</button>
|
||||
<button type="button" @click="form.kind = 'bento'" class="kind-btn" :class="form.kind === 'bento' && 'on'">Bento</button>
|
||||
<button type="button" @click="form.kind = 'work'" class="kind-btn" :class="form.kind === 'work' && 'on'">Work</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">权重 <span class="text-white/35">(越大越靠前)</span></label>
|
||||
<input v-model.number="form.weight" type="number" class="field" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- image picker (the central change — admins pick a real image) -->
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">底图</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="form.image" class="field font-mono text-[11px]"
|
||||
placeholder="user/abc.png 或 https://…" />
|
||||
<button type="button" @click="openPicker" class="btn-soft shrink-0">选择已生成</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-white/35 mt-1">填写 /generated 下的相对路径,或粘贴一个外链 URL。</p>
|
||||
</div>
|
||||
|
||||
<template v-if="form.kind !== 'work'">
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">标题</label>
|
||||
<input v-model="form.title" class="field" placeholder="电影感人物" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">副标题</label>
|
||||
<input v-model="form.subtitle" class="field" placeholder="CINEMATIC PORTRAIT" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">提示词 <span class="text-white/35">(点 Bento 后会预填到画图)</span></label>
|
||||
<textarea v-model="form.prompt" rows="3" class="field resize-none"
|
||||
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<label class="block text-xs text-white/55 mb-1.5">作品标题 <span class="text-white/35">(可选)</span></label>
|
||||
<input v-model="form.title" class="field" placeholder="留空则只展示图片" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="form.kind === 'bento'">
|
||||
<label class="block text-xs text-white/55 mb-1.5">网格跨度 <span class="text-white/35">(Tailwind class)</span></label>
|
||||
<div class="flex gap-1.5 flex-wrap mb-2">
|
||||
<button v-for="s in SPAN_PRESETS" :key="s" type="button" @click="form.span = s"
|
||||
class="px-2.5 py-1 text-[11px] rounded-lg ring-1 ring-white/10 hover:bg-white/[0.08]"
|
||||
:class="form.span === s ? 'bg-white text-slate-900' : 'bg-white/[0.04] text-white/70'">
|
||||
{{ s || '默认 1×1' }}
|
||||
</button>
|
||||
</div>
|
||||
<input v-model="form.span" class="field font-mono text-[11px]" placeholder="md:col-span-2" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-3 border-t border-white/[0.06] flex items-center justify-end gap-2">
|
||||
<button @click="closeForm" class="btn-ghost">取消</button>
|
||||
<button @click="save" :disabled="saving" class="btn-primary">
|
||||
{{ saving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- ======= image picker modal ======= -->
|
||||
<transition name="fade">
|
||||
<div v-if="picking"
|
||||
class="fixed inset-0 z-[60] bg-black/80 backdrop-blur-sm grid place-items-center p-4"
|
||||
@click.self="closePicker">
|
||||
<div class="card w-full max-w-4xl !shadow-2xl">
|
||||
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">选择底图 · 最近生成</h2>
|
||||
<button @click="closePicker" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 max-h-[70vh] overflow-y-auto">
|
||||
<div v-if="!recentFiles.length" class="text-center text-xs text-white/40 py-10">尚未有生成过的图片</div>
|
||||
<div v-else class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
<button v-for="f in recentFiles" :key="f.name" type="button" @click="pickImage(f)"
|
||||
class="relative aspect-square rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all">
|
||||
<img :src="generatedUrl(f.name)" loading="lazy" class="w-full h-full object-cover" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-pill {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.filter-pill:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.filter-pill.on { background: white; color: rgb(15 23 42); }
|
||||
|
||||
.kind-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.kind-btn:hover { background: rgb(255 255 255 / 0.1); }
|
||||
.kind-btn.on { background: white; color: rgb(15 23 42); }
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
color: white;
|
||||
transition: border-color 0.18s, background 0.18s;
|
||||
}
|
||||
.field:focus { border-color: rgb(167 139 250 / 0.65); background: rgb(255 255 255 / 0.06); }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* numbered pagination — matches LogsView / ImagesView */
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on {
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
color: rgb(15 23 42);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup>
|
||||
// Front-end "日志" page — a row-per-event log of the signed-in user's OWN
|
||||
// generations (success / failed / pending), surfacing failure reasons that the
|
||||
// image-only 记录 gallery hides. Uses the same /logs endpoint (auto-scoped to
|
||||
// the caller), just without the success-only filter.
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { fmtDate, fmtClock, fmtTs } from '../utils/format'
|
||||
import { points } from '../credits'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const items = ref([]) // current server page
|
||||
const total = ref(0) // server-side total (matches current filters)
|
||||
const stats = ref({ total: 0, success: 0, failed: 0, pending: 0 }) // 本人统计
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('') // '' | success | failed | pending
|
||||
const sourceFilter = ref('') // '' | api | web (api = key 调用, web = 画图台)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const lightbox = ref(null)
|
||||
|
||||
// 来源筛选走服务端:画图台 = source "user",API = source "v1"。
|
||||
const SOURCE_PARAM = { web: 'user', api: 'v1' }
|
||||
|
||||
// 服务端分页 —— 不再只拉前 200 条;按页向后端取,可翻到全部历史。
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(pageSize),
|
||||
offset: String((page.value - 1) * pageSize),
|
||||
})
|
||||
if (statusFilter.value) qs.set('status', statusFilter.value)
|
||||
if (SOURCE_PARAM[sourceFilter.value]) qs.set('source', SOURCE_PARAM[sourceFilter.value])
|
||||
const r = await api('/logs?' + qs.toString())
|
||||
loading.value = false
|
||||
if (r.ok) {
|
||||
items.value = r.data?.data || []
|
||||
total.value = Number(r.data?.total ?? items.value.length)
|
||||
if (r.data?.stats) stats.value = r.data.stats
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
// Source: backend stamps "v1" for API-key calls, "user"/"admin" for the
|
||||
// playground/test page. Collapse to two buckets the user cares about.
|
||||
const isApi = (e) => e.source === 'v1'
|
||||
const sourceLabel = (e) => (isApi(e) ? 'API' : '画图台')
|
||||
const sourcePill = (e) => (isApi(e)
|
||||
? 'bg-violet-50 text-violet-700 ring-violet-200'
|
||||
: 'bg-sky-50 text-sky-700 ring-sky-200')
|
||||
|
||||
// 搜索只在当前页内过滤(状态/来源已由服务端筛选并分页)。
|
||||
const displayed = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return items.value
|
||||
return items.value.filter((e) =>
|
||||
(e.model || '').toLowerCase().includes(q) ||
|
||||
(e.prompt || '').toLowerCase().includes(q) ||
|
||||
(e.error || '').toLowerCase().includes(q))
|
||||
})
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
const pageStart = computed(() => total.value === 0 ? 0 : (page.value - 1) * pageSize + 1)
|
||||
const pageEnd = computed(() => Math.min(total.value, page.value * pageSize))
|
||||
function setStatus(v) { statusFilter.value = v; page.value = 1; load() }
|
||||
function setSource(v) { sourceFilter.value = v; page.value = 1; load() }
|
||||
|
||||
// Numbered pagination strip — first + last + a window around current; gaps
|
||||
// collapse to null ("…"). Mirrors the admin 日志 page so both look the same.
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
function goPage(n) {
|
||||
const t = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (t === page.value) return
|
||||
page.value = t
|
||||
load()
|
||||
}
|
||||
|
||||
const statusLabel = (s) => ({ success: '成功', failed: '失败', pending: '进行中' }[s] || s)
|
||||
const statusPill = (s) => ({
|
||||
success: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
|
||||
failed: 'bg-rose-50 text-rose-700 ring-rose-200',
|
||||
pending: 'bg-amber-50 text-amber-700 ring-amber-200',
|
||||
}[s] || 'bg-slate-100 text-slate-500 ring-slate-200')
|
||||
const statusDot = (s) => ({
|
||||
success: 'bg-emerald-500', failed: 'bg-rose-500', pending: 'bg-amber-500',
|
||||
}[s] || 'bg-slate-400')
|
||||
// Match the admin 日志 params exactly: 比例 · 画质 · [时长] · [参考 N].
|
||||
const params = (e) => {
|
||||
const parts = [e.ratio || '—', e.resolution || '—']
|
||||
if (e.duration) parts.push(e.duration)
|
||||
if (e.refs > 0) parts.push(`参考 ${e.refs}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5 log-page">
|
||||
<!-- Header -->
|
||||
<div class="flex items-end justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">生成日志</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">{{ total }} 条记录 · 含失败原因</p>
|
||||
</div>
|
||||
<button @click="router.push('/user')" class="btn-primary">
|
||||
<Icon name="spark" class="w-4 h-4" /> 去画图
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI 统计(本人累计) -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-slate-400">总计</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-slate-900">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-600/80">成功</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-600">{{ stats.success }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-rose-600/80">失败</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-600">{{ stats.failed }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-600/80">进行中</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-600">{{ stats.pending }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button v-for="s in [['','全部'],['success','成功'],['failed','失败'],['pending','进行中']]" :key="s[0]"
|
||||
@click="setStatus(s[0])"
|
||||
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||||
:class="statusFilter === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-slate-200"></div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button v-for="s in [['','全部来源'],['web','画图台'],['api','API']]" :key="s[0]"
|
||||
@click="setSource(s[0])"
|
||||
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||||
:class="sourceFilter === s[0] ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[180px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 提示词 / 模型 / 错误…" />
|
||||
</div>
|
||||
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
|
||||
</div>
|
||||
|
||||
<!-- States -->
|
||||
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中…</div>
|
||||
<div v-else-if="!total" class="card flex flex-col items-center gap-3 text-slate-400 py-24">
|
||||
<span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="log" class="w-6 h-6" /></span>
|
||||
<span class="text-sm">还没有生成日志</span>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="card overflow-hidden !p-0">
|
||||
<table class="w-full text-sm table-fixed log-table">
|
||||
<colgroup>
|
||||
<col class="w-16" /> <!-- preview -->
|
||||
<col class="w-28" /> <!-- time -->
|
||||
<col class="w-24" /> <!-- status -->
|
||||
<col class="w-36" /> <!-- model -->
|
||||
<col /> <!-- prompt/error -->
|
||||
<col class="w-40" /> <!-- params -->
|
||||
<col class="w-14" /> <!-- cost -->
|
||||
<col class="w-16" /> <!-- elapsed -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.18em] text-slate-400 border-b border-slate-200">
|
||||
<th class="text-center px-3 py-3 font-medium">预览</th>
|
||||
<th class="text-left px-3 py-3 font-medium">时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-left px-3 py-3 font-medium">模型</th>
|
||||
<th class="text-left px-3 py-3 font-medium">提示词 / 错误</th>
|
||||
<th class="text-left px-3 py-3 font-medium">参数</th>
|
||||
<th class="text-right px-3 py-3 font-medium">积分</th>
|
||||
<th class="text-right px-3 py-3 font-medium">耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in displayed" :key="e.id" class="log-row">
|
||||
<td class="px-3 py-3 align-middle text-center">
|
||||
<button v-if="e.status === 'success' && e.file" @click="lightbox = e"
|
||||
class="block w-11 h-11 mx-auto rounded-lg overflow-hidden ring-1 ring-slate-200 hover:ring-fuchsia-300 transition-all">
|
||||
<img v-if="e.kind !== 'video'" :src="generatedUrl(e.file)" loading="lazy" class="w-full h-full object-cover" />
|
||||
<video v-else :src="generatedUrl(e.file)" muted preload="metadata" class="w-full h-full object-cover" />
|
||||
</button>
|
||||
<span v-else class="text-slate-300">—</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle text-xs whitespace-nowrap" :title="fmtTs(e.ts)">
|
||||
<div v-if="e.ts" class="leading-tight">
|
||||
<div class="text-slate-600 tabular-nums">{{ fmtDate(e.ts) }}</div>
|
||||
<div class="text-slate-400 tabular-nums">{{ fmtClock(e.ts) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-slate-300">—</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 whitespace-nowrap" :class="statusPill(e.status)">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(e.status)"></span>{{ statusLabel(e.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle min-w-0">
|
||||
<div class="font-mono text-xs text-slate-800 truncate" :title="e.model">{{ e.model }}</div>
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<span class="text-[10px] uppercase tracking-wider font-medium"
|
||||
:class="e.kind === 'video' ? 'text-fuchsia-600' : 'text-indigo-600'">
|
||||
{{ e.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded px-1.5 py-px text-[10px] font-medium ring-1 whitespace-nowrap"
|
||||
:class="sourcePill(e)">{{ sourceLabel(e) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle min-w-0">
|
||||
<div class="text-xs text-slate-700 truncate" :title="e.prompt">{{ e.prompt || '—' }}</div>
|
||||
<div v-if="e.error" class="mt-1 text-[11px] text-rose-600 truncate" :title="e.error">⚠ {{ e.error }}</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle text-xs text-slate-500 tabular-nums">{{ params(e) || '—' }}</td>
|
||||
<td class="px-3 py-3 align-middle text-right text-xs text-slate-700 tabular-nums">{{ e.cost ? points(e.cost) : '—' }}</td>
|
||||
<td class="px-3 py-3 align-middle text-right text-xs text-slate-500 tabular-nums">{{ e.elapsed_ms ? (e.elapsed_ms / 1000).toFixed(1) + 's' : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination — numbered with ellipsis, inside the card footer exactly
|
||||
like the admin 日志 page (top border + px-5 py-3). -->
|
||||
<div v-if="total && totalPages > 1"
|
||||
class="flex items-center justify-between gap-3 border-t border-slate-200 px-5 py-3 text-xs text-slate-500">
|
||||
<div>
|
||||
<span class="tabular-nums text-slate-700">{{ pageStart }}–{{ pageEnd }}</span>
|
||||
<span class="ml-1">/ {{ total }} 条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-slate-300">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaLightbox
|
||||
v-if="lightbox"
|
||||
:src="generatedUrl(lightbox.file)"
|
||||
:kind="lightbox.kind"
|
||||
:prompt="lightbox.prompt"
|
||||
:meta="[lightbox.model, lightbox.ratio, lightbox.resolution, lightbox.duration].filter(Boolean).join(' · ')"
|
||||
:download-name="lightbox.file"
|
||||
@close="lightbox = null" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Row hover — light-theme twin of the admin 日志 page's .log-row: a subtle
|
||||
tint plus a violet accent bar on the left edge of the hovered row. */
|
||||
.log-table { border-collapse: separate; border-spacing: 0; }
|
||||
.log-row td {
|
||||
border-bottom: 1px solid rgb(15 23 42 / 0.06);
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.log-row:hover td { background: rgb(15 23 42 / 0.025); }
|
||||
.log-row:hover td:first-child { box-shadow: inset 2px 0 0 rgb(124 58 237 / 0.6); }
|
||||
.log-row:last-child td { border-bottom: none; }
|
||||
|
||||
/* Numbered pagination buttons — light-theme twin of the admin 日志 page's .pg */
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: rgb(71 85 105);
|
||||
background: rgb(241 245 249);
|
||||
box-shadow: inset 0 0 0 1px rgb(15 23 42 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(226 232 240); color: rgb(15 23 42); }
|
||||
.pg-on {
|
||||
background: rgb(15 23 42);
|
||||
color: white;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { fmtTs } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const kindFilter = ref('') // '', 'image', 'video'
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
// 20 per page so a 4-col (lg) / 5-col (xl) grid lays out as clean rows,
|
||||
// matching the admin 图片管理 (ImagesView) page.
|
||||
const pageSize = 20
|
||||
let timer = null
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
// Server-side pagination over real media only: status=success + has_file=1
|
||||
// makes the row count == displayable count, so the numbered pager is accurate.
|
||||
// Failed/pending/file-pruned rows live in admin /admin/logs, never here.
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(pageSize),
|
||||
offset: String((page.value - 1) * pageSize),
|
||||
status: 'success',
|
||||
has_file: '1',
|
||||
})
|
||||
if (kindFilter.value) qs.set('kind', kindFilter.value)
|
||||
const r = await api('/logs?' + qs.toString())
|
||||
items.value = (r.data?.data || []).filter((e) => e.status === 'success' && e.file)
|
||||
total.value = Number(r.data?.total ?? items.value.length)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// Search narrows the CURRENT page (same as the admin 日志 page); the numbered
|
||||
// pager still reflects the full server-side total.
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return items.value
|
||||
return items.value.filter((e) =>
|
||||
(e.model || '').toLowerCase().includes(q) ||
|
||||
(e.prompt || '').toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
function setKind(v) { kindFilter.value = v; page.value = 1; load() }
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target === page.value) return
|
||||
page.value = target
|
||||
load()
|
||||
}
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
function fmtMs(ms) {
|
||||
if (!ms) return ''
|
||||
if (ms < 1000) return ms + 'ms'
|
||||
return (ms / 1000).toFixed(1) + 's'
|
||||
}
|
||||
|
||||
|
||||
async function copyLink(name) {
|
||||
try {
|
||||
const u = generatedUrl(name)
|
||||
await navigator.clipboard.writeText(u.startsWith('http') ? u : location.origin + u)
|
||||
toast.value = '链接已复制'
|
||||
setTimeout(() => (toast.value = ''), 1500)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const toast = ref('')
|
||||
const lightbox = ref(null)
|
||||
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
timer = setInterval(load, 3000)
|
||||
window.addEventListener('keydown', onKey)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-end justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">我的创作记录</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">
|
||||
{{ total }} 条作品
|
||||
</p>
|
||||
</div>
|
||||
<button @click="router.push('/user')" class="btn-primary">
|
||||
<Icon name="spark" class="w-4 h-4" /> 去画图
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button @click="setKind('')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||||
:class="kindFilter === '' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button>
|
||||
<button @click="setKind('image')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||||
:class="kindFilter === 'image' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">图像</button>
|
||||
<button @click="setKind('video')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
|
||||
:class="kindFilter === 'video' ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">视频</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[180px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索提示词或模型…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中…</div>
|
||||
<div v-else-if="!filtered.length"
|
||||
class="card flex flex-col items-center gap-3 text-slate-400 py-24">
|
||||
<span class="w-14 h-14 rounded-2xl bg-slate-100 grid place-items-center"><Icon name="spark" class="w-6 h-6" /></span>
|
||||
<span class="text-sm">还没有创作记录</span>
|
||||
<button @click="router.push('/user')" class="btn-soft mt-2">开始第一张</button>
|
||||
</div>
|
||||
|
||||
<!-- Cards — gallery layout, matching 图片管理 (ImagesView) -->
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
<div v-for="e in filtered" :key="e.id"
|
||||
class="group relative rounded-xl overflow-hidden ring-1 ring-slate-200 bg-slate-100 aspect-[4/5]"
|
||||
:class="(e.status === 'success' && e.file) && 'cursor-zoom-in'"
|
||||
@click="(e.status === 'success' && e.file) && (lightbox = e)">
|
||||
<!-- media -->
|
||||
<template v-if="e.status === 'success' && e.file">
|
||||
<video v-if="e.kind === 'video'" :src="generatedUrl(e.file)" muted loop preload="metadata"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
@mouseenter="$event.target.play && $event.target.play()"
|
||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||
<img v-else :src="generatedUrl(e.file)" loading="lazy"
|
||||
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" />
|
||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div>
|
||||
</template>
|
||||
<!-- pending / failed placeholders -->
|
||||
<div v-else-if="e.status === 'pending'" class="absolute inset-0 grid place-items-center text-slate-400 text-xs">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="w-10 h-10 rounded-xl bg-white grid place-items-center animate-pulse"><Icon name="spark" class="w-4 h-4" /></span>
|
||||
生成中…
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute inset-0 grid place-items-center text-rose-500 text-xs px-4 text-center">
|
||||
<div>
|
||||
<Icon name="close" class="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||||
<div>生成失败</div>
|
||||
<div v-if="e.error" class="text-[10px] text-rose-400 line-clamp-2 mt-1">{{ e.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- kind chip -->
|
||||
<span class="absolute top-3 left-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
|
||||
:class="e.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
|
||||
{{ e.kind === 'video' ? '视频' : '图像' }}
|
||||
</span>
|
||||
|
||||
<!-- hover actions (only when there's a file) -->
|
||||
<div v-if="e.status === 'success' && e.file"
|
||||
class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a :href="generatedUrl(e.file)" target="_blank" @click.stop title="新标签打开"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="open" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<button @click.stop="copyLink(e.file)" title="复制链接"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="copy" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<a :href="generatedUrl(e.file)" :download="e.file.split('/').pop()" @click.stop title="下载"
|
||||
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
|
||||
<Icon name="download" class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- caption (over a real image) -->
|
||||
<div v-if="e.status === 'success' && e.file" class="absolute inset-x-0 bottom-0 p-3 pointer-events-none">
|
||||
<div class="text-[12px] leading-tight text-white font-medium line-clamp-2 mb-1" :title="e.prompt">{{ e.prompt }}</div>
|
||||
<div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums">
|
||||
<span class="truncate" :title="e.model || ''">{{ e.model || '—' }}</span>
|
||||
<span class="shrink-0 flex items-center gap-1">
|
||||
<span v-if="e.resolution" class="text-emerald-300/90">{{ e.resolution }}</span>
|
||||
<span v-if="e.ratio" class="text-white/40">{{ e.ratio }}</span>
|
||||
<span v-if="e.kind === 'video' && e.duration" class="text-fuchsia-300/80">{{ e.duration }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtTs(e.ts) }}<span v-if="e.elapsed_ms"> · {{ fmtMs(e.elapsed_ms) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination — its own card, exactly like 图片管理 (ImagesView) -->
|
||||
<div v-if="total && totalPages > 1" class="card !p-3 flex items-center justify-between gap-3">
|
||||
<div class="text-xs text-slate-500 tabular-nums px-2">
|
||||
<span class="text-slate-700">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(total, page * pageSize) }}</span>
|
||||
/ {{ total }} 张
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-slate-300">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox (shared component) -->
|
||||
<MediaLightbox
|
||||
v-if="lightbox"
|
||||
:src="generatedUrl(lightbox.file)"
|
||||
:kind="lightbox.kind"
|
||||
:prompt="lightbox.prompt"
|
||||
:meta="[lightbox.model, lightbox.ratio, lightbox.resolution, lightbox.duration, fmtMs(lightbox.elapsed_ms)].filter(Boolean).join(' · ')"
|
||||
:download-name="lightbox.file"
|
||||
@close="lightbox = null" />
|
||||
|
||||
<!-- Toast -->
|
||||
<transition name="fade">
|
||||
<div v-if="toast"
|
||||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg">
|
||||
{{ toast }}
|
||||
</div>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* Numbered pagination buttons — light-theme twin of the admin .pg */
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: rgb(71 85 105);
|
||||
background: rgb(241 245 249);
|
||||
box-shadow: inset 0 0 0 1px rgb(15 23 42 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(226 232 240); color: rgb(15 23 42); }
|
||||
.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,569 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api, jsonBody } from '../api'
|
||||
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import SelectMenu from '../components/SelectMenu.vue'
|
||||
import { points } from '../credits'
|
||||
|
||||
const items = ref([])
|
||||
const stats = ref({ total: 0, active: 0, disabled: 0, admins: 0, credits_total: 0 })
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const roleFilter = ref('') // '' | 'admin' | 'user'
|
||||
const statusFilter = ref('') // '' | 'active' | 'disabled'
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const showAdd = ref(false)
|
||||
const editing = ref(null)
|
||||
const toast = ref('')
|
||||
|
||||
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0 })
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: '正常' },
|
||||
{ value: 'disabled', label: '禁用' },
|
||||
]
|
||||
|
||||
// 代理 = 走代理价的客户(不享管理权限)。
|
||||
// 管理员唯一:不能通过用户管理创建/改成管理员,所以选项只给 普通用户 / 代理。
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'user', label: '普通用户' },
|
||||
{ value: 'agent', label: '代理' },
|
||||
]
|
||||
const roleLabel = (r) => ({ user: '用户', agent: '代理', admin: '管理员' }[r] || '用户')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const r = await api('/users')
|
||||
items.value = r.data?.data || []
|
||||
stats.value = r.data?.stats || stats.value
|
||||
loading.value = false
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
// Newest first — created_at desc, falling back to id so users without a
|
||||
// timestamp still get a stable order.
|
||||
const sorted = [...items.value].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
return sorted.filter((u) => {
|
||||
if (roleFilter.value && u.role !== roleFilter.value) return false
|
||||
if (statusFilter.value && u.status !== statusFilter.value) return false
|
||||
if (q && !(
|
||||
(u.email || '').toLowerCase().includes(q) ||
|
||||
(u.name || '').toLowerCase().includes(q) ||
|
||||
(u.id || '').toLowerCase().includes(q)
|
||||
)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// Client-side pagination — user list is bounded.
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filtered.value.length / pageSize.value)))
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filtered.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
function goPage(n) {
|
||||
const target = Math.max(1, Math.min(totalPages.value, n))
|
||||
if (target !== page.value) page.value = target
|
||||
}
|
||||
function setFilter(fn) { fn(); page.value = 1 }
|
||||
const pageNumbers = computed(() => {
|
||||
const n = totalPages.value
|
||||
const cur = page.value
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const want = new Set([1, n, cur - 1, cur, cur + 1])
|
||||
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
|
||||
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
|
||||
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
|
||||
const out = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
|
||||
out.push(list[i])
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
let toastTimer = null
|
||||
function flash(m) {
|
||||
toast.value = m
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toast.value = ''), 2000)
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
if (!addForm.value.email.trim()) { flash('请输入邮箱'); return }
|
||||
const r = await api('/users', jsonBody('POST', addForm.value))
|
||||
if (r.ok) {
|
||||
showAdd.value = false
|
||||
addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0 }
|
||||
flash('用户已创建')
|
||||
load()
|
||||
} else flash(r.data?.detail || '创建失败')
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
const u = editing.value
|
||||
// Email + 用户名 are intentionally NOT in the patch — they're displayed
|
||||
// read-only in the form, and the admin shouldn't be in the habit of
|
||||
// rewriting a user's identity from this page.
|
||||
const patch = {
|
||||
status: u.status,
|
||||
credits: u.credits,
|
||||
role: u.role,
|
||||
}
|
||||
if (u._newPassword) patch.password = u._newPassword
|
||||
const r = await api(`/users/${u.id}`, jsonBody('PATCH', patch))
|
||||
if (r.ok) { editing.value = null; flash('已保存'); load() }
|
||||
else flash(r.data?.detail || '保存失败')
|
||||
}
|
||||
|
||||
async function toggleStatus(u) {
|
||||
// Optimistic: flip instantly so the switch moves the moment it's clicked;
|
||||
// persist in the background and revert on failure (no full table reload).
|
||||
const prev = u.status
|
||||
const next = u.status === 'active' ? 'disabled' : 'active'
|
||||
u.status = next
|
||||
const r = await api(`/users/${u.id}`, jsonBody('PATCH', { status: next }))
|
||||
if (r.ok) flash(next === 'active' ? '已启用' : '已禁用')
|
||||
else { u.status = prev; flash(r.data?.detail || '操作失败') }
|
||||
}
|
||||
|
||||
async function delUser(u) {
|
||||
if (!confirm(`删除用户 ${u.email}? 此操作不可恢复`)) return
|
||||
const r = await api(`/users/${u.id}`, { method: 'DELETE' })
|
||||
if (r.ok) { flash('已删除'); load() } else flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
|
||||
// ===== 多选删除 =====
|
||||
const selected = ref(new Set())
|
||||
function toggleSelect(id) {
|
||||
const s = new Set(selected.value)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
selected.value = s
|
||||
}
|
||||
const allSelected = computed(() =>
|
||||
filtered.value.length > 0 && filtered.value.every((u) => selected.value.has(u.id)))
|
||||
function toggleSelectAll() {
|
||||
const s = new Set(selected.value)
|
||||
if (allSelected.value) filtered.value.forEach((u) => s.delete(u.id))
|
||||
else filtered.value.forEach((u) => s.add(u.id))
|
||||
selected.value = s
|
||||
}
|
||||
async function delSelected() {
|
||||
const ids = [...selected.value]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`确认删除选中的 ${ids.length} 个用户?此操作不可恢复。`)) return
|
||||
const r = await api('/users/delete-bulk', jsonBody('POST', { ids }))
|
||||
if (r.ok) { flash(`已删除 ${r.data?.deleted ?? ids.length} 个`); selected.value = new Set(); load() }
|
||||
else flash(r.data?.detail || '删除失败')
|
||||
}
|
||||
|
||||
async function quickCredits(u, delta) {
|
||||
const r = await api(`/users/${u.id}/credits`, jsonBody('POST', { delta }))
|
||||
if (r.ok) { flash(`已${delta > 0 ? '增加' : '扣除'} ${Math.abs(delta).toLocaleString('en-US')} 积分`); load() }
|
||||
else flash(r.data?.detail || '调整失败')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<!-- KPI strip — same shape as LogsView / ModelsView -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-white/45">用户总数</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">正常</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.active }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-fuchsia-300/80">管理员</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-fuchsia-300">{{ stats.admins }}</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">总积分</div>
|
||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ points(stats.credits_total).toLocaleString('en-US') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="card p-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => roleFilter = '')" class="fp" :class="roleFilter === '' && 'fp-on'">全部角色</button>
|
||||
<button @click="setFilter(() => roleFilter = 'admin')" class="fp" :class="roleFilter === 'admin' && 'fp-fuchsia'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span>管理员
|
||||
</button>
|
||||
<button @click="setFilter(() => roleFilter = 'agent')" class="fp" :class="roleFilter === 'agent' && 'fp-amber'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>代理
|
||||
</button>
|
||||
<button @click="setFilter(() => roleFilter = 'user')" class="fp" :class="roleFilter === 'user' && 'fp-on'">用户</button>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-white/10"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
||||
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常
|
||||
</button>
|
||||
<button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>禁用
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / 用户名 / ID…" />
|
||||
</div>
|
||||
<button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的用户">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
|
||||
</button>
|
||||
<button @click="load" class="btn-soft">
|
||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||
</button>
|
||||
<button @click="showAdd = true" class="btn-primary">
|
||||
<Icon name="plus" class="w-3.5 h-3.5" /> 新建用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
||||
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
|
||||
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
|
||||
<Icon name="accounts" class="w-6 h-6" />
|
||||
</span>
|
||||
<span class="text-sm">{{ items.length ? '没有匹配的用户' : '还没有用户' }}</span>
|
||||
<button v-if="!items.length" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-9" /> <!-- select -->
|
||||
<col class="w-40" /> <!-- username -->
|
||||
<col /> <!-- email (flex) -->
|
||||
<col class="w-20" /> <!-- role -->
|
||||
<col class="w-16" /> <!-- status switch -->
|
||||
<col class="w-24" /> <!-- credits -->
|
||||
<col class="w-20" /> <!-- generation count -->
|
||||
<col class="w-28" /> <!-- registered -->
|
||||
<col class="w-28" /> <!-- last login -->
|
||||
<col class="w-32" /> <!-- login IP -->
|
||||
<col class="w-24" /> <!-- actions -->
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||
<th class="text-center px-3 py-3 font-medium">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
||||
class="chk" title="全选" />
|
||||
</th>
|
||||
<th class="text-left px-5 py-3 font-medium">用户名</th>
|
||||
<th class="text-left px-3 py-3 font-medium">邮箱</th>
|
||||
<th class="text-left px-3 py-3 font-medium">角色</th>
|
||||
<th class="text-left px-3 py-3 font-medium">状态</th>
|
||||
<th class="text-right px-3 py-3 font-medium">积分</th>
|
||||
<th class="text-right px-3 py-3 font-medium">生图次数</th>
|
||||
<th class="text-left px-3 py-3 font-medium">注册时间</th>
|
||||
<th class="text-left px-3 py-3 font-medium">最近登录</th>
|
||||
<th class="text-left px-3 py-3 font-medium">登录 IP</th>
|
||||
<th class="text-right px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in pagedItems" :key="u.id"
|
||||
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
|
||||
<td class="px-3 py-3.5 align-middle text-center">
|
||||
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" @click.stop
|
||||
class="chk" />
|
||||
</td>
|
||||
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'">
|
||||
{{ u.name || '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email">
|
||||
{{ u.email || '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
|
||||
:class="u.role === 'admin'
|
||||
? 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30'
|
||||
: u.role === 'agent'
|
||||
? 'bg-amber-500/10 text-amber-300 ring-amber-400/30'
|
||||
: 'bg-white/[0.06] text-white/70 ring-white/15'">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
:class="u.role === 'admin' ? 'bg-fuchsia-400' : u.role === 'agent' ? 'bg-amber-400' : 'bg-slate-400'"></span>
|
||||
{{ roleLabel(u.role) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle">
|
||||
<button class="sw" :class="u.status === 'active' && 'sw-on'"
|
||||
:aria-pressed="u.status === 'active'"
|
||||
:title="u.status === 'active' ? '点击禁用账号' : '点击启用账号'"
|
||||
@click="toggleStatus(u)">
|
||||
<span class="sw-thumb"></span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85 whitespace-nowrap">
|
||||
{{ points(u.credits).toLocaleString('en-US') }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
|
||||
:class="u.generation_count > 0 ? 'text-white/85' : 'text-white/25'">
|
||||
{{ (u.generation_count || 0).toLocaleString('en-US') }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="u.created_at" class="leading-tight" :title="fmtTs(u.created_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(u.created_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(u.created_at) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">—</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
|
||||
<div v-if="u.last_login_at" class="leading-tight" :title="fmtTs(u.last_login_at)">
|
||||
<div class="text-white/65 tabular-nums">{{ fmtDate(u.last_login_at) }}</div>
|
||||
<div class="text-white/35 tabular-nums">{{ fmtClock(u.last_login_at) }}</div>
|
||||
</div>
|
||||
<span v-else class="text-white/25">从未登录</span>
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-xs font-mono text-white/55 truncate" :title="u.last_login_ip || ''">
|
||||
{{ u.last_login_ip || '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button @click="editing = JSON.parse(JSON.stringify(u))" class="act" title="编辑">
|
||||
<Icon name="config" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button @click="delUser(u)" class="act danger" title="删除">
|
||||
<Icon name="trash" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- pagination -->
|
||||
<div v-if="!loading && totalPages > 1"
|
||||
class="flex items-center justify-between gap-3 border-t border-white/[0.06] px-5 py-3 text-xs text-white/55">
|
||||
<div>
|
||||
<span class="tabular-nums text-white/85">{{ (page - 1) * pageSize + 1 }}–{{ Math.min(filtered.length, page * pageSize) }}</span>
|
||||
<span class="ml-1">/ {{ filtered.length }} 条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(n, i) in pageNumbers" :key="i">
|
||||
<span v-if="n === null" class="px-1 text-white/35">…</span>
|
||||
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add modal -->
|
||||
<div v-if="showAdd"
|
||||
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
|
||||
@click.self="showAdd = false">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-md">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">新建用户</h2>
|
||||
<button @click="showAdd = false" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<div>
|
||||
<label class="lbl">邮箱 <span class="text-rose-300">*</span></label>
|
||||
<input v-model="addForm.email" class="field" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">用户名</label>
|
||||
<input v-model="addForm.name" class="field" placeholder="6-24位,仅字母数字" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">初始密码</label>
|
||||
<input v-model="addForm.password" type="password" class="field" placeholder="留空表示不设密码;否则需满足8-24位且含大小写/数字/符号" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">初始积分</label>
|
||||
<input v-model.number="addForm.credits" type="number" min="0" step="1" class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">角色</label>
|
||||
<SelectMenu v-model="addForm.role" :options="ROLE_OPTIONS" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button @click="showAdd = false" class="btn-soft">取消</button>
|
||||
<button @click="createUser" class="btn-primary">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<div v-if="editing"
|
||||
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
|
||||
@click.self="editing = null">
|
||||
<div class="card !shadow-2xl my-12 w-full max-w-md">
|
||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">编辑用户</h2>
|
||||
<button @click="editing = null" class="text-white/40 hover:text-white">
|
||||
<Icon name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-3">
|
||||
<!-- Email + 用户名 are show-only — identity edits go through register
|
||||
or a future support flow, not from this maintenance screen. -->
|
||||
<div>
|
||||
<label class="lbl">邮箱</label>
|
||||
<input :value="editing.email" disabled class="field font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">用户名</label>
|
||||
<input :value="editing.name" disabled class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">状态</label>
|
||||
<SelectMenu v-model="editing.status" :options="STATUS_OPTIONS" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">角色</label>
|
||||
<!-- 管理员唯一:管理员账号角色锁定,不可改;其他人只能在 普通用户/代理 间切换 -->
|
||||
<input v-if="editing.role === 'admin'" value="管理员(唯一,不可更改)" disabled class="field" />
|
||||
<SelectMenu v-else v-model="editing.role" :options="ROLE_OPTIONS" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">积分</label>
|
||||
<input v-model.number="editing.credits" type="number" min="0" step="1" class="field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">重置密码 <span class="text-white/35">(留空保持不变)</span></label>
|
||||
<input v-model="editing._newPassword" type="password" class="field" placeholder="新密码(8-24位,含大小写/数字/符号)" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button @click="editing = null" class="btn-soft">取消</button>
|
||||
<button @click="saveEdit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<transition name="fade">
|
||||
<div v-if="toast"
|
||||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg">
|
||||
{{ toast }}
|
||||
</div>
|
||||
</transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* --- filter pills (mirrors LogsView/ModelsView) */
|
||||
.fp {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem; font-size: 0.72rem;
|
||||
border-radius: 0.55rem;
|
||||
color: rgb(255 255 255 / 0.65);
|
||||
background: rgb(255 255 255 / 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
|
||||
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
.fp-emerald {
|
||||
background: rgb(16 185 129 / 0.22);
|
||||
color: rgb(110 231 183);
|
||||
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
|
||||
}
|
||||
.fp-rose {
|
||||
background: rgb(244 63 94 / 0.22);
|
||||
color: rgb(253 164 175);
|
||||
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
|
||||
}
|
||||
.fp-fuchsia {
|
||||
background: rgb(217 70 239 / 0.22);
|
||||
color: rgb(245 208 254);
|
||||
box-shadow: inset 0 0 0 1px rgb(245 208 254 / 0.45);
|
||||
}
|
||||
.fp-amber {
|
||||
background: rgb(245 158 11 / 0.22);
|
||||
color: rgb(252 211 77);
|
||||
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45);
|
||||
}
|
||||
|
||||
/* --- icon-only action buttons */
|
||||
.act {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.9rem; height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.act.danger {
|
||||
color: rgb(253 164 175);
|
||||
background: rgb(244 63 94 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
|
||||
}
|
||||
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
|
||||
|
||||
/* disabled inputs in the edit modal — readable, but visually 'cool' so the
|
||||
admin knows they can't change them. */
|
||||
.field:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
}
|
||||
|
||||
/* iOS-style switch for the 状态 column — mirrors the one in ModelsView. */
|
||||
.sw {
|
||||
position: relative;
|
||||
width: 2.25rem; height: 1.3rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.sw-thumb {
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.sw-on {
|
||||
background: rgb(16 185 129 / 0.7);
|
||||
box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5);
|
||||
}
|
||||
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||
|
||||
/* --- numbered pagination buttons */
|
||||
.pg {
|
||||
min-width: 1.75rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border-radius: 0.45rem;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
|
||||
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user