Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e916c985 | ||
|
|
509b0d41c6 | ||
|
|
4d31219368 | ||
|
|
8b85d5623a | ||
|
|
2fa0324a66 | ||
|
|
5ddce37ee6 | ||
|
|
c5cc2e9086 | ||
|
|
a1f61d548d | ||
|
|
89a9d76b3e | ||
|
|
8fd49db8c2 | ||
|
|
466507b388 | ||
|
|
4632b1ab04 | ||
|
|
72d4eec203 | ||
|
|
5226e11cfc | ||
|
|
b76e6ab717 | ||
|
|
abd7b5c23b |
@@ -1,305 +1,137 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="frontend/public/favicon.svg" width="84" alt="Vivid AI" />
|
<img src="frontend/public/favicon.svg" width="84" alt="image2api" />
|
||||||
|
|
||||||
<h1>image2api</h1>
|
<h1>image2api</h1>
|
||||||
|
|
||||||
**Multi-provider AI image / video generation gateway — one OpenAI-compatible API, seven platforms aggregated, a ready-to-run operations system**
|
**vivid-protocol AI image / video generation gateway — channel-based upstream routing, a ready-to-run operations system**
|
||||||
|
|
||||||
<sub>Live instance (brand): [Vivid AI · vividai.run](https://vividai.run)</sub>
|
<sub>Live instance (brand): [Vivid AI · vividai.run](https://vividai.run)</sub>
|
||||||
|
|
||||||
[简体中文](README.md) | **English**
|
[简体中文](README.md) | **English**
|
||||||
|
|
||||||
[](https://vividai.run)
|
[](https://vividai.run)
|
||||||
|
|
||||||
[](https://go.dev)
|
[](https://go.dev)
|
||||||
[](https://vuejs.org)
|
[](https://vuejs.org)
|
||||||
[](#-deployment)
|
[](#-deployment)
|
||||||
[](#-openai-compatible-api)
|
|
||||||
[](#-deployment)
|
|
||||||
[](#-supported-models--providers)
|
|
||||||
[](#-deployment)
|
[](#-deployment)
|
||||||
[](#-license)
|
[](#-license)
|
||||||
|
|
||||||
[Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-openai-compatible-api) · [Community](#-community--contact)
|
[Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-vivid-protocol-api)
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
<img src="docs/screenshots/playground.png" alt="image2api — playground" width="860" />
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📖 Table of Contents
|
## ✨ Introduction
|
||||||
|
|
||||||
- [Overview](#-overview)
|
**image2api** is a homogeneous gateway. Upstream, it connects any number of **vivid-protocol (vividai) instances** as "channels" and routes jobs across them. Downstream, it exposes the same **vivid-protocol API** to callers: `POST /v1/generate` (NDJSON stream) · `POST /v1/uploads` (presigned upload) · `GET /v1/models` · `GET /v1/balance`. No built-in provider code, no account pools — an upstream is just another vividai instance, so gateways can be layered arbitrarily.
|
||||||
- [Screenshots](#-screenshots)
|
|
||||||
- [Features](#-features)
|
|
||||||
- [Supported Models / Providers](#-supported-models--providers)
|
|
||||||
- [OpenAI-Compatible API](#-openai-compatible-api)
|
|
||||||
- [Deployment](#-deployment)
|
|
||||||
- [Tech Stack](#-tech-stack)
|
|
||||||
- [Repository Layout](#-repository-layout)
|
|
||||||
- [Roadmap](#-roadmap)
|
|
||||||
- [Community / Contact](#-community--contact)
|
|
||||||
- [License](#-license)
|
|
||||||
|
|
||||||
## ✨ Overview
|
It is more than a gateway: built-in **credit billing, CDK top-ups, online payment (epay), invite rewards, reseller pricing, user accounts, an admin console and a modern generation UI** — one command turns it into a production AI generation site. The author's live instance **[Vivid AI · vividai.run](https://vividai.run)** runs on this project.
|
||||||
|
|
||||||
**image2api** wraps the image / video capabilities of Adobe Firefly, OpenAI, Runway, Grok, Leonardo, Krea and Imagine into **a single OpenAI-compatible API**. Behind it, multi-account pools are scheduled automatically — out of quota → switch account, auth expired → refresh or kill, transient errors → retry, tokens proactively renewed before they expire — to deliver a stable service.
|
> 💡 Frontend + backend fully open source (MIT). Go + Vue 3.
|
||||||
|
|
||||||
It's more than an API proxy: it ships with **credit billing, CDK top-ups, referral rewards, a user system, an admin console, and a modern generation frontend**, so a single command turns it into a fully operational AI generation site — the author's live instance **[Vivid AI · vividai.run](https://vividai.run)** (brand) is built on this project.
|
**One-liner** 🔁 vivid-protocol channel mesh · 📦 save-a-channel syncs all models · 💳 credits + payment + reseller pricing · 🧩 concurrency groups · 🎨 generation UI + admin console · 🐳 one-command deploy
|
||||||
|
|
||||||
> 💡 Both frontend and backend are **fully open-source** (MIT) — Go + Vue 3, free to fork and self-host.
|
|
||||||
|
|
||||||
**At a glance** 🔌 OpenAI-compatible · 🤖 7 platforms, 10+ models · 🔁 auto failover / token keep-alive · 💳 credits + agent pricing · 🎨 generation frontend + admin console · 🐳 one-command deploy (bring your own TLS proxy)
|
|
||||||
|
|
||||||
## 🖼️ Screenshots
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<sub>🎨 Modern generation frontend · light / dark admin console · data-driven ops dashboard</sub>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td width="50%"><img src="docs/screenshots/dashboard.png" alt="Dashboard" /></td>
|
|
||||||
<td width="50%"><img src="docs/screenshots/models.png" alt="Models" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><b>📊 Dashboard</b><br/><sub>Users / volume / provider health / 24h trend</sub></td>
|
|
||||||
<td align="center"><b>🧩 Model management</b><br/><sub>Per-model capabilities, pricing & weight</sub></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="50%"><img src="docs/screenshots/accounts.png" alt="Accounts" /></td>
|
|
||||||
<td width="50%"><img src="docs/screenshots/logs.png" alt="Logs" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><b>🔑 Account pools</b><br/><sub>Multi-account pools · weight / concurrency · CRUD</sub></td>
|
|
||||||
<td align="center"><b>📜 Call logs</b><br/><sub>Success / failure / in-progress · prompts & latency</sub></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
#### 🎨 Generation
|
#### 🎨 Generation
|
||||||
- Images + videos in one place, with **image-to-image / reference frames** (first frame, last frame, style reference)
|
- Image + video in one place, with multi-modal references (images / first-last frames / video / audio) per model capability
|
||||||
- Multiple resolutions (images 1K / 2K / 4K · videos 720p / 1080p), aspect ratios and video durations — configured and priced per model
|
- Multiple resolutions (1K/2K/4K images · 720p/1080p/768p video), aspect ratios, discrete or per-second video durations — all per-model configurable and priced
|
||||||
- 7 providers, 10+ models, **enable / disable / re-price from the admin console**, no code changes
|
- **Priced = listed**: no enable switch — a model goes public once every tier is priced and an enabled channel serves it
|
||||||
- **Model aliases**: one model can expose multiple public ids — API calls with any alias resolve to it
|
- **Model aliases**: one upstream model can map to several public ids
|
||||||
- **De-AI fingerprint** (optional): one-click toggle on the playground — generated images get anti-AI-detection post-processing (subtle detail jitter + metadata stripping), charged as a per-tier surcharge (defaults 1K+1 / 2K+2 / 4K+3 credits, admin-configurable, can be disabled globally); processed works carry a "de-AI" badge across the playground, gallery, logs and admin image manager
|
- Optional **de-AI** post-processing on generated images (per-tier surcharge)
|
||||||
|
|
||||||
#### 🔌 OpenAI Compatible
|
#### 🔁 Channel routing
|
||||||
- Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models` · balance `/v1/user/balance` (remaining / cumulative used)
|
- **Save = full sync**: storing a channel pulls the upstream `/v1/models`, binds everything, unbinds what disappeared and deletes orphans
|
||||||
- **Strict OpenAI params**: `size` drives **both aspect ratio + resolution tier** (images by long edge → 1K/2K/4K, videos by short edge → 720p/1080p) — just swap `base_url` + `api_key` into an existing OpenAI SDK
|
- **Weight + concurrency-aware** dispatch with failover; upstream 4xx rejections pass through in Chinese, transient errors move to the next channel
|
||||||
- Image results returned **inline as base64** — nothing stored server-side, privacy-friendly; the in-app **/docs** ships a size ↔ tier reference table
|
- Channel keys stored AES-GCM encrypted (`CHANNEL_SECRET`); upstream balance probed live
|
||||||
|
|
||||||
#### 🔁 Account Pools + Smart Failover
|
#### 🔌 vivid-protocol API
|
||||||
- Round-robin scheduling across the pool; one bad account doesn't break the whole
|
- `POST /v1/generate` — NDJSON stream: first frame returns `jobId`, ~25s blank-line heartbeats, terminal `succeeded`/`failed`
|
||||||
- **Out of quota → switch** · **auth expired → refresh & retry / kill** · **transient → retry same account ×3** · **bad params → fail fast**
|
- `POST /v1/uploads` — presigned PUT direct-to-storage for reference files
|
||||||
- **Pre-deducted credits**: atomic debit before generation, auto-refunded on failure, no over-spend under concurrency
|
- `GET /v1/models` — bare array with capability specs · `GET /v1/balance` — balance
|
||||||
|
- **jobId reconnect**: resubmit with only `jobId` to resume a dropped stream — no duplicate job or charge; pending jobs resume after a server restart too
|
||||||
|
- Unified errors `{"error":{"code","message"}}` (4001 rejected / 4002 insufficient / 4011 unauthorized / 4293 concurrency / 5001 server); API keys are `vk_` + 48 hex
|
||||||
|
|
||||||
#### 🔐 Automatic Token Keep-alive
|
#### 💳 Billing & operations
|
||||||
- Single-use rotating tokens (Krea / Imagine) are **renewed proactively 10 minutes before expiry**; new tokens persisted automatically
|
- Credits (pre-charge + auto-refund), per model/tier/duration pricing; de-AI surcharge per tier
|
||||||
- Adobe cookies exchanged for fresh tokens on a schedule; bare JWTs killed on expiry
|
- **Reseller pricing**: reseller role + per-model reseller prices (API calls included)
|
||||||
- Daily quota recovered at each provider's reset time, then re-probed for the real balance
|
- **Online payment (epay)**: WeChat/Alipay QR, fixed + custom amounts, unpaid orders auto-cancel after 30 min, MD5-verified idempotent callbacks
|
||||||
|
- **CDK codes** · invite rewards · email register / verification / password reset
|
||||||
|
- **Concurrency groups**: per-group in-flight caps across UI + API (`0` = unlimited)
|
||||||
|
- Roles: user / reseller / admin
|
||||||
|
|
||||||
#### 💳 Billing & Operations
|
#### 🛠️ Admin console
|
||||||
- Credit-based (**pre-deduct + refund on failure**), priced per model / resolution / duration; de-AI fingerprint adds a per-tier surcharge
|
- Dashboard (trends / DAU / top failures / top spenders)
|
||||||
- **Agent pricing**: a user can be set as an "agent" role and models can carry agent prices; agent users (including their API key calls) are billed at the agent price, falling back to the normal price when unset
|
- **Channels** (save-to-sync, upstream balance, enable, concurrency, weight) · models (standard + reseller pricing, aliases, read-only listable state) · concurrency groups · orders · global logs · users · CDK · media manager · showcase · announcements · site config
|
||||||
- **Online top-up (易支付 / epay)**: WeChat / Alipay QR, preset + custom amounts, unpaid orders auto-cancel after 30 min, MD5-verified idempotent callback auto-credits; cumulative top-up tracked
|
- **Banned words**: prompt hits rejected, per-word / per-user trigger stats
|
||||||
- **CDK redeem codes** · **referral rewards** · email sign-up / verification code / password reset
|
|
||||||
- **Concurrency groups**: cap a user's simultaneous generations (playground + API key combined, `0` = unlimited), self-healing Redis counters, new users auto-join the default group
|
|
||||||
- Three roles: regular user / agent / admin (single)
|
|
||||||
|
|
||||||
#### 🖥️ User Frontend (Vue 3)
|
**🧰 Engineering**: S3/RustFS media behind an authenticated proxy + retention cleanup · presigned reference uploads · self-healing maintenance (stale-job resume/refund, expired file pruning) · one-command Docker deploy (TLS is your reverse proxy's job).
|
||||||
- Playground · creations gallery · generation logs (with failure reasons / source tags)
|
|
||||||
- Gallery **multi-select batch ops**: select page / bulk delete (videos take their frame stills along) / bulk download (multiple files auto-packed into a zip, fetched concurrently)
|
|
||||||
- Lightbox preview with built-in **copy original / download / close** buttons; one-click copy of the original image from cards
|
|
||||||
- **Top-up · Orders** (recharge history / resume unpaid) · API docs · API key management · referral · about, light / dark theme
|
|
||||||
- **In-app announcements**: a Markdown notice pops up after login and re-shows whenever its content changes
|
|
||||||
|
|
||||||
#### 🛠️ Admin Console
|
## 🔌 Channel architecture
|
||||||
- Overview dashboard (trends / DAU / top failures / top spenders)
|
|
||||||
- Model management (normal + agent price + aliases) · account management (bulk import / dedup / quota) · **concurrency groups** · **order management** (filter / search / paginate) · site-wide logs · user management (set as agent / assign concurrency group / view cumulative top-up / banned-word hits) · CDK · image management (multi-select bulk delete / zip download) · showcase · **announcements** · site config (incl. epay, de-AI fingerprint toggle & surcharge pricing)
|
|
||||||
- **Banned words**: add / remove words in the console (paginated + multi-select bulk delete); prompts containing a banned word are rejected outright (playground + API, case-insensitive), with per-word / per-user hit counters
|
|
||||||
|
|
||||||
**🧰 Engineering highlights**: tls-client (Chrome JA3/JA4 fingerprint) reliably passes Cloudflare · media stored in S3/RustFS, served through an authenticated proxy with retention cleanup · self-healing maintenance loop (quota recovery / credential refresh / orphan-job cleanup with refunds) · one-command Docker deploy (TLS via your own reverse proxy).
|
```
|
||||||
|
caller ──vivid protocol──▶ image2api ──vivid protocol──▶ upstream vividai A (weight w, conc c)
|
||||||
## 🤖 Supported Models / Providers
|
(this host) ├─ upstream vividai B
|
||||||
|
└─ …any number
|
||||||
| Provider | Models (examples) | Type |
|
|
||||||
|---|---|---|
|
|
||||||
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | Image / Video |
|
|
||||||
| **OpenAI** | gpt-image-2 | Image |
|
|
||||||
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | Video / Image |
|
|
||||||
| **Grok (console.x.ai)** | grok-video (imagine text/image-to-video), grok-image / grok-image-quality (imagine text/image-to-image) | Video / Image |
|
|
||||||
| **Leonardo.ai** | seedream-4.5 | Image |
|
|
||||||
| **Krea.ai** | flux-klein-2 | Image |
|
|
||||||
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | Image |
|
|
||||||
|
|
||||||
> Models are enabled and priced dynamically from the admin console — add or remove anytime.
|
|
||||||
|
|
||||||
## 🔌 OpenAI-Compatible API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Text-to-image — pure OpenAI params: size drives both aspect ratio + tier (long edge <1800→1K / <3500→2K / ≥3500→4K)
|
|
||||||
curl https://your-domain/v1/images/generations \
|
|
||||||
-H "Authorization: Bearer sk-xxxx" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"model": "gpt-image-2",
|
|
||||||
"prompt": "a cute cat on a desk, studio lighting",
|
|
||||||
"size": "2048x2048"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Image-to-image — multipart reference upload (multiple via image[])
|
|
||||||
curl https://your-domain/v1/images/edits \
|
|
||||||
-H "Authorization: Bearer sk-xxxx" \
|
|
||||||
-F model="seedream-4.5" -F prompt="make it cyberpunk" -F image=@input.png
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Images return OpenAI-style `{ "created": ..., "data": [{ "b64_json": "..." }] }` (raw base64, no `data:` prefix, nothing stored server-side). **Video** is async: `POST /v1/videos` → poll `GET /v1/videos/{id}` until `completed` → `GET /v1/videos/{id}/content` for the mp4. Full parameters are documented on the in-app **/docs** page.
|
- A channel = a homogeneous upstream: `base_url` + `vk_` key + enable switch + concurrency cap + routing weight
|
||||||
|
- Models come from the upstream `/v1/models` sync; locally they only get aliases and pricing; **one model can fail over across channels**
|
||||||
|
- This service speaks the same protocol externally — it can itself be mounted as someone's channel, infinitely nestable
|
||||||
|
- **No OpenAI-protocol upstreams**: a channel must be a vivid-protocol instance
|
||||||
|
|
||||||
|
## 📡 vivid-protocol API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# generate — NDJSON stream; first frame returns jobId
|
||||||
|
curl -N https://your-domain/v1/generate \
|
||||||
|
-H "Authorization: Bearer vk_xxxxxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model": "seedance-2.0", "prompt": "a cute cat on a desk", "ratio": "16:9", "quality": "720p"}'
|
||||||
|
# → {"jobId":"…","status":"running"} …heartbeats… {"status":"succeeded","outputs":[{"url":"…"}]}
|
||||||
|
|
||||||
|
# reconnect — resubmit with only jobId, no duplicate job
|
||||||
|
curl -N https://your-domain/v1/generate -H "Authorization: Bearer vk_xxxx" -d '{"jobId":"<jobId>"}'
|
||||||
|
|
||||||
|
# reference files — presigned upload session, then PUT the bytes
|
||||||
|
curl https://your-domain/v1/uploads -H "Authorization: Bearer vk_xxxx" -d '{"kind":"image","mime":"image/png"}'
|
||||||
|
|
||||||
|
curl https://your-domain/v1/models -H "Authorization: Bearer vk_xxxx"
|
||||||
|
curl https://your-domain/v1/balance -H "Authorization: Bearer vk_xxxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
Full parameters, the error-code table and Python examples live on the in-app **/docs** page.
|
||||||
|
|
||||||
## 🚀 Deployment
|
## 🚀 Deployment
|
||||||
|
|
||||||
> Domain + HTTPS are handled by your own reverse proxy (this project issues no certificates).
|
**Docker (recommended)**: `docker compose up -d --build` brings up PostgreSQL + Redis + RustFS + backend + frontend (nginx listens on container **HTTP :2000**); point your reverse proxy at `http://<host>:2000`.
|
||||||
|
|
||||||
**Docker (recommended)**: `docker compose up -d --build` brings up PostgreSQL + Redis + RustFS + backend + frontend (nginx serving **HTTP on container port 2000**); point your reverse proxy at `http://<host>:2000` (port via `WEB_PORT`; edit the values (passwords / keys / `CORS_ORIGINS`, and `COOKIE_SECURE=true` when your proxy serves HTTPS) directly in `docker-compose.yml`).
|
> ⚠️ Change the secrets in `docker-compose.yml` before deploying — especially **`CHANNEL_SECRET`** (encrypts stored channel keys; rotating it makes saved keys undecryptable).
|
||||||
|
|
||||||
Or **build from source** — bring your own **PostgreSQL · Redis · RustFS (or any S3) · reverse proxy**:
|
Manual build needs PostgreSQL · Redis · RustFS (or any S3) · a reverse proxy — see `backend/.env.example` for all env vars.
|
||||||
|
|
||||||
```bash
|
After deploy: admin → Channels → add channel (upstream URL + `vk_` key) → models auto-sync → price them in 模型管理 → done.
|
||||||
# 1. Create an empty database (the backend auto-migrates on start)
|
|
||||||
createdb vivid_ai
|
|
||||||
|
|
||||||
# 2. Configure and build the backend from source
|
## 🧱 Stack
|
||||||
cat > backend/.env <<'EOF'
|
|
||||||
APP_ENV=production
|
|
||||||
HTTP_ADDR=127.0.0.1:6666
|
|
||||||
POSTGRES_DSN=host=127.0.0.1 user=postgres password=YOUR_PASSWORD dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai
|
|
||||||
REDIS_ADDR=127.0.0.1:6379
|
|
||||||
RUSTFS_ENDPOINT=http://127.0.0.1:9000
|
|
||||||
RUSTFS_BUCKET=vivid-ai
|
|
||||||
RUSTFS_ACCESS_KEY=YOUR_AK
|
|
||||||
RUSTFS_SECRET_KEY=YOUR_SK
|
|
||||||
CORS_ORIGINS=https://your-domain
|
|
||||||
COOKIE_SECURE=true
|
|
||||||
EOF
|
|
||||||
cd backend && go build -o bin/api ./cmd/api && ./bin/api # listens on 127.0.0.1:6666
|
|
||||||
|
|
||||||
# 3. Build the frontend (output in frontend/dist)
|
| Layer | Tech |
|
||||||
cd frontend && npm install && npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Nginx reverse proxy (issue the certificate yourself with certbot / acme.sh):
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
server_name your-domain;
|
|
||||||
ssl_certificate /path/fullchain.pem;
|
|
||||||
ssl_certificate_key /path/privkey.pem;
|
|
||||||
root /path/to/frontend/dist;
|
|
||||||
index index.html;
|
|
||||||
client_max_body_size 50m;
|
|
||||||
proxy_read_timeout 600s; # video generation can take a while
|
|
||||||
|
|
||||||
location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; }
|
|
||||||
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
|
|
||||||
location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; }
|
|
||||||
location ^~ /images/ { proxy_pass http://127.0.0.1:6666; }
|
|
||||||
location = /health { proxy_pass http://127.0.0.1:6666; }
|
|
||||||
location ^~ /v1/ { proxy_pass http://127.0.0.1:6666; add_header Cache-Control "no-store" always; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> See `backend/.env.example` for the full set of environment variables.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## 🧱 Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Backend | Go · gin · gorm (PostgreSQL) · go-redis · tls-client (Chrome fingerprint) |
|
| Backend | Go · gin · gorm (PostgreSQL) · go-redis |
|
||||||
| Frontend | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
|
| Frontend | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
|
||||||
| Infrastructure | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx |
|
| Infra | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx |
|
||||||
|
|
||||||
## 📦 Repository Layout
|
## 💬 Contact
|
||||||
|
|
||||||
```
|
|
||||||
backend/ Backend source (Go)
|
|
||||||
├── cmd/
|
|
||||||
│ ├── api/ Service entry point (main)
|
|
||||||
│ └── marklabel/ Ops helper (mark accounts on demand)
|
|
||||||
├── internal/
|
|
||||||
│ ├── bootstrap/ App wiring, scheduled maintenance startup
|
|
||||||
│ ├── config/ Env-var configuration loading
|
|
||||||
│ ├── http/
|
|
||||||
│ │ ├── handler/ HTTP handlers (v1-compatible API, admin, auth…)
|
|
||||||
│ │ ├── middleware/ Auth / request-id middleware
|
|
||||||
│ │ └── router/ Route registration
|
|
||||||
│ ├── model/ GORM data models
|
|
||||||
│ ├── provider/ Upstream provider clients
|
|
||||||
│ │ ├── adobe/ Adobe Firefly (tls-client fingerprint)
|
|
||||||
│ │ ├── chatgpt/ OpenAI (incl. PoW / turnstile)
|
|
||||||
│ │ ├── runway/ Runway video + Nano Banana image
|
|
||||||
│ │ ├── grok/ Grok (console.x.ai DPoP, video / image; account state via grok.com)
|
|
||||||
│ │ ├── leonardo/ Leonardo
|
|
||||||
│ │ ├── krea/ Krea
|
|
||||||
│ │ ├── imagine/ Imagine.art
|
|
||||||
│ │ ├── custom/ Custom upstream (OpenAI-compatible v1, routed by id)
|
|
||||||
│ │ └── epay/ 易支付 / epay (mapi order + MD5-verified callback, top-ups)
|
|
||||||
│ ├── repo/ Data-access layer (users / models / accounts / logs / CDK / orders / concurrency groups…)
|
|
||||||
│ ├── service/ Business logic (scheduling, billing, account pools, keep-alive, maintenance)
|
|
||||||
│ └── storage/ RustFS / S3 media storage
|
|
||||||
├── Dockerfile Multi-stage build (compile source → slim runtime image)
|
|
||||||
└── .env.example Backend env-var template
|
|
||||||
|
|
||||||
frontend/ Frontend source (Vue 3 + Vite)
|
|
||||||
├── src/
|
|
||||||
│ ├── views/ Pages (playground / accounts / models / users / concurrency / orders / logs / overview / top-up / settings…)
|
|
||||||
│ ├── components/ Reusable components (modals / selectors / lightbox…)
|
|
||||||
│ ├── layouts/ Public / admin layouts
|
|
||||||
│ ├── utils/ Utility functions
|
|
||||||
│ └── api.js · auth.js … API client, auth, theme, credits, etc.
|
|
||||||
├── Dockerfile Nginx static hosting (HTTP :2000) + API proxy
|
|
||||||
└── default.conf.template Nginx site template (reverse proxy + caching)
|
|
||||||
|
|
||||||
docker-compose.yml Docker orchestration (Postgres / Redis / RustFS / backend / frontend)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
|
||||||
|
|
||||||
- [ ] More upstream providers
|
|
||||||
- [ ] Usage analytics / export
|
|
||||||
- [ ] Multi-language UI (i18n)
|
|
||||||
- [ ] Webhook / async callbacks
|
|
||||||
|
|
||||||
## 💬 Community / Contact
|
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 🌐 Website | **[vividai.run](https://vividai.run)** |
|
| 🌐 Site | **[vividai.run](https://vividai.run)** |
|
||||||
| 👥 QQ Group | **1106849765** · [Join](https://qm.qq.com/q/976LeMFoHu) |
|
| 👥 QQ group | **1106849765** · [join](https://qm.qq.com/q/976LeMFoHu) |
|
||||||
| 🐧 QQ | **1114639355** · [Add](https://qm.qq.com/q/ItgCcNA7ac) |
|
| 🐧 QQ | **1114639355** · [add](https://qm.qq.com/q/ItgCcNA7ac) |
|
||||||
| 🛒 Shop | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
|
|
||||||
| ✉️ Email | vividairun@gmail.com |
|
| ✉️ Email | vividairun@gmail.com |
|
||||||
|
|
||||||
## ⭐ Star History
|
|
||||||
|
|
||||||
<!-- After creating the GitHub repo, uncomment the line below and replace OWNER with your username to show the chart: -->
|
|
||||||
<!-- [](https://star-history.com/#OWNER/image2api&Date) -->
|
|
||||||
|
|
||||||
If you find this useful, give it a ⭐ — uncomment the line above after creating the repo to show the Star History chart.
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
This project (frontend + backend) is open-source under the [MIT](LICENSE) license — free to use, modify, commercialize and redistribute.
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
If this project helps you, a ⭐ Star is much appreciated!
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="frontend/public/favicon.svg" width="84" alt="Vivid AI" />
|
<img src="frontend/public/favicon.svg" width="84" alt="image2api" />
|
||||||
|
|
||||||
<h1>image2api</h1>
|
<h1>image2api</h1>
|
||||||
|
|
||||||
**多供应商 AI 生图 / 生视频网关 —— 一套 OpenAI 兼容 API,聚合七大平台,开箱即用的运营系统**
|
**vivid 协议 AI 生图 / 生视频网关 —— 渠道化接入同构上游,开箱即用的运营系统**
|
||||||
|
|
||||||
<sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub>
|
<sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub>
|
||||||
|
|
||||||
@@ -15,13 +15,10 @@
|
|||||||
[](https://go.dev)
|
[](https://go.dev)
|
||||||
[](https://vuejs.org)
|
[](https://vuejs.org)
|
||||||
[](#-部署)
|
[](#-部署)
|
||||||
[](#-openai-兼容-api)
|
|
||||||
[](#-部署)
|
|
||||||
[](#-支持的模型--供应商)
|
|
||||||
[](#-部署)
|
[](#-部署)
|
||||||
[](#-license)
|
[](#-license)
|
||||||
|
|
||||||
[在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-openai-兼容-api) · [交流群](#-交流--联系)
|
[在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-vivid-协议-api) · [交流群](#-交流--联系)
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
@@ -36,24 +33,23 @@
|
|||||||
- [简介](#-简介)
|
- [简介](#-简介)
|
||||||
- [界面预览](#-界面预览)
|
- [界面预览](#-界面预览)
|
||||||
- [核心功能](#-核心功能)
|
- [核心功能](#-核心功能)
|
||||||
- [支持的模型 / 供应商](#-支持的模型--供应商)
|
- [渠道架构](#-渠道架构)
|
||||||
- [OpenAI 兼容 API](#-openai-兼容-api)
|
- [vivid 协议 API](#-vivid-协议-api)
|
||||||
- [部署](#-部署)
|
- [部署](#-部署)
|
||||||
- [技术栈](#-技术栈)
|
- [技术栈](#-技术栈)
|
||||||
- [仓库结构](#-仓库结构)
|
- [仓库结构](#-仓库结构)
|
||||||
- [Roadmap](#-roadmap)
|
|
||||||
- [交流 / 联系](#-交流--联系)
|
- [交流 / 联系](#-交流--联系)
|
||||||
- [License](#-license)
|
- [License](#-license)
|
||||||
|
|
||||||
## ✨ 简介
|
## ✨ 简介
|
||||||
|
|
||||||
**image2api** 把 Adobe Firefly、OpenAI、Runway、Grok、Leonardo、Krea、Imagine 等平台,以及**任意 OpenAI 兼容上游**的图像 / 视频能力,统一封装成**一套 OpenAI 兼容的 API**;背后用多账号池自动调度 —— 权重优先 + 并发感知、额度耗尽自动换号、认证失效自动刷新或判死、临时错误自动重试、token 到期前主动续期 —— 对外提供稳定服务。
|
**image2api** 是一个同构网关:对上,把任意多个 **vivid 协议(vividai)实例**接成「渠道」统一调度;对下,对外暴露同一套 **vivid 协议 API**(`POST /v1/generate` NDJSON 流 · `POST /v1/uploads` 预签名直传 · `GET /v1/models` · `GET /v1/balance`)。没有内置供应商代码、没有账号池 —— 上游就是另一个 vividai,套娃式组网任意层叠。
|
||||||
|
|
||||||
它不只是 API 代理:自带**积分计费、CDK 充值、邀请奖励、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)**(品牌)即基于本项目搭建。
|
它不只是 API 网关:自带**积分计费、CDK 充值、在线充值(易支付)、邀请奖励、代理价体系、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)** 即基于本项目搭建。
|
||||||
|
|
||||||
> 💡 前后端**完全开源**(MIT),Go + Vue 3,可自由二开 / 自部署。
|
> 💡 前后端**完全开源**(MIT),Go + Vue 3,可自由二开 / 自部署。
|
||||||
|
|
||||||
**一句话亮点** 🔌 OpenAI 兼容 · 🤖 7 平台十余模型 · 🔁 自动换号 / Token 保活 · 💳 积分 + 在线充值(易支付)+ 代理价 · 🧩 并发分组 · 🎨 画图前端 + 管理后台 · 🐳 一键部署(TLS 反代自理)
|
**一句话亮点** 🔁 vivid 协议渠道组网 · 📦 保存渠道即全量同步模型 · 💳 积分 + 充值 + 代理价 · 🧩 并发分组 · 🎨 画图前端 + 管理后台 · 🐳 一键部署(TLS 反代自理)
|
||||||
|
|
||||||
## 🖼️ 界面预览
|
## 🖼️ 界面预览
|
||||||
|
|
||||||
@@ -63,19 +59,13 @@
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%"><img src="docs/screenshots/dashboard.png" alt="概览看板" /></td>
|
<td width="33%"><img src="docs/screenshots/dashboard.png" alt="概览看板" /></td>
|
||||||
<td width="50%"><img src="docs/screenshots/models.png" alt="模型管理" /></td>
|
<td width="33%"><img src="docs/screenshots/models.png" alt="模型管理" /></td>
|
||||||
|
<td width="33%"><img src="docs/screenshots/logs.png" alt="调用日志" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>📊 概览看板</b><br/><sub>用户 / 生成量 / Provider 健康 / 24h 趋势</sub></td>
|
<td align="center"><b>📊 概览看板</b><br/><sub>用户 / 生成量 / 渠道健康 / 24h 趋势</sub></td>
|
||||||
<td align="center"><b>🧩 模型管理</b><br/><sub>按模型独立配置能力、定价与权重</sub></td>
|
<td align="center"><b>🧩 模型管理</b><br/><sub>按模型独立配置能力、定价与权重,配价即上架</sub></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="50%"><img src="docs/screenshots/accounts.png" alt="账号池管理" /></td>
|
|
||||||
<td width="50%"><img src="docs/screenshots/logs.png" alt="调用日志" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><b>🔑 账号池管理</b><br/><sub>多账号池 · 权重 / 并发 · 一键增删改</sub></td>
|
|
||||||
<td align="center"><b>📜 调用日志</b><br/><sub>成功 / 失败 / 进行中 · 提示词与耗时全留痕</sub></td>
|
<td align="center"><b>📜 调用日志</b><br/><sub>成功 / 失败 / 进行中 · 提示词与耗时全留痕</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -83,117 +73,98 @@
|
|||||||
## 🚀 核心功能
|
## 🚀 核心功能
|
||||||
|
|
||||||
#### 🎨 生成能力
|
#### 🎨 生成能力
|
||||||
- 生图 + 生视频一站式,支持**图生图 / 参考图**(首帧、末帧、风格参考)
|
- 生图 + 生视频一站式,支持**参考图 / 首末帧 / 视频 / 音频**等多模态参考(按模型能力声明下发)
|
||||||
- 多分辨率(图像 1K / 2K / 4K · 视频 720p / 1080p)、多宽高比、视频多时长,按模型独立配置与定价
|
- 多分辨率(图像 1K / 2K / 4K · 视频 720p / 1080p / 768p)、多宽高比、视频多时长(离散档或区间档按秒计价),按模型独立配置与定价
|
||||||
- 7 大供应商、十余模型,后台**动态启用 / 下架 / 改价**,无需改代码
|
- **配价即上架**:模型无启停开关,全部档位有价 + 绑定了启用渠道即对外可见;不定价 = 隐藏
|
||||||
- **模型别名**:同一模型可配多个对外 id,API 调用任意别名均可命中
|
- **模型别名**:同一上游模型可配多个对外 id,API 调用任意别名均可命中
|
||||||
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理(细节微扰 + 去除元数据),按画质档位加收积分(默认 1K+1 / 2K+2 / 4K+3,后台可改价、可整体关闭);带标记的作品在画图台、创作记录、日志与后台图片管理中均有「去AI特征」标识
|
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理,按画质档位加收积分(后台可改价、可整体关闭)
|
||||||
|
|
||||||
#### 🔌 OpenAI 兼容
|
#### 🔁 渠道调度
|
||||||
- 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models` · 余额 `/v1/user/balance`(剩余/累计已用)
|
- **保存渠道即全量同步**:填上游 `base_url` + `vk_` key 保存,自动拉取上游 `/v1/models` 全部入库绑定;上游消失的模型自动解绑,无其他渠道的孤儿模型自动清理
|
||||||
- **严格 OpenAI 入参**:`size` **同时决定比例 + 分辨率档**(图像看长边 → 1K/2K/4K,视频看短边 → 720p/1080p),改个 `base_url` + `api_key` 即接现有 OpenAI SDK
|
- **权重 + 并发感知调度**:渠道可配权重(加权随机)与并发上限,逐渠道尝试、失败自动故障转移
|
||||||
- 图片结果 **base64 直返**,服务端不留存文件,隐私友好;站内 **/docs** 附「分辨率对照表」直接查 `size` 该传什么
|
- **错误分类**:上游 4xx(参数/额度)直接透传中文拒绝理由、不重试;网络/5xx 换渠道重试
|
||||||
|
- 渠道 API Key 以 AES-GCM 加密落库(`CHANNEL_SECRET`),上游余额实时探测
|
||||||
|
|
||||||
#### 🔁 多账号池 + 智能故障转移
|
#### 🔌 vivid 协议 API
|
||||||
- 账号池调度,单账号出错不影响整体
|
- `POST /v1/generate` —— NDJSON 流式生成:首行回执 `jobId`,约 25s 空行心跳,终帧 `succeeded`/`failed`
|
||||||
- **权重优先 + 并发感知**:按账号权重从高到低调度,某账号并发满了才轮到下一个;同权重组内 round-robin 均摊。每账号并发数可配(上游账号),其余系统固定
|
- `POST /v1/uploads` —— 预签名 PUT 直传参考文件到对象存储
|
||||||
- **额度耗尽→换号** · **认证失效→刷新重试 / 判死** · **临时错误→同号重试 ×3** · **参数错→直接报错**
|
- `GET /v1/models` —— 裸数组,含能力规格(清晰度档位 / 比例 / 时长 / 参考文件上限)
|
||||||
- **预扣额度**:生成前原子扣减,失败自动退回,杜绝并发超额
|
- `GET /v1/balance` —— 余额查询
|
||||||
|
- **jobId 断流重连**:断流后仅带 `jobId` 重发同一请求即可续等,不重复下单、不重复扣费;进程重启后带 `jobId` 的任务也会自动续等
|
||||||
#### 🔗 自定义上游聚合(OpenAI 兼容)
|
- 统一错误包 `{"error":{"code","message"}}`(4001 参数拒绝 / 4002 积分不足 / 4011 未授权 / 4293 并发上限 / 5001 服务异常);API Key 为 `vk_` + 48 hex
|
||||||
- 把任意 **OpenAI 兼容的 v1 端点**当成一个账号接入(填 `base_url` + `key`),无需写代码
|
|
||||||
- **按 model id 自动路由**:上游声明支持哪些 id,生成该 id 时即走对应上游(可覆盖内置 provider);id 留空 = 全部
|
|
||||||
- 模型管理里自由新建自定义模型(id / 类型 / 比例 / 分辨率·价 / 时长·价 / 参考图),按本地价计费
|
|
||||||
- 调用**直连不走代理**;上游可配权重与并发,与内置池统一调度
|
|
||||||
|
|
||||||
#### 🔐 Token 自动保活
|
|
||||||
- 一次性轮换 token(Krea / Imagine)**到期前 10 分钟主动续期**,新 token 自动落库
|
|
||||||
- Adobe cookie 定时换 token;纯 JWT 到期自动判死
|
|
||||||
- 每日额度按平台重置时间自动恢复 + 重新探测真实余额
|
|
||||||
|
|
||||||
#### 💳 计费与运营
|
#### 💳 计费与运营
|
||||||
- 积分制(**预扣 + 失败退款**),按模型 / 分辨率 / 时长精细定价;去AI特征按画质另计附加积分
|
- 积分制(**预扣 + 失败退款**),按模型 / 分辨率 / 时长精细定价;去AI特征按画质另计附加积分
|
||||||
- **代理价体系**:用户可设为「代理」角色,模型可设代理价;代理用户(含其 API Key 调用)自动按代理价计费,未设代理价则回退普通价
|
- **代理价体系**:用户可设为「代理」角色,模型可设代理价;代理用户(含其 API Key 调用)自动按代理价计费,未设代理价则回退普通价
|
||||||
- **在线充值(易支付)**:微信 / 支付宝扫码,金额档位 + 自定义,订单 30 分钟未付自动取消,支付回调 MD5 验签 + 幂等自动到账;累计充值可查
|
- **在线充值(易支付)**:微信 / 支付宝扫码,金额档位 + 自定义,订单 30 分钟未付自动取消,支付回调 MD5 验签 + 幂等自动到账
|
||||||
- **CDK 兑换码**充值 · **邀请奖励** · 邮箱注册 / 验证码 / 找回密码
|
- **CDK 兑换码**充值 · **邀请奖励** · 邮箱注册 / 验证码 / 找回密码
|
||||||
- **并发分组**:按分组限制用户「同时生成数」(画图台 + API Key 合计,`0` = 不限),Redis 自愈计数,新用户自动入默认组
|
- **并发分组**:按分组限制用户「同时生成数」(画图台 + API Key 合计,`0` = 不限),Redis 自愈计数,新用户自动入默认组
|
||||||
- 三级角色:普通用户 / 代理 / 管理员(唯一)
|
- 三级角色:普通用户 / 代理 / 管理员
|
||||||
|
|
||||||
#### 🖥️ 用户前台(Vue 3)
|
#### 🖥️ 用户前台(Vue 3)
|
||||||
- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签)
|
- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签)
|
||||||
- 作品画廊**多选批量操作**:全选本页 / 批量删除(视频连带首尾帧图) / 批量下载(多个自动打包 zip,并发拉取)
|
- 作品画廊**多选批量操作**:全选本页 / 批量删除 / 批量下载(自动打包 zip)
|
||||||
- 预览大图内置**复制原图 / 下载 / 关闭**按钮,卡片一键复制原图
|
- **充值 · 订单** · API 文档 · API Key 管理 · 邀请 · 亮 / 暗主题
|
||||||
- **充值 · 订单**(充值记录 / 未付可继续支付) · API 文档 · API Key 管理 · 邀请 · 关于,亮 / 暗主题
|
- **站内公告**:登录后自动弹出 Markdown 公告
|
||||||
- **站内公告**:登录后自动弹出 Markdown 公告,内容更新即重推
|
|
||||||
|
|
||||||
#### 🛠️ 管理后台
|
#### 🛠️ 管理后台
|
||||||
- 概览看板(趋势 / DAU / 失败 Top / 消费榜)
|
- 概览看板(趋势 / DAU / 失败 Top / 消费榜)
|
||||||
- 模型管理(普通价 + 代理价 + 别名) · 账号管理(批量导入 / 去重 / 额度) · **并发分组** · **订单管理**(筛选 / 搜索 / 分页) · 全站日志 · 用户管理(设为代理 / 分配并发组 / 看累计充值 / 违禁触发次数) · CDK · 图片管理(多选批量删除 / zip 打包下载) · 展示位 · **站点公告** · 站点配置(含易支付、去AI特征开关与附加价格)
|
- **渠道管理**(保存即同步模型 / 上游余额 / 启用开关 / 并发 / 权重)· 模型管理(普通价 + 代理价 + 别名,上架状态只读)· **并发分组** · **订单管理** · 全站日志 · 用户管理(设为代理 / 分配并发组)· CDK · 图片管理 · 展示位 · **站点公告** · 站点配置(易支付、去AI特征等)
|
||||||
- **违禁词管理**:后台增删违禁词(分页 + 多选批量删除),提示词命中即拦截(画图台 + API,不区分大小写),按词 / 按用户统计触发次数
|
- **违禁词管理**:提示词命中即拦截,按词 / 按用户统计触发次数
|
||||||
|
|
||||||
**🧰 工程亮点**:tls-client(Chrome JA3/JA4 指纹)稳定穿透 Cloudflare · 媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 自愈式维护轮询(恢复额度 / 刷新凭据 / 清理僵死任务并退款) · 一条命令 Docker 部署(TLS 交给你的反代)。
|
**🧰 工程亮点**:媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 预签名直传参考文件 · 自愈式维护轮询(僵死任务续等 / 退款、过期文件清理)· 一条命令 Docker 部署(TLS 交给你的反代)。
|
||||||
|
|
||||||
## 🤖 支持的模型 / 供应商
|
## 🔌 渠道架构
|
||||||
|
|
||||||
| 供应商 | 模型(示例) | 类型 |
|
```
|
||||||
|---|---|---|
|
调用方 ──vivid 协议──▶ image2api ──vivid 协议──▶ 上游 vividai 实例 A (渠道, 权重 w, 并发 c)
|
||||||
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 |
|
(本服务) ├─ 上游 vividai 实例 B
|
||||||
| **OpenAI** | gpt-image-2 | 图像 |
|
└─ …任意多个
|
||||||
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | 视频 / 图像 |
|
|
||||||
| **Grok(console.x.ai)** | grok-video(imagine 文生 / 图生视频)、grok-image / grok-image-quality(imagine 文生图 / 图生图) | 视频 / 图像 |
|
|
||||||
| **Leonardo.ai** | seedream-4.5 | 图像 |
|
|
||||||
| **Krea.ai** | flux-klein-2 | 图像 |
|
|
||||||
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 |
|
|
||||||
| **自定义上游** | 任意 OpenAI 兼容 v1 端点(按 id 路由) | 图像 / 视频 |
|
|
||||||
|
|
||||||
> 模型由管理后台动态启用并定价,可随时增删。自定义上游支持把任何 OpenAI 兼容服务接成账号,按 model id 路由调用。
|
|
||||||
|
|
||||||
## 🔌 OpenAI 兼容 API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 文生图 —— 纯 OpenAI 参数:size 同时决定比例 + 分辨率档(长边 <1800→1K / <3500→2K / ≥3500→4K)
|
|
||||||
curl https://你的域名/v1/images/generations \
|
|
||||||
-H "Authorization: Bearer sk-xxxx" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"model": "gpt-image-2",
|
|
||||||
"prompt": "a cute cat on a desk, studio lighting",
|
|
||||||
"size": "2048x2048"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 图生图 —— multipart 上传参考图(可多张 image[])
|
|
||||||
curl https://你的域名/v1/images/edits \
|
|
||||||
-H "Authorization: Bearer sk-xxxx" \
|
|
||||||
-F model="seedream-4.5" -F prompt="改成赛博朋克风格" -F image=@input.png
|
|
||||||
```
|
```
|
||||||
|
|
||||||
图片返回 OpenAI 风格 `{ "created": ..., "data": [{ "b64_json": "..." }] }`(原始 base64,无 `data:` 前缀,服务端不留存)。**视频**走异步:`POST /v1/videos` 建任务 → 轮询 `GET /v1/videos/{id}` 至 `completed` → `GET /v1/videos/{id}/content` 取 mp4。完整参数见站内 **/docs** 文档页。
|
- 渠道 = 一个同构上游:`base_url` + `vk_` API Key + 启用开关 + 并发上限 + 调度权重
|
||||||
|
- 模型来自渠道同步(上游 `/v1/models`),本地只做别名映射与定价;**模型可在多个渠道间故障转移**
|
||||||
|
- 本服务对外同样说 vivid 协议 —— 所以它自己也能被接成别人的渠道,无限层叠组网
|
||||||
|
- **不支持接 OpenAI 协议上游**:上游必须是 vivid 协议实例
|
||||||
|
|
||||||
|
## 📡 vivid 协议 API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 文生图 / 生视频 —— NDJSON 流,首行回执 jobId
|
||||||
|
curl -N https://你的域名/v1/generate \
|
||||||
|
-H "Authorization: Bearer vk_xxxxxxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"model": "seedance-2.0", "prompt": "a cute cat on a desk", "ratio": "16:9", "quality": "720p"}'
|
||||||
|
# → {"jobId":"…","status":"running"} …空行心跳… {"status":"succeeded","outputs":[{"url":"…"}]}
|
||||||
|
|
||||||
|
# 断流重连:只带 jobId 重发,不重复下单
|
||||||
|
curl -N https://你的域名/v1/generate -H "Authorization: Bearer vk_xxxx" -d '{"jobId":"<上文 jobId>"}'
|
||||||
|
|
||||||
|
# 参考文件:先拿预签名上传会话,PUT 直传后拿 ref key
|
||||||
|
curl https://你的域名/v1/uploads -H "Authorization: Bearer vk_xxxx" -d '{"kind":"image","mime":"image/png"}'
|
||||||
|
|
||||||
|
# 模型清单(裸数组)与余额
|
||||||
|
curl https://你的域名/v1/models -H "Authorization: Bearer vk_xxxx"
|
||||||
|
curl https://你的域名/v1/balance -H "Authorization: Bearer vk_xxxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
完整参数、错误码表与 Python 示例见站内 **/docs** 文档页。
|
||||||
|
|
||||||
## 🚀 部署
|
## 🚀 部署
|
||||||
|
|
||||||
> 域名 + HTTPS 由你自己的反向代理处理(本项目不内置证书签发)。
|
> 域名 + HTTPS 由你自己的反向代理处理(本项目不内置证书签发)。
|
||||||
|
|
||||||
**Docker(推荐)**:`docker compose up -d --build` 一条命令拉起 PostgreSQL + Redis + RustFS + 后端 + 前端(nginx **HTTP 监听容器 2000 端口**),把你的反向代理指到 `http://<本机>:2000`(端口用 `WEB_PORT` 改;要改密码 / 密钥 / `CORS_ORIGINS`(反代走 HTTPS 时把 `COOKIE_SECURE` 设为 `true`),直接改 `docker-compose.yml` 里对应值即可)。
|
**Docker(推荐)**:`docker compose up -d --build` 一条命令拉起 PostgreSQL + Redis + RustFS + 后端 + 前端(nginx **HTTP 监听容器 2000 端口**),把你的反向代理指到 `http://<本机>:2000`。
|
||||||
|
|
||||||
也可**从源码手动构建**,自备 **PostgreSQL · Redis · RustFS(或任意 S3)· 反向代理**:
|
> ⚠️ 部署前把 `docker-compose.yml` 里的密码 / 密钥改掉 —— 特别是 **`CHANNEL_SECRET`**(渠道上游 Key 的加密密钥,变更后已存渠道密钥无法解密)。
|
||||||
|
|
||||||
|
也可**从源码手动构建**,自备 PostgreSQL · Redis · RustFS(或任意 S3)· 反向代理:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 创建空库(后端启动自动建表)
|
# 1. 创建空库(后端启动自动建表)
|
||||||
createdb vivid_ai
|
createdb vivid_ai
|
||||||
|
|
||||||
# 2. 配置并从源码构建后端
|
# 2. 配置并从源码构建后端 —— 完整环境变量见 backend/.env.example
|
||||||
cat > backend/.env <<'EOF'
|
|
||||||
APP_ENV=production
|
|
||||||
HTTP_ADDR=127.0.0.1:6666
|
|
||||||
POSTGRES_DSN=host=127.0.0.1 user=postgres password=你的密码 dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai
|
|
||||||
REDIS_ADDR=127.0.0.1:6379
|
|
||||||
RUSTFS_ENDPOINT=http://127.0.0.1:9000
|
|
||||||
RUSTFS_BUCKET=vivid-ai
|
|
||||||
RUSTFS_ACCESS_KEY=你的AK
|
|
||||||
RUSTFS_SECRET_KEY=你的SK
|
|
||||||
CORS_ORIGINS=https://你的域名
|
|
||||||
COOKIE_SECURE=true
|
|
||||||
EOF
|
|
||||||
cd backend && go build -o bin/api ./cmd/api && ./bin/api # 监听 127.0.0.1:6666
|
cd backend && go build -o bin/api ./cmd/api && ./bin/api # 监听 127.0.0.1:6666
|
||||||
|
|
||||||
# 3. 构建前端(产物 frontend/dist)
|
# 3. 构建前端(产物 frontend/dist)
|
||||||
@@ -212,7 +183,6 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
client_max_body_size 50m;
|
client_max_body_size 50m;
|
||||||
proxy_read_timeout 600s; # 视频生成耗时长
|
proxy_read_timeout 600s; # 视频生成耗时长
|
||||||
|
|
||||||
location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; }
|
location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; }
|
||||||
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
|
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
|
||||||
location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; }
|
location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; }
|
||||||
@@ -222,13 +192,13 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 完整环境变量见 `backend/.env.example`。
|
部署后:后台「渠道」页新建渠道(上游 URL + `vk_` key)→ 保存即自动同步全部上游模型 → 模型管理里配价 → 画图台 / 对外 API 立即可用。
|
||||||
|
|
||||||
## 🧱 技术栈
|
## 🧱 技术栈
|
||||||
|
|
||||||
| 层 | 技术 |
|
| 层 | 技术 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 后端 | Go · gin · gorm(PostgreSQL)· go-redis · tls-client(Chrome 指纹) |
|
| 后端 | Go · gin · gorm(PostgreSQL)· go-redis |
|
||||||
| 前端 | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
|
| 前端 | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
|
||||||
| 基础设施 | PostgreSQL · Redis · RustFS(S3 兼容)· Nginx |
|
| 基础设施 | PostgreSQL · Redis · RustFS(S3 兼容)· Nginx |
|
||||||
|
|
||||||
@@ -236,54 +206,34 @@ server {
|
|||||||
|
|
||||||
```
|
```
|
||||||
backend/ 后端源码(Go)
|
backend/ 后端源码(Go)
|
||||||
├── cmd/
|
├── cmd/api/ 服务入口(main)
|
||||||
│ ├── api/ 服务入口(main)
|
|
||||||
│ └── marklabel/ 运维小工具(按需标记账号)
|
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── bootstrap/ 应用装配、定时维护任务启动
|
│ ├── bootstrap/ 应用装配、定时维护任务启动
|
||||||
│ ├── config/ 环境变量配置加载
|
│ ├── config/ 环境变量配置加载
|
||||||
│ ├── http/
|
│ ├── http/
|
||||||
│ │ ├── handler/ HTTP 处理器(v1 兼容接口、后台、鉴权…)
|
│ │ ├── handler/ HTTP 处理器(/v1 vivid 协议、渠道管理、鉴权…)
|
||||||
│ │ ├── middleware/ 鉴权 / 请求 ID 等中间件
|
│ │ ├── middleware/ 鉴权 / 请求 ID 等中间件
|
||||||
│ │ └── router/ 路由注册
|
│ │ └── router/ 路由注册
|
||||||
│ ├── model/ GORM 数据模型
|
│ ├── model/ GORM 数据模型(渠道 / 模型 / 用户 / 事件…)
|
||||||
│ ├── provider/ 各上游供应商客户端
|
│ ├── upstream/ vivid 协议上游客户端(NDJSON / 上传 / 模型 / 余额)
|
||||||
│ │ ├── adobe/ Adobe Firefly(tls-client 指纹)
|
│ ├── repo/ 数据访问层
|
||||||
│ │ ├── chatgpt/ OpenAI(含 PoW / turnstile)
|
│ ├── service/ 业务逻辑(渠道调度、计费、维护、v1 job 生命周期)
|
||||||
│ │ ├── runway/ Runway 视频 + Nano Banana 图像
|
│ └── storage/ RustFS / S3 媒体存储(预签名 PUT/GET)
|
||||||
│ │ ├── grok/ Grok(console.x.ai DPoP,视频 / 图像;账号态走 grok.com)
|
├── Dockerfile 多阶段构建
|
||||||
│ │ ├── leonardo/ Leonardo
|
|
||||||
│ │ ├── krea/ Krea
|
|
||||||
│ │ ├── imagine/ Imagine.art
|
|
||||||
│ │ ├── custom/ 自定义上游(OpenAI 兼容 v1,按 id 路由,直连不走代理)
|
|
||||||
│ │ └── epay/ 易支付(mapi 下单 + 回调 MD5 验签,积分充值)
|
|
||||||
│ ├── repo/ 数据访问层(用户 / 模型 / 账号 / 日志 / CDK / 订单 / 并发组…)
|
|
||||||
│ ├── service/ 业务逻辑(生成调度、计费、账号池、保活、维护)
|
|
||||||
│ └── storage/ RustFS / S3 媒体存储
|
|
||||||
├── Dockerfile 多阶段构建(源码编译 → 精简运行镜像)
|
|
||||||
└── .env.example 后端环境变量模板
|
└── .env.example 后端环境变量模板
|
||||||
|
|
||||||
frontend/ 前端源码(Vue 3 + Vite)
|
frontend/ 前端源码(Vue 3 + Vite)
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── views/ 页面(画图台 / 账号 / 模型 / 用户 / 并发组 / 订单 / 日志 / 概览 / 充值 / 设置…)
|
│ ├── views/ 页面(画图台 / 渠道 / 模型 / 用户 / 并发组 / 订单 / 日志 / 概览 / 充值 / 设置…)
|
||||||
│ ├── components/ 复用组件(弹窗 / 选择器 / 灯箱…)
|
│ ├── components/ 复用组件(弹窗 / 选择器 / 灯箱…)
|
||||||
│ ├── layouts/ 公共 / 后台布局
|
│ ├── layouts/ 公共 / 后台布局
|
||||||
│ ├── utils/ 工具函数
|
|
||||||
│ └── api.js · auth.js … 接口封装、鉴权、主题、积分等
|
│ └── api.js · auth.js … 接口封装、鉴权、主题、积分等
|
||||||
├── Dockerfile Nginx 静态托管(HTTP :2000)+ API 反代
|
├── Dockerfile Nginx 静态托管(HTTP :2000)+ API 反代
|
||||||
└── default.conf.template Nginx 站点模板(反代 + 缓存策略)
|
└── default.conf.template Nginx 站点模板(反代 + 缓存策略)
|
||||||
|
|
||||||
docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端 / 前端)
|
docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端 / 前端)
|
||||||
.env.example → backend/.env 后端环境变量模板
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
|
||||||
|
|
||||||
- [ ] 更多上游供应商接入
|
|
||||||
- [ ] 用量统计 / 导出
|
|
||||||
- [ ] 多语言界面(i18n)
|
|
||||||
- [ ] Webhook / 异步回调
|
|
||||||
|
|
||||||
## 💬 交流 / 联系
|
## 💬 交流 / 联系
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -294,13 +244,6 @@ docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端
|
|||||||
| 🛒 小店 | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
|
| 🛒 小店 | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
|
||||||
| ✉️ 邮箱 | vividairun@gmail.com |
|
| ✉️ 邮箱 | vividairun@gmail.com |
|
||||||
|
|
||||||
## ⭐ Star History
|
|
||||||
|
|
||||||
<!-- 建好 GitHub 仓库后,取消下面这行注释并把 OWNER 换成你的用户名即可显示趋势图: -->
|
|
||||||
<!-- [](https://star-history.com/#OWNER/image2api&Date) -->
|
|
||||||
|
|
||||||
觉得有用就点个 ⭐ 吧 —— 建好仓库后取消上方注释即可展示 Star 趋势图。
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
本项目(前端 + 后端)基于 [MIT](LICENSE) 协议开源,可自由使用、修改、商用与二次分发。
|
本项目(前端 + 后端)基于 [MIT](LICENSE) 协议开源,可自由使用、修改、商用与二次分发。
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Vivid AI backend config template. Copy to .env and fill in real values.
|
# image2api backend config template. Copy to .env and fill in real values.
|
||||||
# Real environment variables override .env.
|
# Real environment variables override .env.
|
||||||
|
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
APP_TITLE=Vivid AI
|
APP_TITLE=image2api
|
||||||
HTTP_ADDR=:6666
|
HTTP_ADDR=:6666
|
||||||
|
|
||||||
# PostgreSQL (database must already exist; tables auto-migrate on boot)
|
# PostgreSQL (database must already exist; tables auto-migrate on boot)
|
||||||
@@ -13,8 +13,22 @@ REDIS_ADDR=127.0.0.1:6379
|
|||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# RustFS / S3-compatible object storage (generated media + reference uploads)
|
||||||
|
RUSTFS_ENDPOINT=http://127.0.0.1:9000
|
||||||
|
RUSTFS_BUCKET=vivid-ai
|
||||||
|
RUSTFS_ACCESS_KEY=YOUR_AK
|
||||||
|
RUSTFS_SECRET_KEY=YOUR_SK
|
||||||
|
|
||||||
|
# Channel upstream API keys are stored AES-GCM encrypted with this secret.
|
||||||
|
# Set a random value in production and keep it stable — changing it makes
|
||||||
|
# previously saved channel keys undecryptable.
|
||||||
|
CHANNEL_SECRET=change-me-channel-secret
|
||||||
|
|
||||||
# Frontend dev origins allowed for CORS
|
# Frontend dev origins allowed for CORS
|
||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|
||||||
# Optional: override generated media root (defaults to ../../ai-gateway/data/generated)
|
# Set to true when serving the site over HTTPS (session cookie Secure flag)
|
||||||
# GENERATED_ROOT=
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# SESSION_COOKIE_NAME=vivid_session
|
||||||
|
# GENERATED_ROOT= # optional: override generated media root
|
||||||
|
|||||||
@@ -1,55 +1,27 @@
|
|||||||
# Vivid AI Backend
|
# image2api backend
|
||||||
|
|
||||||
Go backend for `vivid-ai`, using:
|
Go backend for `image2api` — a vivid-protocol gateway that routes generation
|
||||||
|
jobs across same-protocol upstream channels. Stack: Gin · GORM (PostgreSQL) ·
|
||||||
|
go-redis.
|
||||||
|
|
||||||
- Gin
|
## Architecture sketch
|
||||||
- GORM
|
|
||||||
- PostgreSQL
|
|
||||||
- Redis
|
|
||||||
|
|
||||||
## Current scope
|
- `internal/upstream` — vivid-protocol client (NDJSON generate + jobId resume,
|
||||||
|
`/v1/uploads`, `/v1/models`, `/v1/balance`, error classification)
|
||||||
This is an in-progress rewrite. The current skeleton already includes:
|
- `internal/service/channel.go` — channel CRUD, upstream model sync (save =
|
||||||
|
full sync), AES-GCM key storage
|
||||||
- app bootstrap
|
- `internal/service/v1.go` — channel-weighted dispatch, billing, job lifecycle,
|
||||||
- PostgreSQL and Redis initialization
|
stale-job resume
|
||||||
- GORM auto-migrations
|
- `internal/http/handler/v1.go` — public vivid endpoints
|
||||||
- session storage in Redis
|
(`/v1/generate` `/v1/uploads` `/v1/models` `/v1/balance`)
|
||||||
- image access control for `/images/:user/:name`
|
|
||||||
- public site endpoint: `/admin/api/site`
|
|
||||||
- public showcase endpoint: `/admin/api/showcase`
|
|
||||||
- session-based auth endpoint: `/admin/api/auth/me`
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
Set these before running:
|
See `.env.example`. Required: `POSTGRES_DSN`, `REDIS_ADDR`, `RUSTFS_*`.
|
||||||
|
Important: `CHANNEL_SECRET` encrypts stored channel API keys (AES-GCM) — set a
|
||||||
|
random value and keep it stable; rotating it makes saved keys undecryptable.
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
$env:POSTGRES_DSN="host=127.0.0.1 user=postgres password=postgres dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai"
|
cp .env.example .env # fill in values
|
||||||
$env:REDIS_ADDR="127.0.0.1:6379"
|
go build -o bin/api ./cmd/api && ./bin/api
|
||||||
$env:HTTP_ADDR=":6061"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:APP_ENV="development"
|
|
||||||
$env:APP_TITLE="Vivid AI"
|
|
||||||
$env:SESSION_COOKIE_NAME="vivid_session"
|
|
||||||
$env:CORS_ORIGINS="http://localhost:5173,http://127.0.0.1:5173"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
go run ./cmd/api
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Generated media defaults to `../../ai-gateway/data/generated` relative to the backend working directory.
|
|
||||||
- Private images require either:
|
|
||||||
- session cookie
|
|
||||||
- bearer session token
|
|
||||||
- bearer API key
|
|
||||||
- Showcase images are public.
|
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
`ON cdk_codes (batch_id, redeemed_by) WHERE type = 'marketing' AND redeemed_by IS NOT NULL`).Error; err != nil {
|
`ON cdk_codes (batch_id, redeemed_by) WHERE type = 'marketing' AND redeemed_by IS NOT NULL`).Error; err != nil {
|
||||||
return nil, fmt.Errorf("cdk marketing index: %w", err)
|
return nil, fmt.Errorf("cdk marketing index: %w", err)
|
||||||
}
|
}
|
||||||
|
// 配价即上架(vivid 同款):models 不再有启停开关,删遗留列。
|
||||||
|
if err := db.WithContext(ctx).Exec(`ALTER TABLE model_configs DROP COLUMN IF EXISTS enabled`).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("drop model_configs.enabled: %w", err)
|
||||||
|
}
|
||||||
|
// 首页展示位不再支持 bento,清掉遗留记录。
|
||||||
|
if err := db.WithContext(ctx).Exec(`DELETE FROM showcase_items WHERE kind = 'bento'`).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("purge bento showcase: %w", err)
|
||||||
|
}
|
||||||
if err := seedDefaults(ctx, db); err != nil {
|
if err := seedDefaults(ctx, db); err != nil {
|
||||||
return nil, fmt.Errorf("seed defaults: %w", err)
|
return nil, fmt.Errorf("seed defaults: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,7 +122,14 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
// rows when CHANNEL_SECRET isn't set.
|
// rows when CHANNEL_SECRET isn't set.
|
||||||
channelSecret = cfg.PostgresDSN
|
channelSecret = cfg.PostgresDSN
|
||||||
}
|
}
|
||||||
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret)
|
channelSvc := service.NewChannelService(channelRepo, modelRepo, eventRepo, concSvc, upClient, channelSecret)
|
||||||
|
// Sweep provider-era model rows: anything not bound to a channel can never
|
||||||
|
// be routed under the channel scheduler.
|
||||||
|
if n, err := channelSvc.PurgeOrphanModels(ctx); err != nil {
|
||||||
|
log.Printf("purge orphan models: %v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("purged %d unbound model(s) left over from the provider era", n)
|
||||||
|
}
|
||||||
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
|
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
|
||||||
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
||||||
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
showcaseSvc := service.NewShowcaseService(showcaseRepo)
|
||||||
@@ -152,7 +167,7 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
|
|
||||||
// Background self-healing sweep (order expiry, stale-pending cleanup+refund,
|
// Background self-healing sweep (order expiry, stale-pending cleanup+refund,
|
||||||
// log/media retention).
|
// log/media retention).
|
||||||
maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc.Inflight(), showcaseRepo, orderRepo)
|
maintenanceSvc := service.NewMaintenanceService(eventRepo, userRepo, siteRepo, rustfsClient, v1Svc, showcaseRepo, orderRepo)
|
||||||
loopCtx, loopCancel := context.WithCancel(context.Background())
|
loopCtx, loopCancel := context.WithCancel(context.Background())
|
||||||
go maintenanceSvc.Run(loopCtx)
|
go maintenanceSvc.Run(loopCtx)
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (h *ChannelAdminHandler) Save(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
body.BaseURL = strings.TrimSpace(body.BaseURL)
|
body.BaseURL = strings.TrimSpace(body.BaseURL)
|
||||||
ch, err := h.channels.Save(c.Request.Context(), body)
|
ch, warning, err := h.channels.Save(c.Request.Context(), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
c.JSON(http.StatusNotFound, gin.H{"detail": "channel not found"})
|
||||||
@@ -47,7 +47,7 @@ func (h *ChannelAdminHandler) Save(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "id": ch.ID})
|
c.JSON(http.StatusOK, gin.H{"ok": true, "id": ch.ID, "warning": warning})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ChannelAdminHandler) Delete(c *gin.Context) {
|
func (h *ChannelAdminHandler) Delete(c *gin.Context) {
|
||||||
@@ -88,6 +88,25 @@ func (h *ChannelAdminHandler) FetchModels(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncModels manually re-runs the full upstream model sync on a saved channel
|
||||||
|
// (new models bound, disappeared ones unbound, orphans deleted). Fetch
|
||||||
|
// failures are errors here — unlike the save path which degrades to a warning.
|
||||||
|
func (h *ChannelAdminHandler) SyncModels(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg, err := h.channels.SyncModels(c.Request.Context(), body.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeUpstreamErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "message": msg})
|
||||||
|
}
|
||||||
|
|
||||||
// ImportModels binds the ticked upstream models to the channel (creating local
|
// ImportModels binds the ticked upstream models to the channel (creating local
|
||||||
// model rows on first import).
|
// model rows on first import).
|
||||||
func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
|
func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
|
||||||
|
|||||||
@@ -46,16 +46,16 @@ func (h *ShowcaseHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
// AdminList is the paginated flat view for the admin 首页内容 page — the public
|
// AdminList is the paginated flat view for the admin 首页内容 page — the public
|
||||||
// List keeps its grouped shape for the home page, this one adds kind filter +
|
// List keeps its grouped shape for the home page, this one adds kind filter +
|
||||||
// limit/offset/total. Order mirrors the old client flattening: hero → bento → work.
|
// limit/offset/total. Order mirrors the old client flattening: hero → work.
|
||||||
func (h *ShowcaseHandler) AdminList(c *gin.Context) {
|
func (h *ShowcaseHandler) AdminList(c *gin.Context) {
|
||||||
grouped, err := h.showcase.Grouped(c.Request.Context())
|
grouped, err := h.showcase.Grouped(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
kindFilter := c.Query("kind") // "" | hero | bento | work
|
kindFilter := c.Query("kind") // "" | hero | work
|
||||||
flat := make([]gin.H, 0, 16)
|
flat := make([]gin.H, 0, 16)
|
||||||
for _, kind := range []string{"hero", "bento", "work"} {
|
for _, kind := range []string{"hero", "work"} {
|
||||||
if kindFilter != "" && kind != kindFilter {
|
if kindFilter != "" && kind != kindFilter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ func (h *UserGenerationHandler) VideoPresets(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
out := make([]gin.H, 0, len(models))
|
out := make([]gin.H, 0, len(models))
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if m.Type != "video" || !m.Enabled {
|
if m.Type != "video" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, modelPreset(m))
|
out = append(out, modelPreset(m))
|
||||||
@@ -547,7 +547,7 @@ func (h *UserGenerationHandler) catalogEntries(c *gin.Context) ([]gin.H, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// publicModels — GET /api/models for the public site (docs/landing pickers).
|
// publicModels — GET /api/models for the public site (docs/landing pickers).
|
||||||
// Enabled models only, in the same shape as before minus the provider id.
|
// Fully-priced models only (配价即上架), same shape as before.
|
||||||
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
||||||
models, err := h.admin.Models(c.Request.Context())
|
models, err := h.admin.Models(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -555,7 +555,8 @@ func (h *UserGenerationHandler) publicModels(c *gin.Context) ([]gin.H, error) {
|
|||||||
}
|
}
|
||||||
items := make([]gin.H, 0, len(models))
|
items := make([]gin.H, 0, len(models))
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if !m.Enabled {
|
// 配价即上架:未配齐价的模型对前台不可见(绑定/配价都满足才进 /v1)。
|
||||||
|
if !service.ModelFullyPriced(&m) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items = append(items, gin.H{
|
items = append(items, gin.H{
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
|||||||
authed.POST("/channels", handlers.ChannelAdmin.Save)
|
authed.POST("/channels", handlers.ChannelAdmin.Save)
|
||||||
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
|
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
|
||||||
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
|
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
|
||||||
|
authed.POST("/channels/sync-models", handlers.ChannelAdmin.SyncModels)
|
||||||
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
|
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
|
||||||
authed.GET("/providers", handlers.AdminRead.Providers)
|
authed.GET("/providers", handlers.AdminRead.Providers)
|
||||||
authed.GET("/images", handlers.AdminRead.Images)
|
authed.GET("/images", handlers.AdminRead.Images)
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ type ModelConfig struct {
|
|||||||
Name string `gorm:"size:255;not null"`
|
Name string `gorm:"size:255;not null"`
|
||||||
Alias string `gorm:"column:alias;size:255;not null;default:''"`
|
Alias string `gorm:"column:alias;size:255;not null;default:''"`
|
||||||
Provider string `gorm:"size:100;index;not null"`
|
Provider string `gorm:"size:100;index;not null"`
|
||||||
Enabled bool `gorm:"not null;default:true"`
|
|
||||||
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
|
FreeAllowed bool `gorm:"not null;default:false"` // 普号(free)能否调度此模型,默认不可用
|
||||||
Ratios datatypes.JSON `gorm:"type:jsonb"`
|
Ratios datatypes.JSON `gorm:"type:jsonb"`
|
||||||
Prices datatypes.JSONMap `gorm:"type:jsonb"`
|
Prices datatypes.JSONMap `gorm:"type:jsonb"`
|
||||||
@@ -195,7 +194,7 @@ type Channel struct {
|
|||||||
BaseURL string `gorm:"size:500;not null"`
|
BaseURL string `gorm:"size:500;not null"`
|
||||||
APIKeyEnc string `gorm:"type:text;not null"` // AES-GCM encrypted upstream API key
|
APIKeyEnc string `gorm:"type:text;not null"` // AES-GCM encrypted upstream API key
|
||||||
Enabled bool `gorm:"not null;default:true;index"`
|
Enabled bool `gorm:"not null;default:true;index"`
|
||||||
// Concurrency caps simultaneous in-flight jobs on this channel. 0 = default.
|
// Concurrency caps simultaneous in-flight jobs on this channel. 0 = unlimited.
|
||||||
Concurrency int `gorm:"not null;default:0"`
|
Concurrency int `gorm:"not null;default:0"`
|
||||||
// Weight biases weighted-random channel order: weight 2 is picked first at
|
// Weight biases weighted-random channel order: weight 2 is picked first at
|
||||||
// roughly 2x the rate of weight 1.
|
// roughly 2x the rate of weight 1.
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ func (r *ChannelRepository) Unbind(ctx context.Context, channelID, modelID strin
|
|||||||
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ? AND model_id = ?", channelID, modelID).Error
|
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ? AND model_id = ?", channelID, modelID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnbindExcept removes every binding of a channel whose upstream model name is
|
||||||
|
// NOT in the keep list (upstream_model, falling back to model_id for legacy
|
||||||
|
// rows) — the "true sync" counterpart used after saving a channel.
|
||||||
|
func (r *ChannelRepository) UnbindExcept(ctx context.Context, channelID string, upstreamIDs []string) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Where("channel_id = ? AND NOT (COALESCE(NULLIF(upstream_model, ''), model_id) IN ?)", channelID, upstreamIDs).
|
||||||
|
Delete(&model.ChannelModel{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ChannelRepository) DeleteBindingsByChannel(ctx context.Context, channelID string) error {
|
func (r *ChannelRepository) DeleteBindingsByChannel(ctx context.Context, channelID string) error {
|
||||||
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ?", channelID).Error
|
return r.db.WithContext(ctx).Delete(&model.ChannelModel{}, "channel_id = ?", channelID).Error
|
||||||
}
|
}
|
||||||
@@ -158,6 +167,22 @@ func (r *ChannelRepository) UpstreamModelMap(ctx context.Context, channelID stri
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoutableModelIDs returns the set of local model ids bound to at least one
|
||||||
|
// ENABLED channel — the "can actually dispatch" set behind the sellable flag.
|
||||||
|
func (r *ChannelRepository) RoutableModelIDs(ctx context.Context) (map[string]struct{}, error) {
|
||||||
|
var ids []string
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("channel_models").
|
||||||
|
Select("DISTINCT channel_models.model_id").
|
||||||
|
Joins("JOIN channels ON channels.id = channel_models.channel_id AND channels.enabled = ?", true).
|
||||||
|
Scan(&ids).Error
|
||||||
|
out := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
out[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// ChannelIDsByModel lists channel ids bound to a model (any enabled state).
|
// ChannelIDsByModel lists channel ids bound to a model (any enabled state).
|
||||||
func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID string) ([]string, error) {
|
func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID string) ([]string, error) {
|
||||||
var ids []string
|
var ids []string
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ type DashboardWindow struct {
|
|||||||
Pending int64 `json:"pending"`
|
Pending int64 `json:"pending"`
|
||||||
Image int64 `json:"image"`
|
Image int64 `json:"image"`
|
||||||
Video int64 `json:"video"`
|
Video int64 `json:"video"`
|
||||||
API int64 `json:"api"` // source = 'v1' (OpenAI-compatible key)
|
API int64 `json:"api"` // source = 'v1' (vk_ external API key)
|
||||||
Web int64 `json:"web"` // everything else (web / playground)
|
Web int64 `json:"web"` // everything else (web / playground)
|
||||||
Spent float64 `json:"spent"`
|
Spent float64 `json:"spent"`
|
||||||
}
|
}
|
||||||
@@ -430,12 +430,41 @@ const stalePendingWhere = "status = ? AND ((kind = ? AND ts < ?) OR (kind <> ? A
|
|||||||
|
|
||||||
// StaleEvent identifies a purged pending event so the caller can refund the
|
// StaleEvent identifies a purged pending event so the caller can refund the
|
||||||
// credits debited up-front AND attribute the failure to the account the
|
// credits debited up-front AND attribute the failure to the account the
|
||||||
// (now-abandoned) generation was using.
|
// (now-abandoned) generation was using. UpstreamJob/Kind/Model/File/Source are
|
||||||
|
// what the channel-resume path needs to pick the job back up.
|
||||||
type StaleEvent struct {
|
type StaleEvent struct {
|
||||||
ID string `gorm:"column:id"`
|
ID string `gorm:"column:id"`
|
||||||
UserID string `gorm:"column:user_id"`
|
UserID string `gorm:"column:user_id"`
|
||||||
AccountID string `gorm:"column:account_id"`
|
AccountID string `gorm:"column:account_id"`
|
||||||
|
UpstreamJob string `gorm:"column:upstream_job"`
|
||||||
|
Kind string `gorm:"column:kind"`
|
||||||
|
Model string `gorm:"column:model"`
|
||||||
|
File string `gorm:"column:file"`
|
||||||
|
Source string `gorm:"column:source"`
|
||||||
Cost float64 `gorm:"column:cost"`
|
Cost float64 `gorm:"column:cost"`
|
||||||
|
TS time.Time `gorm:"column:ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumableStale returns pending events past their per-kind deadline that still
|
||||||
|
// carry an upstream job id — a process restart orphaned the local goroutine but
|
||||||
|
// the upstream job may still be running, so these get resumed (not purged).
|
||||||
|
// Events older than maxResumeAge are excluded and fall through to PurgeStale.
|
||||||
|
func (r *EventRepository) ResumableStale(ctx context.Context, maxAge, videoMaxAge, maxResumeAge time.Duration) ([]StaleEvent, error) {
|
||||||
|
if maxAge <= 0 {
|
||||||
|
maxAge = 600 * time.Second
|
||||||
|
}
|
||||||
|
if videoMaxAge <= 0 {
|
||||||
|
videoMaxAge = maxAge
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-maxAge)
|
||||||
|
videoCutoff := time.Now().Add(-videoMaxAge)
|
||||||
|
resumeFloor := time.Now().Add(-maxResumeAge)
|
||||||
|
var stale []StaleEvent
|
||||||
|
err := r.db.WithContext(ctx).Model(&model.EventLog{}).
|
||||||
|
Where(stalePendingWhere+" AND upstream_job <> '' AND ts >= ?", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
|
||||||
|
Select("id", "user_id", "account_id", "upstream_job", "kind", "model", "file", "source", "cost", "ts").
|
||||||
|
Scan(&stale).Error
|
||||||
|
return stale, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurgeStale marks long-pending entries as failed/abandoned and RETURNS them so
|
// PurgeStale marks long-pending entries as failed/abandoned and RETURNS them so
|
||||||
@@ -446,7 +475,10 @@ type StaleEvent struct {
|
|||||||
//
|
//
|
||||||
// videoMaxAge 单独给视频用:出片本来就慢(上游长镜头能跑二十多分钟),按图片的
|
// videoMaxAge 单独给视频用:出片本来就慢(上游长镜头能跑二十多分钟),按图片的
|
||||||
// 期限扫会在它出片前就判死并退款。
|
// 期限扫会在它出片前就判死并退款。
|
||||||
func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge time.Duration) ([]StaleEvent, error) {
|
//
|
||||||
|
// Events carrying an upstream job id and younger than resumeFloor are skipped —
|
||||||
|
// ResumableStale resumes them against the channel instead of killing them.
|
||||||
|
func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge, maxResumeAge time.Duration) ([]StaleEvent, error) {
|
||||||
if maxAge <= 0 {
|
if maxAge <= 0 {
|
||||||
maxAge = 600 * time.Second
|
maxAge = 600 * time.Second
|
||||||
}
|
}
|
||||||
@@ -455,13 +487,14 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge ti
|
|||||||
}
|
}
|
||||||
cutoff := time.Now().Add(-maxAge)
|
cutoff := time.Now().Add(-maxAge)
|
||||||
videoCutoff := time.Now().Add(-videoMaxAge)
|
videoCutoff := time.Now().Add(-videoMaxAge)
|
||||||
|
resumeFloor := time.Now().Add(-maxResumeAge)
|
||||||
var stale []StaleEvent
|
var stale []StaleEvent
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// Snapshot who/what to refund BEFORE flipping status, so a concurrent
|
// Snapshot who/what to refund BEFORE flipping status, so a concurrent
|
||||||
// sweep can't double-count (the UPDATE in the same tx removes them from
|
// sweep can't double-count (the UPDATE in the same tx removes them from
|
||||||
// the pending set).
|
// the pending set).
|
||||||
if err := tx.Model(&model.EventLog{}).
|
if err := tx.Model(&model.EventLog{}).
|
||||||
Where(stalePendingWhere, "pending", "video", videoCutoff, "video", cutoff).
|
Where(stalePendingWhere+" AND (upstream_job IS NULL OR upstream_job = '' OR ts < ?)", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
|
||||||
Select("id", "user_id", "account_id", "cost").
|
Select("id", "user_id", "account_id", "cost").
|
||||||
Scan(&stale).Error; err != nil {
|
Scan(&stale).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -470,7 +503,7 @@ func (r *EventRepository) PurgeStale(ctx context.Context, maxAge, videoMaxAge ti
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return tx.Model(&model.EventLog{}).
|
return tx.Model(&model.EventLog{}).
|
||||||
Where(stalePendingWhere, "pending", "video", videoCutoff, "video", cutoff).
|
Where(stalePendingWhere+" AND (upstream_job IS NULL OR upstream_job = '' OR ts < ?)", "pending", "video", videoCutoff, "video", cutoff, resumeFloor).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error": gorm.Expr("COALESCE(NULLIF(error, ''), ?)", "abandoned (process restarted or request interrupted)"),
|
"error": gorm.Expr("COALESCE(NULLIF(error, ''), ?)", "abandoned (process restarted or request interrupted)"),
|
||||||
@@ -635,6 +668,29 @@ func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID, ac
|
|||||||
Updates(map[string]any{"account_id": accountID, "account_email": accountEmail}).Error
|
Updates(map[string]any{"account_id": accountID, "account_email": accountEmail}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FailedByAccount counts failed events grouped by account_id (channel id) —
|
||||||
|
// the channel list's 失败数 column.
|
||||||
|
func (r *EventRepository) FailedByAccount(ctx context.Context) (map[string]int64, error) {
|
||||||
|
type row struct {
|
||||||
|
AccountID string `gorm:"column:account_id"`
|
||||||
|
Count int64 `gorm:"column:count"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Model(&model.EventLog{}).
|
||||||
|
Select("account_id, COUNT(*) AS count").
|
||||||
|
Where("status = ? AND account_id <> ''", "failed").
|
||||||
|
Group("account_id").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[string]int64, len(rows))
|
||||||
|
for _, item := range rows {
|
||||||
|
out[item.AccountID] = item.Count
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InFlightByAccount counts pending (in-flight) events grouped by account_id, for
|
// InFlightByAccount counts pending (in-flight) events grouped by account_id, for
|
||||||
// the accounts view's live "in-flight" column.
|
// the accounts view's live "in-flight" column.
|
||||||
func (r *EventRepository) InFlightByAccount(ctx context.Context) (map[string]int64, error) {
|
func (r *EventRepository) InFlightByAccount(ctx context.Context) (map[string]int64, error) {
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ func (r *ShowcaseRepository) Grouped(ctx context.Context) (map[string][]model.Sh
|
|||||||
|
|
||||||
grouped := map[string][]model.ShowcaseItem{
|
grouped := map[string][]model.ShowcaseItem{
|
||||||
"hero": {},
|
"hero": {},
|
||||||
"bento": {},
|
|
||||||
"work": {},
|
"work": {},
|
||||||
}
|
}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
|||||||
@@ -84,15 +84,18 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
routable, _ := s.channels.RoutableModelIDs(ctx)
|
||||||
out := make([]map[string]any, 0, len(items))
|
out := make([]map[string]any, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
_, bound := routable[item.ID]
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"id": item.ID,
|
"id": item.ID,
|
||||||
"alias": item.Alias,
|
"alias": item.Alias,
|
||||||
"type": item.Type,
|
"type": item.Type,
|
||||||
"name": item.Name,
|
"name": item.Name,
|
||||||
"provider": item.Provider,
|
"provider": item.Provider,
|
||||||
"enabled": item.Enabled,
|
// 配价即上架(无启停开关):全档位有价 + 绑定了启用渠道才算可售。
|
||||||
|
"sellable": bound && ModelFullyPriced(&item),
|
||||||
"ratios": repo.JSONStrings(item.Ratios),
|
"ratios": repo.JSONStrings(item.Ratios),
|
||||||
"prices": map[string]any(item.Prices),
|
"prices": map[string]any(item.Prices),
|
||||||
"resolutions": repo.JSONStrings(item.Resolutions),
|
"resolutions": repo.JSONStrings(item.Resolutions),
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ func (s *AdminWriteService) DeleteUserAPIKey(ctx context.Context, userID, keyID
|
|||||||
func (s *AdminWriteService) CreateShowcase(ctx context.Context, body map[string]any) (*model.ShowcaseItem, error) {
|
func (s *AdminWriteService) CreateShowcase(ctx context.Context, body map[string]any) (*model.ShowcaseItem, error) {
|
||||||
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
||||||
if kind == "" {
|
if kind == "" {
|
||||||
return nil, errors.New("kind must be hero, bento or work")
|
return nil, errors.New("kind must be hero or work")
|
||||||
}
|
}
|
||||||
image := strings.TrimSpace(stringValue(body["image"]))
|
image := strings.TrimSpace(stringValue(body["image"]))
|
||||||
if image == "" {
|
if image == "" {
|
||||||
@@ -333,7 +333,7 @@ func (s *AdminWriteService) UpdateShowcase(ctx context.Context, entryID string,
|
|||||||
if _, ok := body["kind"]; ok {
|
if _, ok := body["kind"]; ok {
|
||||||
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
kind := normalizedShowcaseKind(stringValue(body["kind"]))
|
||||||
if kind == "" {
|
if kind == "" {
|
||||||
return nil, errors.New("kind must be hero, bento or work")
|
return nil, errors.New("kind must be hero or work")
|
||||||
}
|
}
|
||||||
patch["kind"] = kind
|
patch["kind"] = kind
|
||||||
}
|
}
|
||||||
@@ -391,7 +391,6 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
|||||||
Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID),
|
Name: defaultString(strings.TrimSpace(stringValue(body["name"])), modelID),
|
||||||
Alias: alias,
|
Alias: alias,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Enabled: boolValueWithDefault(body["enabled"], true),
|
|
||||||
Ratios: jsonArray(body["ratios"]),
|
Ratios: jsonArray(body["ratios"]),
|
||||||
Prices: prices,
|
Prices: prices,
|
||||||
Resolutions: resolutions,
|
Resolutions: resolutions,
|
||||||
@@ -403,6 +402,8 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
|
|||||||
MaxReferenceImages: intValue(body["max_reference_images"]),
|
MaxReferenceImages: intValue(body["max_reference_images"]),
|
||||||
MaxReferenceVideos: intValue(body["max_reference_videos"]),
|
MaxReferenceVideos: intValue(body["max_reference_videos"]),
|
||||||
MaxReferenceAudios: intValue(body["max_reference_audios"]),
|
MaxReferenceAudios: intValue(body["max_reference_audios"]),
|
||||||
|
MaxRefVideoSeconds: intValue(body["max_ref_video_seconds"]),
|
||||||
|
MaxRefAudioSeconds: intValue(body["max_ref_audio_seconds"]),
|
||||||
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
|
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
|
||||||
Weight: intValue(body["weight"]),
|
Weight: intValue(body["weight"]),
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -440,13 +441,6 @@ func (s *AdminWriteService) UpdateModel(ctx context.Context, modelID string, bod
|
|||||||
patch["provider"] = provider
|
patch["provider"] = provider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Only touch `enabled` when the caller explicitly sends a non-null value;
|
|
||||||
// mirrors Python models_store.update ("enabled" in fields and is not None).
|
|
||||||
// Without this guard a PATCH that omits the field would default it to false
|
|
||||||
// and silently disable the model.
|
|
||||||
if raw, ok := body["enabled"]; ok && raw != nil {
|
|
||||||
patch["enabled"] = boolValueWithDefault(raw, true)
|
|
||||||
}
|
|
||||||
if _, ok := body["ratios"]; ok {
|
if _, ok := body["ratios"]; ok {
|
||||||
patch["ratios"] = jsonArray(body["ratios"])
|
patch["ratios"] = jsonArray(body["ratios"])
|
||||||
}
|
}
|
||||||
@@ -587,7 +581,7 @@ func normalizedStatus(status string) string {
|
|||||||
|
|
||||||
func normalizedShowcaseKind(kind string) string {
|
func normalizedShowcaseKind(kind string) string {
|
||||||
switch strings.TrimSpace(kind) {
|
switch strings.TrimSpace(kind) {
|
||||||
case "hero", "bento", "work":
|
case "hero", "work":
|
||||||
return strings.TrimSpace(kind)
|
return strings.TrimSpace(kind)
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
@@ -661,8 +655,8 @@ func boolValueWithDefault(v any, fallback bool) bool {
|
|||||||
|
|
||||||
// resolutionsFromPrices mirrors Python models_store._resolutions_from_prices:
|
// resolutionsFromPrices mirrors Python models_store._resolutions_from_prices:
|
||||||
// an image model's quality tiers ARE its price keys (the admin form never sends
|
// an image model's quality tiers ARE its price keys (the admin form never sends
|
||||||
// `resolutions` for images), returned in canonical 1K/2K/4K order. gpt-image-2,
|
// `resolutions` for images), returned in canonical 1K/2K/4K order. A model that
|
||||||
// for example, only ever has a "1K" price, so it resolves to exactly ["1K"].
|
// only ever has a "1K" price, for example, resolves to exactly ["1K"].
|
||||||
func resolutionsFromPrices(prices datatypes.JSONMap) datatypes.JSON {
|
func resolutionsFromPrices(prices datatypes.JSONMap) datatypes.JSON {
|
||||||
out := []string{}
|
out := []string{}
|
||||||
for _, r := range []string{"1K", "2K", "4K"} {
|
for _, r := range []string{"1K", "2K", "4K"} {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,13 +30,15 @@ import (
|
|||||||
type ChannelService struct {
|
type ChannelService struct {
|
||||||
channels *repo.ChannelRepository
|
channels *repo.ChannelRepository
|
||||||
models *repo.ModelRepository
|
models *repo.ModelRepository
|
||||||
|
events *repo.EventRepository
|
||||||
|
conc *ConcurrencyService
|
||||||
up *upstream.Client
|
up *upstream.Client
|
||||||
secret []byte
|
secret []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, up *upstream.Client, secret string) *ChannelService {
|
func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, events *repo.EventRepository, conc *ConcurrencyService, up *upstream.Client, secret string) *ChannelService {
|
||||||
sum := sha256.Sum256([]byte("image2api-channel-keys|" + secret))
|
sum := sha256.Sum256([]byte("image2api-channel-keys|" + secret))
|
||||||
return &ChannelService{channels: channels, models: models, up: up, secret: sum[:]}
|
return &ChannelService{channels: channels, models: models, events: events, conc: conc, up: up, secret: sum[:]}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelView is the admin list row: local stats + bound model names + a live
|
// ChannelView is the admin list row: local stats + bound model names + a live
|
||||||
@@ -49,6 +52,10 @@ type ChannelView struct {
|
|||||||
Concurrency int `json:"concurrency"`
|
Concurrency int `json:"concurrency"`
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Models []string `json:"models"`
|
Models []string `json:"models"`
|
||||||
|
// RunningCount 运行中 = Redis 并发槽位实时计数(与调度器同源);
|
||||||
|
// FailCount 失败数 = event_logs 实时聚合(channel 数少,无需预计算)。
|
||||||
|
RunningCount int64 `json:"running_count"`
|
||||||
|
FailCount int64 `json:"fail_count"`
|
||||||
SuccessTotal int64 `json:"success_total"`
|
SuccessTotal int64 `json:"success_total"`
|
||||||
UsedCredits float64 `json:"used_credits"`
|
UsedCredits float64 `json:"used_credits"`
|
||||||
UpstreamBalance *int64 `json:"upstream_balance"`
|
UpstreamBalance *int64 `json:"upstream_balance"`
|
||||||
@@ -62,6 +69,7 @@ func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
fails, _ := s.events.FailedByAccount(ctx)
|
||||||
out := make([]ChannelView, len(items))
|
out := make([]ChannelView, len(items))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, ch := range items {
|
for i, ch := range items {
|
||||||
@@ -72,6 +80,8 @@ func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
|
|||||||
Enabled: ch.Enabled,
|
Enabled: ch.Enabled,
|
||||||
Concurrency: ch.Concurrency,
|
Concurrency: ch.Concurrency,
|
||||||
Weight: ch.Weight,
|
Weight: ch.Weight,
|
||||||
|
RunningCount: int64(s.conc.Count(ctx, "conc:c:"+ch.ID)),
|
||||||
|
FailCount: fails[ch.ID],
|
||||||
SuccessTotal: ch.SuccessTotal,
|
SuccessTotal: ch.SuccessTotal,
|
||||||
UsedCredits: ch.UsedCredits,
|
UsedCredits: ch.UsedCredits,
|
||||||
CreatedAt: ch.CreatedAt.Unix(),
|
CreatedAt: ch.CreatedAt.Unix(),
|
||||||
@@ -120,14 +130,20 @@ type SaveChannelDto struct {
|
|||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Channel, error) {
|
// Save upserts a channel then auto-syncs its upstream models (same semantics
|
||||||
|
// as vivid: upstream /v1/models is the truth). The returned warning is a
|
||||||
|
// non-fatal sync message — "" means fully synced.
|
||||||
|
func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Channel, string, error) {
|
||||||
name := strings.TrimSpace(d.Name)
|
name := strings.TrimSpace(d.Name)
|
||||||
base := strings.TrimRight(strings.TrimSpace(d.BaseURL), "/")
|
base := strings.TrimRight(strings.TrimSpace(d.BaseURL), "/")
|
||||||
if base == "" {
|
if base == "" {
|
||||||
return nil, errors.New("base_url required")
|
return nil, "", errors.New("base_url required")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||||
return nil, errors.New("base_url must start with http:// or https://")
|
return nil, "", errors.New("base_url must start with http:// or https://")
|
||||||
|
}
|
||||||
|
if d.Concurrency < 0 || d.Concurrency > math.MaxInt32 {
|
||||||
|
return nil, "", errors.New("并发数需在 0-2147483647 之间(0 = 不限制)")
|
||||||
}
|
}
|
||||||
weight := d.Weight
|
weight := d.Weight
|
||||||
if weight < 1 {
|
if weight < 1 {
|
||||||
@@ -135,11 +151,11 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
|
|||||||
}
|
}
|
||||||
if d.ID == "" {
|
if d.ID == "" {
|
||||||
if strings.TrimSpace(d.APIKey) == "" {
|
if strings.TrimSpace(d.APIKey) == "" {
|
||||||
return nil, errors.New("api_key required")
|
return nil, "", errors.New("api_key required")
|
||||||
}
|
}
|
||||||
enc, err := s.encrypt(d.APIKey)
|
enc, err := s.encrypt(d.APIKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
ch := &model.Channel{
|
ch := &model.Channel{
|
||||||
ID: "ch-" + randomUpper(10),
|
ID: "ch-" + randomUpper(10),
|
||||||
@@ -153,7 +169,10 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
|
|||||||
if ch.Name == "" {
|
if ch.Name == "" {
|
||||||
ch.Name = base
|
ch.Name = base
|
||||||
}
|
}
|
||||||
return ch, s.channels.Create(ctx, ch)
|
if err := s.channels.Create(ctx, ch); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return ch, syncWarn(s.syncChannelModels(ctx, ch.ID)), nil
|
||||||
}
|
}
|
||||||
patch := map[string]any{
|
patch := map[string]any{
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -165,11 +184,122 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
|
|||||||
if key := strings.TrimSpace(d.APIKey); key != "" && !strings.Contains(key, "•") && !strings.Contains(key, "*") {
|
if key := strings.TrimSpace(d.APIKey); key != "" && !strings.Contains(key, "•") && !strings.Contains(key, "*") {
|
||||||
enc, err := s.encrypt(key)
|
enc, err := s.encrypt(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
patch["api_key_enc"] = enc
|
patch["api_key_enc"] = enc
|
||||||
}
|
}
|
||||||
return s.channels.Update(ctx, d.ID, patch)
|
ch, err := s.channels.Update(ctx, d.ID, patch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return ch, syncWarn(s.syncChannelModels(ctx, d.ID)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncWarn folds the sync error into the save-path warning (vivid parity: a
|
||||||
|
// failed upstream fetch degrades to a non-fatal warning instead of blocking
|
||||||
|
// the channel save).
|
||||||
|
func syncWarn(warn string, err error) string {
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return warn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncModels manually triggers a full upstream-model sync for a saved channel
|
||||||
|
// (same truth semantics as the save path). Unlike Save, an unreachable upstream
|
||||||
|
// or empty model list is a hard error, not a warning.
|
||||||
|
func (s *ChannelService) SyncModels(ctx context.Context, channelID string) (string, error) {
|
||||||
|
if _, err := s.channels.Get(ctx, strings.TrimSpace(channelID)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
warn, err := s.syncChannelModels(ctx, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if warn != "" {
|
||||||
|
return warn, nil
|
||||||
|
}
|
||||||
|
return "已同步上游模型", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncChannelModels runs after Save: upstream /v1/models is the truth — every
|
||||||
|
// sellable upstream model is upserted locally (alias = upstream id) and bound;
|
||||||
|
// bindings on this channel not in the upstream list are removed, and orphan
|
||||||
|
// local models are deleted. An unreachable upstream or empty list is an error
|
||||||
|
// (the save path degrades it to a warning); warn carries non-fatal skips.
|
||||||
|
func (s *ChannelService) syncChannelModels(ctx context.Context, channelID string) (string, error) {
|
||||||
|
ch, err := s.channels.Get(ctx, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("模型同步失败:%w", err)
|
||||||
|
}
|
||||||
|
key := s.decrypt(ch.APIKeyEnc)
|
||||||
|
if key == "" {
|
||||||
|
return "", errors.New("模型同步失败:无法解密上游密钥")
|
||||||
|
}
|
||||||
|
fctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
models, err := s.up.Models(fctx, ch.BaseURL, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("模型同步失败:%w", err)
|
||||||
|
}
|
||||||
|
if len(models) == 0 {
|
||||||
|
return "", errors.New("上游未返回任何模型,已跳过同步")
|
||||||
|
}
|
||||||
|
specs := make([]ChannelModelSpec, 0, len(models))
|
||||||
|
upstreamIDs := make([]string, 0, len(models))
|
||||||
|
var skipped []string
|
||||||
|
for _, m := range models {
|
||||||
|
spec, serr := normalizeRemoteModel(m)
|
||||||
|
if serr != nil {
|
||||||
|
skipped = append(skipped, m.ID+":"+serr.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
specs = append(specs, spec)
|
||||||
|
upstreamIDs = append(upstreamIDs, spec.ID)
|
||||||
|
}
|
||||||
|
if len(upstreamIDs) == 0 {
|
||||||
|
return "", fmt.Errorf("上游模型均规格异常,已跳过同步:%s", strings.Join(skipped, ";"))
|
||||||
|
}
|
||||||
|
if err := s.ImportModels(ctx, channelID, specs); err != nil {
|
||||||
|
return "", fmt.Errorf("模型同步失败:%w", err)
|
||||||
|
}
|
||||||
|
if err := s.channels.UnbindExcept(ctx, channelID, upstreamIDs); err != nil {
|
||||||
|
return "", fmt.Errorf("模型同步失败:%w", err)
|
||||||
|
}
|
||||||
|
if orphans, oerr := s.channels.OrphanModelIDs(ctx); oerr == nil {
|
||||||
|
for _, mid := range orphans {
|
||||||
|
_, _ = s.models.Delete(ctx, mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(skipped) > 0 {
|
||||||
|
return fmt.Sprintf("已同步上游模型,跳过 %d 个异常项:%s", len(skipped), strings.Join(skipped, ";")), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeRemoteModel validates an upstream /v1/models item into an import
|
||||||
|
// spec (alias = upstream id).
|
||||||
|
func normalizeRemoteModel(m upstream.RemoteModel) (ChannelModelSpec, error) {
|
||||||
|
spec := ChannelModelSpec{
|
||||||
|
ID: strings.TrimSpace(m.ID),
|
||||||
|
Kind: strings.TrimSpace(m.Kind),
|
||||||
|
Qualities: m.Qualities,
|
||||||
|
Duration: m.Duration,
|
||||||
|
RefImages: m.RefImages,
|
||||||
|
RefVideos: m.RefVideos,
|
||||||
|
RefAudios: m.RefAudios,
|
||||||
|
RefVideoSeconds: m.RefVideoSeconds,
|
||||||
|
RefAudioSeconds: m.RefAudioSeconds,
|
||||||
|
}
|
||||||
|
if spec.ID == "" {
|
||||||
|
return spec, errors.New("模型 id 为空")
|
||||||
|
}
|
||||||
|
switch spec.Kind {
|
||||||
|
case "image", "video", "text":
|
||||||
|
default:
|
||||||
|
return spec, fmt.Errorf("未知类型 %q", spec.Kind)
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a channel, its bindings, and any local model that ends up
|
// Delete removes a channel, its bindings, and any local model that ends up
|
||||||
@@ -205,8 +335,9 @@ type FetchModelsDto struct {
|
|||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchModels lists the upstream's sellable models and flags which are already
|
// FetchModels lists the upstream's sellable models; when a saved channel id is
|
||||||
// imported locally.
|
// given, items already bound to that channel are flagged Bound so the admin UI
|
||||||
|
// can render them as a bound/unbound toggle.
|
||||||
func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) {
|
func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) {
|
||||||
base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey)
|
base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,9 +347,44 @@ func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]u
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(d.ID) != "" {
|
||||||
|
if bound, berr := s.channels.UpstreamModelMap(ctx, d.ID); berr == nil && len(bound) > 0 {
|
||||||
|
set := make(map[string]struct{}, len(bound))
|
||||||
|
for _, upID := range bound {
|
||||||
|
set[upID] = struct{}{}
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
if _, ok := set[items[i].ID]; ok {
|
||||||
|
items[i].Bound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PurgeOrphanModels sweeps model_configs rows left over from the provider era:
|
||||||
|
// an unbound model can never be routed, so any row that didn't come through a
|
||||||
|
// channel import (provider != "channel") is deleted at startup. Channel-typed
|
||||||
|
// orphans keep their pricing config for a later rebind.
|
||||||
|
func (s *ChannelService) PurgeOrphanModels(ctx context.Context) (int, error) {
|
||||||
|
ids, err := s.channels.OrphanModelIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
m, gerr := s.models.Get(ctx, id)
|
||||||
|
if gerr != nil || m == nil || m.Provider == "channel" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, derr := s.models.Delete(ctx, id); derr == nil {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ChannelModelSpec is one model the admin ticks for import (same shape as the
|
// ChannelModelSpec is one model the admin ticks for import (same shape as the
|
||||||
// upstream /v1/models item plus an optional local alias).
|
// upstream /v1/models item plus an optional local alias).
|
||||||
type ChannelModelSpec struct {
|
type ChannelModelSpec struct {
|
||||||
@@ -257,19 +423,34 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(specs) > 50 {
|
||||||
|
return errors.New("单次最多导入 50 个模型")
|
||||||
|
}
|
||||||
|
seenAliases := make(map[string]string, len(specs)) // localID → 上游 id,别名冲突当场拒绝
|
||||||
for _, spec := range specs {
|
for _, spec := range specs {
|
||||||
upID := strings.TrimSpace(spec.ID)
|
upID := strings.TrimSpace(spec.ID)
|
||||||
if upID == "" {
|
if upID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
localID := strings.TrimSpace(spec.Alias)
|
alias := strings.TrimSpace(spec.Alias)
|
||||||
|
if alias == upID {
|
||||||
|
return fmt.Errorf("「%s」别名不能与上游模型同名,留空即可", upID)
|
||||||
|
}
|
||||||
|
localID := alias
|
||||||
if localID == "" {
|
if localID == "" {
|
||||||
localID = upID
|
localID = upID
|
||||||
}
|
}
|
||||||
|
if prev, dup := seenAliases[localID]; dup && prev != upID {
|
||||||
|
return fmt.Errorf("别名 %s 被多个上游模型复用", localID)
|
||||||
|
}
|
||||||
|
seenAliases[localID] = upID
|
||||||
existing, gerr := s.models.Get(ctx, localID)
|
existing, gerr := s.models.Get(ctx, localID)
|
||||||
if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) {
|
if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) {
|
||||||
existing = nil
|
existing = nil
|
||||||
}
|
}
|
||||||
|
if existing != nil && existing.Type != "" && spec.Kind != "" && existing.Type != spec.Kind {
|
||||||
|
return fmt.Errorf("模型 %s 已存在且类型不同(%s)", localID, existing.Type)
|
||||||
|
}
|
||||||
var local *model.ModelConfig
|
var local *model.ModelConfig
|
||||||
if existing != nil && existing.ID == localID {
|
if existing != nil && existing.ID == localID {
|
||||||
local = existing
|
local = existing
|
||||||
@@ -281,7 +462,6 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
|
|||||||
Name: localID,
|
Name: localID,
|
||||||
Type: spec.Kind,
|
Type: spec.Kind,
|
||||||
Provider: "channel",
|
Provider: "channel",
|
||||||
Enabled: true,
|
|
||||||
Ratios: jsonArray([]string{"16:9"}),
|
Ratios: jsonArray([]string{"16:9"}),
|
||||||
Prices: datatypes.JSONMap{},
|
Prices: datatypes.JSONMap{},
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -303,8 +483,8 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// modelPatchFromSpec maps an upstream model spec onto model_configs columns.
|
// modelPatchFromSpec maps an upstream model spec onto model_configs columns.
|
||||||
// Prices/enabled/alias are intentionally untouched on update so admin pricing
|
// Prices/alias are intentionally untouched on update so admin pricing survives
|
||||||
// survives re-import.
|
// re-import.
|
||||||
func modelPatchFromSpec(spec ChannelModelSpec, existing *model.ModelConfig) map[string]any {
|
func modelPatchFromSpec(spec ChannelModelSpec, existing *model.ModelConfig) map[string]any {
|
||||||
patch := map[string]any{
|
patch := map[string]any{
|
||||||
"type": spec.Kind,
|
"type": spec.Kind,
|
||||||
@@ -392,6 +572,11 @@ func (s *ChannelService) ChannelsForModelAnyState(ctx context.Context, modelID s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt exposes the upstream key for dispatch.
|
// Decrypt exposes the upstream key for dispatch.
|
||||||
|
// Get loads one channel row by id (resume path needs the owning channel).
|
||||||
|
func (s *ChannelService) Get(ctx context.Context, id string) (*model.Channel, error) {
|
||||||
|
return s.channels.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ChannelService) Decrypt(ch model.Channel) string { return s.decrypt(ch.APIKeyEnc) }
|
func (s *ChannelService) Decrypt(ch model.Channel) string { return s.decrypt(ch.APIKeyEnc) }
|
||||||
|
|
||||||
// Upstream returns the shared protocol client (dispatch needs it for
|
// Upstream returns the shared protocol client (dispatch needs it for
|
||||||
|
|||||||
@@ -20,23 +20,24 @@ type MaintenanceService struct {
|
|||||||
users *repo.UserRepository
|
users *repo.UserRepository
|
||||||
settings *repo.SiteSettingRepository
|
settings *repo.SiteSettingRepository
|
||||||
store *storage.Client
|
store *storage.Client
|
||||||
inflight *InflightRegistry
|
v1 *V1Service
|
||||||
showcase *repo.ShowcaseRepository
|
showcase *repo.ShowcaseRepository
|
||||||
orders *repo.OrderRepository
|
orders *repo.OrderRepository
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
stalePending time.Duration
|
stalePending time.Duration
|
||||||
stalePendingVid time.Duration
|
stalePendingVid time.Duration
|
||||||
|
maxResumeAge time.Duration
|
||||||
mediaPruneEvery time.Duration
|
mediaPruneEvery time.Duration
|
||||||
lastMediaPrune time.Time
|
lastMediaPrune time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMaintenanceService(events *repo.EventRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository, store *storage.Client, inflight *InflightRegistry, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
|
func NewMaintenanceService(events *repo.EventRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository, store *storage.Client, v1 *V1Service, showcase *repo.ShowcaseRepository, orders *repo.OrderRepository) *MaintenanceService {
|
||||||
return &MaintenanceService{
|
return &MaintenanceService{
|
||||||
events: events,
|
events: events,
|
||||||
users: users,
|
users: users,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
store: store,
|
store: store,
|
||||||
inflight: inflight,
|
v1: v1,
|
||||||
showcase: showcase,
|
showcase: showcase,
|
||||||
orders: orders,
|
orders: orders,
|
||||||
interval: 60 * time.Second,
|
interval: 60 * time.Second,
|
||||||
@@ -44,6 +45,9 @@ func NewMaintenanceService(events *repo.EventRepository, users *repo.UserReposit
|
|||||||
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
|
// 视频比图片慢得多,单条执行预算就是 30 分钟(videoGenBudget),用图片的
|
||||||
// 10 分钟去扫会在它出片前判死并退款。
|
// 10 分钟去扫会在它出片前判死并退款。
|
||||||
stalePendingVid: 30 * time.Minute,
|
stalePendingVid: 30 * time.Minute,
|
||||||
|
// 带上游 jobId 的逾期任务先尝试续等而不是判死;超过 3 小时总时限
|
||||||
|
// (vivid 同款)才放弃走判死退款。
|
||||||
|
maxResumeAge: 3 * time.Hour,
|
||||||
mediaPruneEvery: 60 * time.Second,
|
mediaPruneEvery: 60 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,10 +79,26 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Fail long-pending events so they stop blocking the per-user gate, and
|
// 1a. Pending events with an upstream job id that outlived their local
|
||||||
|
// goroutine (process restart) get resumed against the owning channel —
|
||||||
|
// the upstream job keeps running even while we were down.
|
||||||
|
if m.v1 != nil {
|
||||||
|
if resumable, err := m.events.ResumableStale(ctx, m.stalePending, m.stalePendingVid, m.maxResumeAge); err != nil {
|
||||||
|
log.Printf("maintenance: resumable_stale: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, e := range resumable {
|
||||||
|
m.v1.ResumeStaleJob(ctx, e)
|
||||||
|
}
|
||||||
|
if len(resumable) > 0 {
|
||||||
|
log.Printf("maintenance: resuming %d stale job(s) on their channels", len(resumable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Fail long-pending events so they stop blocking the per-user gate, and
|
||||||
// refund the credits debited up-front for each abandoned generation (the
|
// refund the credits debited up-front for each abandoned generation (the
|
||||||
// normal failure-refund path never ran for a process-restart orphan).
|
// normal failure-refund path never ran for a process-restart orphan).
|
||||||
if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid); err != nil {
|
if purged, err := m.events.PurgeStale(ctx, m.stalePending, m.stalePendingVid, m.maxResumeAge); err != nil {
|
||||||
log.Printf("maintenance: purge_stale: %v", err)
|
log.Printf("maintenance: purge_stale: %v", err)
|
||||||
} else if len(purged) > 0 {
|
} else if len(purged) > 0 {
|
||||||
refunded := 0
|
refunded := 0
|
||||||
@@ -87,7 +107,7 @@ func (m *MaintenanceService) tick(ctx context.Context) {
|
|||||||
// Stop the generation goroutine if it's still running, so it doesn't
|
// Stop the generation goroutine if it's still running, so it doesn't
|
||||||
// keep grinding for minutes and surface a late "success" on this
|
// keep grinding for minutes and surface a late "success" on this
|
||||||
// just-abandoned event.
|
// just-abandoned event.
|
||||||
if m.inflight != nil && m.inflight.Cancel(e.ID) {
|
if m.v1 != nil && m.v1.Inflight().Cancel(e.ID) {
|
||||||
cancelled++
|
cancelled++
|
||||||
}
|
}
|
||||||
if e.UserID == "" || e.Cost <= 0 {
|
if e.UserID == "" || e.Cost <= 0 {
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ const videoGenBudget = 30 * time.Minute
|
|||||||
// imageGenBudget caps one image generation end-to-end.
|
// imageGenBudget caps one image generation end-to-end.
|
||||||
const imageGenBudget = 8 * time.Minute
|
const imageGenBudget = 8 * time.Minute
|
||||||
|
|
||||||
// defaultChannelConcurrency applies when a channel's Concurrency is 0.
|
// Channel concurrency semantics: 0 = unlimited (the Lua slot script passes
|
||||||
const defaultChannelConcurrency = 5
|
// max=0 straight through), matching vivid's channel design.
|
||||||
|
|
||||||
// maxChannelFailover caps how many channels one request may burn on
|
// maxChannelFailover caps how many channels one request may burn on
|
||||||
// retryable/temporary errors before giving up, so an upstream-wide blip can't
|
// retryable/temporary errors before giving up, so an upstream-wide blip can't
|
||||||
@@ -100,11 +100,11 @@ type V1Service struct {
|
|||||||
conc *ConcurrencyService
|
conc *ConcurrencyService
|
||||||
}
|
}
|
||||||
|
|
||||||
// chanAcquire takes one per-channel slot (capped at max; 0 = default), tagged
|
// chanAcquire takes one per-channel slot (capped at max; 0 = unlimited), tagged
|
||||||
// with the generation's eventID.
|
// with the generation's eventID.
|
||||||
func (s *V1Service) chanAcquire(ctx context.Context, channelID, eventID string, max int) bool {
|
func (s *V1Service) chanAcquire(ctx context.Context, channelID, eventID string, max int) bool {
|
||||||
if max < 1 {
|
if max < 0 {
|
||||||
max = defaultChannelConcurrency
|
max = 0
|
||||||
}
|
}
|
||||||
return s.conc.Acquire(ctx, "conc:c:"+channelID, max, eventID)
|
return s.conc.Acquire(ctx, "conc:c:"+channelID, max, eventID)
|
||||||
}
|
}
|
||||||
@@ -159,6 +159,16 @@ func (r *InflightRegistry) Add(eventID string, cancel context.CancelFunc) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TryAdd registers an event only when nothing is tracked for it — the resume
|
||||||
|
// sweep uses it so two ticks can't double-attach to the same upstream job.
|
||||||
|
func (r *InflightRegistry) TryAdd(eventID string, cancel context.CancelFunc) bool {
|
||||||
|
if eventID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, loaded := r.m.LoadOrStore(eventID, cancel)
|
||||||
|
return !loaded
|
||||||
|
}
|
||||||
|
|
||||||
// Done deregisters an event (called on normal completion).
|
// Done deregisters an event (called on normal completion).
|
||||||
func (r *InflightRegistry) Done(eventID string) { r.m.Delete(eventID) }
|
func (r *InflightRegistry) Done(eventID string) { r.m.Delete(eventID) }
|
||||||
|
|
||||||
@@ -342,9 +352,9 @@ type V1ModelDur struct {
|
|||||||
Options []int64 `json:"options,omitempty"`
|
Options []int64 `json:"options,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SellableModels — GET /v1/models. Only enabled models bound to at least one
|
// SellableModels — GET /v1/models. 配价即上架(无启停开关):绑定到至少一个
|
||||||
// enabled channel AND fully priced are sellable (a model with an unpriced tier
|
// enabled 渠道 AND 全部档位有价才是可售模型(缺价的档位提交时会扣费拒绝,
|
||||||
// would charge-reject at submit time — hide it instead).
|
// 直接不下发)。kind 不限——image/video/text 同构透出,下游才能连导。
|
||||||
func (s *V1Service) SellableModels(ctx context.Context) ([]V1ModelItem, error) {
|
func (s *V1Service) SellableModels(ctx context.Context) ([]V1ModelItem, error) {
|
||||||
items, err := s.models.List(ctx)
|
items, err := s.models.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -352,9 +362,6 @@ func (s *V1Service) SellableModels(ctx context.Context) ([]V1ModelItem, error) {
|
|||||||
}
|
}
|
||||||
out := make([]V1ModelItem, 0, len(items))
|
out := make([]V1ModelItem, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if !item.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !s.modelSellable(ctx, &item) {
|
if !s.modelSellable(ctx, &item) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -393,6 +400,12 @@ func (s *V1Service) modelSellable(ctx context.Context, item *model.ModelConfig)
|
|||||||
if err != nil || len(channels) == 0 {
|
if err != nil || len(channels) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
return ModelFullyPriced(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelFullyPriced — 上架门槛(无启停开关,配价即上架):每个清晰度档位在
|
||||||
|
// Prices 里有价;视频模型还要求时长有价(离散档逐档,或区间档按秒价)。
|
||||||
|
func ModelFullyPriced(item *model.ModelConfig) bool {
|
||||||
for _, res := range repo.JSONStrings(item.Resolutions) {
|
for _, res := range repo.JSONStrings(item.Resolutions) {
|
||||||
if _, ok := jsonMapFloat(item.Prices, res); !ok {
|
if _, ok := jsonMapFloat(item.Prices, res); !ok {
|
||||||
return false
|
return false
|
||||||
@@ -564,6 +577,10 @@ func (s *V1Service) generateOnChannel(ctx context.Context, eventID string, ch mo
|
|||||||
if upstreamJob == "" {
|
if upstreamJob == "" {
|
||||||
upstreamJob = job
|
upstreamJob = job
|
||||||
_ = s.events.SetUpstreamJob(ctx, eventID, job)
|
_ = s.events.SetUpstreamJob(ctx, eventID, job)
|
||||||
|
// Stamp the owning channel the moment the upstream accepts the job —
|
||||||
|
// a restart between submit and finish would otherwise leave a
|
||||||
|
// pending row that can't tell which channel to resume on.
|
||||||
|
_ = s.events.SetAccount(ctx, eventID, ch.ID, ch.Name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -881,9 +898,6 @@ func (s *V1Service) CreateJob(ctx context.Context, principal *APIPrincipal, in V
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !modelItem.Enabled {
|
|
||||||
return nil, ErrUnknownModel
|
|
||||||
}
|
|
||||||
kind := modelItem.Type
|
kind := modelItem.Type
|
||||||
if kind != "image" && kind != "video" {
|
if kind != "image" && kind != "video" {
|
||||||
return nil, ErrUnknownModel
|
return nil, ErrUnknownModel
|
||||||
@@ -1052,6 +1066,96 @@ func (s *V1Service) runV1Job(ctx context.Context, principal *APIPrincipal, event
|
|||||||
_ = s.maybeGrantInviteReward(ctx, principal)
|
_ = s.maybeGrantInviteReward(ctx, principal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResumeStaleJob re-attaches to an upstream job whose local goroutine died with
|
||||||
|
// a process restart. Called by the maintenance sweep; TryAdd guards against
|
||||||
|
// double-attach across ticks. Terminal failure (upstream job lost/failed,
|
||||||
|
// channel gone or key broken) fails the event and refunds; transient failure
|
||||||
|
// leaves it pending for the next sweep (events older than maxResumeAge get
|
||||||
|
// purged instead of resumed).
|
||||||
|
func (s *V1Service) ResumeStaleJob(ctx context.Context, ev repo.StaleEvent) {
|
||||||
|
if ev.UpstreamJob == "" || ev.AccountID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), videoGenBudget)
|
||||||
|
if !s.inflight.TryAdd(ev.ID, cancel) {
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go s.resumeUpstreamJob(rctx, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *V1Service) resumeUpstreamJob(ctx context.Context, ev repo.StaleEvent) {
|
||||||
|
defer s.inflight.Done(ev.ID)
|
||||||
|
fail := func(msg string) {
|
||||||
|
s.refundStale(ctx, ev)
|
||||||
|
elapsed := int(time.Since(ev.TS).Milliseconds())
|
||||||
|
_ = s.events.UpdateStatus(ctx, ev.ID, "failed", msg, elapsed)
|
||||||
|
}
|
||||||
|
ch, err := s.channels.Get(ctx, ev.AccountID)
|
||||||
|
if err != nil || ch == nil {
|
||||||
|
fail("上游渠道已删除,任务无法续等")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiKey := s.channels.Decrypt(*ch)
|
||||||
|
if apiKey == "" {
|
||||||
|
fail("上游渠道密钥不可用,任务无法续等")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(ch.BaseURL, "/")
|
||||||
|
resultURL, credits, err := s.up.Generate(ctx, base, apiKey, upstream.GenParams{JobID: ev.UpstreamJob}, int64(videoGenBudget/time.Second), nil)
|
||||||
|
if err != nil {
|
||||||
|
switch upstream.KindOf(err) {
|
||||||
|
case upstream.KindJobFailed, upstream.KindInvalid, upstream.KindAuth:
|
||||||
|
fail(upstreamUserError(err).Error())
|
||||||
|
default:
|
||||||
|
// Retryable/transport/timeout — the upstream job may still run;
|
||||||
|
// leave the event pending for the next sweep.
|
||||||
|
log.Printf("[v1] resume upstream job %s (event %s) retry later: %v", ev.UpstreamJob, ev.ID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, ct, err := s.up.Download(ctx, resultURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[v1] resume download %s (event %s) retry later: %v", ev.UpstreamJob, ev.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rel := strings.TrimSpace(ev.File)
|
||||||
|
if rel == "" {
|
||||||
|
rel = filepath.ToSlash(filepath.Join("tmp", ev.ID+"."+resultExt(ct, ev.Kind)))
|
||||||
|
}
|
||||||
|
if err := s.store.Put(ctx, rel, data, ct); err != nil {
|
||||||
|
fail("storage upload failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.events.SetFile(ctx, ev.ID, rel)
|
||||||
|
if err := s.events.UpdateStatus(ctx, ev.ID, "success", "", int(time.Since(ev.TS).Milliseconds())); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.channels.BumpSuccess(ctx, ch.ID, float64(credits))
|
||||||
|
if ev.Model != "" {
|
||||||
|
_ = s.models.IncrementGenerationCount(ctx, ev.Model)
|
||||||
|
}
|
||||||
|
if ev.UserID != "" {
|
||||||
|
_ = s.users.IncrementGenerationCount(ctx, ev.UserID)
|
||||||
|
}
|
||||||
|
log.Printf("[v1] resumed upstream job %s → event %s succeeded", ev.UpstreamJob, ev.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundStale returns the up-front charge for an abandoned/resume-dead event,
|
||||||
|
// claimed exactly once via the refunded flag.
|
||||||
|
func (s *V1Service) refundStale(ctx context.Context, ev repo.StaleEvent) {
|
||||||
|
if ev.UserID == "" || ev.Cost <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claimed, err := s.events.MarkRefunded(ctx, ev.ID)
|
||||||
|
if err != nil || !claimed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.users.RefundCredits(ctx, ev.UserID, ev.Cost); err != nil {
|
||||||
|
log.Printf("[v1] refund stale event %s (user %s, %.0f): %v", ev.ID, ev.UserID, ev.Cost, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resultExt(contentType, kind string) string {
|
func resultExt(contentType, kind string) string {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(contentType, "webp"):
|
case strings.Contains(contentType, "webp"):
|
||||||
@@ -1300,7 +1404,7 @@ func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, i
|
|||||||
}
|
}
|
||||||
return nil, "", "", 0, err
|
return nil, "", "", 0, err
|
||||||
}
|
}
|
||||||
if !modelItem.Enabled || modelItem.Type != "image" {
|
if modelItem.Type != "image" {
|
||||||
return nil, "", "", 0, ErrUnknownModel
|
return nil, "", "", 0, ErrUnknownModel
|
||||||
}
|
}
|
||||||
// Fail fast before charging if no channel serves this model.
|
// Fail fast before charging if no channel serves this model.
|
||||||
@@ -1361,7 +1465,7 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i
|
|||||||
}
|
}
|
||||||
return nil, "", "", "", 0, err
|
return nil, "", "", "", 0, err
|
||||||
}
|
}
|
||||||
if !modelItem.Enabled || modelItem.Type != "video" {
|
if modelItem.Type != "video" {
|
||||||
return nil, "", "", "", 0, ErrUnknownModel
|
return nil, "", "", "", 0, ErrUnknownModel
|
||||||
}
|
}
|
||||||
// Validate duration against the model's spec (options list or min/max range).
|
// Validate duration against the model's spec (options list or min/max range).
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ type RemoteModel struct {
|
|||||||
// Combined reference duration caps in seconds; 0 = unlimited.
|
// Combined reference duration caps in seconds; 0 = unlimited.
|
||||||
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
||||||
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
||||||
|
// Bound is set by the admin fetch-models endpoint only: true when this
|
||||||
|
// upstream id is already bound to that channel locally.
|
||||||
|
Bound bool `json:"bound,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoteDuration mirrors the duration spec of a vivid model item.
|
// RemoteDuration mirrors the duration spec of a vivid model item.
|
||||||
@@ -419,8 +422,13 @@ func (c *Client) Download(ctx context.Context, url string) ([]byte, string, erro
|
|||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil, "", fmt.Errorf("%w: empty result", ErrRetryable)
|
return nil, "", fmt.Errorf("%w: empty result", ErrRetryable)
|
||||||
}
|
}
|
||||||
|
// Content-Type fallback chain: only trust image/* video/* headers (upstreams
|
||||||
|
// often mislabel results text/plain or octet-stream), else sniff magic bytes.
|
||||||
ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||||
if ct == "" || ct == "application/octet-stream" {
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||||
|
ct = strings.TrimSpace(ct[:i])
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ct, "image/") && !strings.HasPrefix(ct, "video/") {
|
||||||
ct = http.DetectContentType(data)
|
ct = http.DetectContentType(data)
|
||||||
}
|
}
|
||||||
return data, ct, nil
|
return data, ct, nil
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ services:
|
|||||||
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-vividai_secret_2026}
|
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-vividai_secret_2026}
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://vividai.run,http://www.vividai.run,http://206.168.190.183}
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://vividai.run,http://www.vividai.run,http://206.168.190.183}
|
||||||
COOKIE_SECURE: "false"
|
COOKIE_SECURE: "false"
|
||||||
|
# 渠道上游 API Key 的 AES-GCM 加密密钥——务必配置随机值;丢失/变更后已存的渠道密钥无法解密
|
||||||
|
CHANNEL_SECRET: ${CHANNEL_SECRET:-change-me-channel-secret}
|
||||||
volumes:
|
volumes:
|
||||||
- generated:/app/data/generated
|
- generated:/app/data/generated
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ services:
|
|||||||
RUSTFS_SECRET_KEY: vividai-secret-change-me
|
RUSTFS_SECRET_KEY: vividai-secret-change-me
|
||||||
CORS_ORIGINS: http://localhost:2000
|
CORS_ORIGINS: http://localhost:2000
|
||||||
COOKIE_SECURE: "false"
|
COOKIE_SECURE: "false"
|
||||||
|
# 渠道上游 API Key 的 AES-GCM 加密密钥——生产环境务必改成随机值并保持不变
|
||||||
|
CHANNEL_SECRET: change-me-channel-secret
|
||||||
volumes:
|
volumes:
|
||||||
- generated:/app/data/generated
|
- generated:/app/data/generated
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# ai-gateway frontend
|
# image2api frontend
|
||||||
|
|
||||||
Vue 3 + Vite admin console for the ai-gateway backend. This replaces the old
|
Vue 3 + Vite frontend for `image2api`: user-facing generation UI (playground,
|
||||||
single-file `static/admin.html`.
|
gallery, docs, billing) plus the admin console (channels, models, users,
|
||||||
|
orders, logs, config).
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -10,27 +11,10 @@ npm install
|
|||||||
npm run dev # http://localhost:5173
|
npm run dev # http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
The dev server proxies `/admin`, `/health`, `/generated`, `/v1` to the backend.
|
The dev server proxies `/admin`, `/health`, `/images`, `/v1` to the backend.
|
||||||
Start the backend separately:
|
Start the Go backend separately (see `backend/README.md`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# from repo root
|
# from ../backend
|
||||||
python app.py # http://0.0.0.0:6060
|
go build -o bin/api ./cmd/api && ./bin/api # http://127.0.0.1:6666
|
||||||
```
|
```
|
||||||
|
|
||||||
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 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e9d8c4"/><stop offset="1" stop-color="#c9a27e"/></linearGradient></defs><rect width="600" height="800" fill="url(#g)"/><circle cx="492.00000000000006" cy="256" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 528 Q150 400 300 512 T600 463.99999999999994 V800 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 624 Q180 528 360 624 T600 576 V800 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfd8c8"/><stop offset="1" stop-color="#8aa38b"/></linearGradient></defs><rect width="600" height="800" fill="url(#g)"/><circle cx="414" cy="256" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 528 Q150 400 300 512 T600 463.99999999999994 V800 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 624 Q180 528 360 624 T600 576 V800 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cdd6e0"/><stop offset="1" stop-color="#7f93ad"/></linearGradient></defs><rect width="600" height="600" fill="url(#g)"/><circle cx="336" cy="192" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 396 Q150 300 300 384 T600 348 V600 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 468 Q180 396 360 468 T600 432 V600 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 517 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e6c9bd"/><stop offset="1" stop-color="#c07a5f"/></linearGradient></defs><rect width="600" height="800" fill="url(#g)"/><circle cx="558" cy="256" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 528 Q150 400 300 512 T600 463.99999999999994 V800 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 624 Q180 528 360 624 T600 576 V800 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e6c9bd"/><stop offset="1" stop-color="#c07a5f"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="656" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#dcd6cf"/><stop offset="1" stop-color="#9a9088"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="552" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d9cfe0"/><stop offset="1" stop-color="#9a86a8"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="448" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfe0dc"/><stop offset="1" stop-color="#6f9c95"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="744" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e9d8c4"/><stop offset="1" stop-color="#c9a27e"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="640" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfd8c8"/><stop offset="1" stop-color="#8aa38b"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="536" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cdd6e0"/><stop offset="1" stop-color="#7f93ad"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="432" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>
|
||||||
|
After Width: | Height: | Size: 589 B |
@@ -17,7 +17,7 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
|
|||||||
<div class="px-6 py-4 border-b border-b-[color:var(--ink-soft)] flex items-center justify-between gap-3 shrink-0">
|
<div class="px-6 py-4 border-b border-b-[color:var(--ink-soft)] flex items-center justify-between gap-3 shrink-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-7 h-7 rounded-lg text-white grid place-items-center"
|
<span class="w-7 h-7 rounded-lg text-white grid place-items-center"
|
||||||
style="background: linear-gradient(135deg, #7c5cfc, #22d3ee); box-shadow: 0 4px 12px var(--accent-glow)">
|
style="background: linear-gradient(135deg, #18181b, #52525b); box-shadow: 0 4px 12px var(--accent-glow)">
|
||||||
<Icon name="spark" class="w-4 h-4" />
|
<Icon name="spark" class="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
<h2 class="text-base font-extrabold">公告</h2>
|
<h2 class="text-base font-extrabold">公告</h2>
|
||||||
@@ -30,7 +30,7 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
|
|||||||
<div class="px-6 py-4 border-t border-t-[color:var(--ink-soft)] flex justify-end shrink-0">
|
<div class="px-6 py-4 border-t border-t-[color:var(--ink-soft)] flex justify-end shrink-0">
|
||||||
<button @click="dismissAnnouncement"
|
<button @click="dismissAnnouncement"
|
||||||
class="rounded-lg text-white border border-white/20 px-5 py-2 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
|
class="rounded-lg text-white border border-white/20 px-5 py-2 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
|
||||||
style="background: linear-gradient(120deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">
|
style="background: linear-gradient(120deg, #18181b, #52525b); box-shadow: 0 8px 20px var(--accent-glow)">
|
||||||
我知道了
|
我知道了
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +55,7 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
|
|||||||
.ann-body :deep(li) { margin: 0.2em 0; }
|
.ann-body :deep(li) { margin: 0.2em 0; }
|
||||||
.ann-body :deep(a) { color: var(--m-orange); text-decoration: underline; }
|
.ann-body :deep(a) { color: var(--m-orange); text-decoration: underline; }
|
||||||
.ann-body :deep(strong) { font-weight: 700; color: var(--fg); }
|
.ann-body :deep(strong) { font-weight: 700; color: var(--fg); }
|
||||||
.ann-body :deep(code) { background: rgb(124 92 252 / 0.12); padding: 0.1em 0.35em; border-radius: 0.3rem; font-size: 0.85em; }
|
.ann-body :deep(code) { background: rgb(24 24 27 / 0.12); padding: 0.1em 0.35em; border-radius: 0.3rem; font-size: 0.85em; }
|
||||||
.ann-body :deep(pre) { background: #111; color: #f5f5f5; padding: 0.8em; border-radius: 0.5rem; overflow-x: auto; margin: 0.6em 0; }
|
.ann-body :deep(pre) { background: #111; color: #f5f5f5; padding: 0.8em; border-radius: 0.5rem; overflow-x: auto; margin: 0.6em 0; }
|
||||||
.ann-body :deep(pre code) { background: none; padding: 0; }
|
.ann-body :deep(pre code) { background: none; padding: 0; }
|
||||||
.ann-body :deep(blockquote) { border-left: 3px solid var(--m-orange); padding-left: 0.9em; color: var(--fg-3); margin: 0.6em 0; }
|
.ann-body :deep(blockquote) { border-left: 3px solid var(--m-orange); padding-left: 0.9em; color: var(--fg-3); margin: 0.6em 0; }
|
||||||
|
|||||||
@@ -28,12 +28,24 @@ const PATHS = {
|
|||||||
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
|
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
|
||||||
play: '<path d="m6 3 14 9-14 9V3Z"/>',
|
play: '<path d="m6 3 14 9-14 9V3Z"/>',
|
||||||
pause: '<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
|
pause: '<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
|
||||||
|
arrow: '<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>',
|
||||||
|
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
|
||||||
|
chat: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
|
||||||
|
layers: '<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"/>',
|
||||||
|
key: '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l3-3a1 1 0 0 0 0-1.4l-2.3-2.3a1 1 0 0 0-1.4 0l-3 3a1 1 0 0 0 0 1.4z"/><path d="m15.5 7.5-8 8"/><circle cx="7.5" cy="15.5" r="5.5"/>',
|
||||||
|
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/>',
|
||||||
|
team: '<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"/>',
|
||||||
|
wallet: '<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/>',
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps({ name: { type: String, required: true } })
|
defineProps({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
size: { type: [Number, String], default: null },
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
:width="size || undefined" :height="size || undefined"
|
||||||
stroke-linecap="round" stroke-linejoin="round" v-html="PATHS[name] || ''" />
|
stroke-linecap="round" stroke-linejoin="round" v-html="PATHS[name] || ''" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -161,12 +161,9 @@ async function submit() {
|
|||||||
<template>
|
<template>
|
||||||
<transition name="modal">
|
<transition name="modal">
|
||||||
<div v-if="auth.loginOpen" class="fixed inset-0 z-50 grid place-items-center px-4">
|
<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="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="closeLogin"></div>
|
||||||
|
|
||||||
<div class="card relative z-10 w-full max-w-[26rem]">
|
<div class="card relative z-10 w-full max-w-[26rem]">
|
||||||
<!-- ambient aurora washes -->
|
|
||||||
<div class="pointer-events-none absolute -top-24 -right-16 h-56 w-56 rounded-full bg-[#8b5cf6]/30 blur-3xl"></div>
|
|
||||||
<div class="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-[#22d3ee]/25 blur-3xl"></div>
|
|
||||||
|
|
||||||
<div class="relative p-7">
|
<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"
|
<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"
|
||||||
@@ -263,11 +260,9 @@ async function submit() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.card {
|
.card {
|
||||||
border-radius: 1.2rem;
|
border-radius: 1.2rem;
|
||||||
background: var(--menu-bg);
|
background: #ffffff;
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid rgb(24 24 27 / 0.08);
|
||||||
box-shadow: var(--glass-shadow-lg);
|
box-shadow: var(--glass-shadow-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -277,7 +272,7 @@ html.dark .card {
|
|||||||
|
|
||||||
.logo-halo {
|
.logo-halo {
|
||||||
position: relative;
|
position: relative;
|
||||||
filter: drop-shadow(0 6px 14px rgb(124 92 252 / 0.4));
|
filter: drop-shadow(0 6px 14px rgb(24 24 27 / 0.4));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* segmented tab control */
|
/* segmented tab control */
|
||||||
@@ -296,7 +291,7 @@ html.dark .card {
|
|||||||
.tab:hover { color: var(--fg); }
|
.tab:hover { color: var(--fg); }
|
||||||
.tab-on {
|
.tab-on {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
background: linear-gradient(120deg, #18181b, #27272a);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
box-shadow: 0 4px 14px var(--accent-glow);
|
box-shadow: 0 4px 14px var(--accent-glow);
|
||||||
}
|
}
|
||||||
@@ -317,8 +312,8 @@ html.dark .card {
|
|||||||
}
|
}
|
||||||
html.dark .fld { background: rgb(255 255 255 / 0.05); }
|
html.dark .fld { background: rgb(255 255 255 / 0.05); }
|
||||||
.fld:focus {
|
.fld:focus {
|
||||||
border-color: rgb(124 92 252 / 0.65);
|
border-color: rgb(24 24 27 / 0.65);
|
||||||
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
|
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.18);
|
||||||
}
|
}
|
||||||
.fld::placeholder { color: var(--fg-faint); }
|
.fld::placeholder { color: var(--fg-faint); }
|
||||||
/* Keep autofill on-theme in dark (otherwise it paints white/yellow). */
|
/* Keep autofill on-theme in dark (otherwise it paints white/yellow). */
|
||||||
@@ -345,11 +340,11 @@ html.dark .fld:-webkit-autofill:focus {
|
|||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
font-size: 0.75rem; font-weight: 800; white-space: nowrap;
|
font-size: 0.75rem; font-weight: 800; white-space: nowrap;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
background: var(--hover); border: 1px solid rgb(124 92 252 / 0.4);
|
background: var(--hover); border: 1px solid rgb(24 24 27 / 0.4);
|
||||||
box-shadow: 0 2px 10px rgb(124 92 252 / 0.14);
|
box-shadow: 0 2px 10px rgb(24 24 27 / 0.14);
|
||||||
transition: background 0.15s, box-shadow 0.15s, transform 0.15s, opacity 0.15s;
|
transition: background 0.15s, box-shadow 0.15s, transform 0.15s, opacity 0.15s;
|
||||||
}
|
}
|
||||||
.code-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 14px rgb(124 92 252 / 0.22); }
|
.code-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 14px rgb(24 24 27 / 0.22); }
|
||||||
.code-btn:active:not(:disabled) { transform: scale(0.96) translateY(1px); box-shadow: none; }
|
.code-btn:active:not(:disabled) { transform: scale(0.96) translateY(1px); box-shadow: none; }
|
||||||
/* Disabled (sending / countdown): just dim — keep the theme text color so it
|
/* Disabled (sending / countdown): just dim — keep the theme text color so it
|
||||||
never washes out to invisible white on the light card. */
|
never washes out to invisible white on the light card. */
|
||||||
@@ -361,7 +356,7 @@ html.dark .fld:-webkit-autofill:focus {
|
|||||||
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||||
padding: 0.72rem 0; border-radius: 0.65rem; font-size: 0.9rem; font-weight: 800;
|
padding: 0.72rem 0; border-radius: 0.65rem; font-size: 0.9rem; font-weight: 800;
|
||||||
letter-spacing: 0.02em; color: white; margin-top: 0.35rem;
|
letter-spacing: 0.02em; color: white; margin-top: 0.35rem;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
|
background: linear-gradient(120deg, #18181b, #27272a 55%, #52525b 140%);
|
||||||
border: 1px solid rgb(255 255 255 / 0.16);
|
border: 1px solid rgb(255 255 255 / 0.16);
|
||||||
box-shadow: 0 8px 22px var(--accent-glow);
|
box-shadow: 0 8px 22px var(--accent-glow);
|
||||||
transition: transform 0.13s, box-shadow 0.13s, filter 0.15s;
|
transition: transform 0.13s, box-shadow 0.13s, filter 0.15s;
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ async function save() {
|
|||||||
duration_prices_agent,
|
duration_prices_agent,
|
||||||
alias: alias.value.trim(),
|
alias: alias.value.trim(),
|
||||||
max_reference_images: e.max_reference_images || 0,
|
max_reference_images: e.max_reference_images || 0,
|
||||||
|
max_reference_videos: e.max_reference_videos || 0,
|
||||||
|
max_reference_audios: e.max_reference_audios || 0,
|
||||||
|
max_ref_video_seconds: e.max_ref_video_seconds || 0,
|
||||||
|
max_ref_audio_seconds: e.max_ref_audio_seconds || 0,
|
||||||
reference_mode: e.reference_mode || 'none',
|
reference_mode: e.reference_mode || 'none',
|
||||||
weight: Number(weight.value) || 0,
|
weight: Number(weight.value) || 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ onUnmounted(clearTimers)
|
|||||||
<p class="text-sm text-[color:var(--fg-3)] mt-1">二维码已失效,订单已取消</p>
|
<p class="text-sm text-[color:var(--fg-3)] mt-1">二维码已失效,订单已取消</p>
|
||||||
<p class="text-xs text-[color:var(--fg-faint)] mt-1">如需充值请重新下单</p>
|
<p class="text-xs text-[color:var(--fg-faint)] mt-1">如需充值请重新下单</p>
|
||||||
<button @click="finish" class="mt-5 rounded-lg text-white border border-white/20 px-5 py-2 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
|
<button @click="finish" class="mt-5 rounded-lg text-white border border-white/20 px-5 py-2 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
|
||||||
style="background: linear-gradient(120deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">关闭</button>
|
style="background: linear-gradient(120deg, #18181b, #52525b); box-shadow: 0 8px 20px var(--accent-glow)">关闭</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- pending -->
|
<!-- pending -->
|
||||||
@@ -147,7 +147,7 @@ onUnmounted(clearTimers)
|
|||||||
<!-- jump (no qrcode): the cashier opened in a new tab — just monitor -->
|
<!-- jump (no qrcode): the cashier opened in a new tab — just monitor -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex flex-col items-center justify-center py-7 mb-2">
|
<div class="flex flex-col items-center justify-center py-7 mb-2">
|
||||||
<div class="w-12 h-12 rounded-full border-[3px] border-[rgb(124_92_252/0.2)] border-t-[#7c5cfc] animate-spin mb-3"></div>
|
<div class="w-12 h-12 rounded-full border-[3px] border-[rgb(124_92_252/0.2)] border-t-[#18181b] animate-spin mb-3"></div>
|
||||||
<p class="text-sm font-medium text-[color:var(--fg)]">支付监控中…</p>
|
<p class="text-sm font-medium text-[color:var(--fg)]">支付监控中…</p>
|
||||||
<p class="text-xs text-[color:var(--fg-faint)] mt-1">已打开支付页面,完成后自动到账</p>
|
<p class="text-xs text-[color:var(--fg-faint)] mt-1">已打开支付页面,完成后自动到账</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -223,14 +223,14 @@ defineExpose({ startEdit, commitNote })
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.cv-node {
|
.cv-node {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid rgb(24 24 27 / 0.08);
|
||||||
contain: layout style;
|
contain: layout style;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
background: var(--surface-solid);
|
background: var(--surface-solid);
|
||||||
box-shadow: var(--glass-shadow);
|
box-shadow: 0 1px 2px rgb(24 24 27 / 0.05), 0 8px 24px -8px rgb(24 24 27 / 0.12);
|
||||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
.cv-node__clip {
|
.cv-node__clip {
|
||||||
@@ -240,8 +240,8 @@ defineExpose({ startEdit, commitNote })
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.cv-node.is-selected {
|
.cv-node.is-selected {
|
||||||
border-color: rgb(124 92 252 / 0.7);
|
border-color: #18181b;
|
||||||
box-shadow: var(--glass-shadow-lg), 0 0 0 3px rgb(124 92 252 / 0.25), 0 10px 30px var(--accent-glow);
|
box-shadow: 0 0 0 2px #18181b, 0 14px 34px -10px rgb(24 24 27 / 0.3);
|
||||||
}
|
}
|
||||||
.cv-node:active {
|
.cv-node:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
@@ -286,10 +286,10 @@ defineExpose({ startEdit, commitNote })
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 笔记 = 淡黄色贴纸 */
|
/* 笔记 = 白色卡片 */
|
||||||
.cv-node--note {
|
.cv-node--note {
|
||||||
background: #fdf6c9;
|
background: #fff;
|
||||||
border-color: rgb(180 140 20 / 0.35);
|
border-color: rgb(24 24 27 / 0.1);
|
||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
html.dark .cv-node--note {
|
html.dark .cv-node--note {
|
||||||
@@ -305,7 +305,7 @@ html.dark .cv-node--note {
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
color: #111;
|
color: #27272a;
|
||||||
outline: none;
|
outline: none;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -337,8 +337,8 @@ html.dark .cv-node--note {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(130% 130% at 15% 0%, rgb(124 92 252 / 0.14), transparent 55%),
|
radial-gradient(130% 130% at 15% 0%, rgb(24 24 27 / 0.14), transparent 55%),
|
||||||
radial-gradient(130% 130% at 85% 100%, rgb(34 211 238 / 0.12), transparent 55%),
|
radial-gradient(130% 130% at 85% 100%, rgb(24 24 27 / 0.12), transparent 55%),
|
||||||
var(--surface);
|
var(--surface);
|
||||||
}
|
}
|
||||||
.cv-node__gen::after {
|
.cv-node__gen::after {
|
||||||
@@ -371,7 +371,7 @@ html.dark .cv-node--note {
|
|||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 3px solid rgb(124 92 252 / 0.2);
|
border: 3px solid rgb(24 24 27 / 0.2);
|
||||||
border-top-color: var(--m-orange);
|
border-top-color: var(--m-orange);
|
||||||
border-right-color: var(--m-orange-2);
|
border-right-color: var(--m-orange-2);
|
||||||
animation: cv-spin 0.9s linear infinite;
|
animation: cv-spin 0.9s linear infinite;
|
||||||
@@ -390,7 +390,7 @@ html.dark .cv-node--note {
|
|||||||
max-height: 5em;
|
max-height: 5em;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,20 +404,26 @@ html.dark .cv-node--note {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
|
.cv-node:hover .cv-port,
|
||||||
|
.cv-node.is-selected .cv-port,
|
||||||
|
.cv-port.is-active,
|
||||||
|
.cv-port:focus-visible { opacity: 1; }
|
||||||
.cv-port::after {
|
.cv-port::after {
|
||||||
content: '';
|
content: '';
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 3px solid var(--m-orange);
|
border: 2px solid #18181b;
|
||||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.35), 0 0 0 2px rgb(255 255 255 / 0.8);
|
box-shadow: 0 1px 4px rgb(0 0 0 / 0.25);
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
.cv-port:hover::after, .cv-port:focus-visible::after, .cv-port.is-active::after {
|
.cv-port:hover::after, .cv-port:focus-visible::after, .cv-port.is-active::after {
|
||||||
transform: scale(1.3);
|
transform: scale(1.3);
|
||||||
box-shadow: 0 0 0 4px rgb(124 92 252 / 0.25), 0 2px 10px var(--accent-glow);
|
box-shadow: 0 0 0 4px rgb(24 24 27 / 0.25), 0 2px 10px var(--accent-glow);
|
||||||
}
|
}
|
||||||
.cv-port.is-active::after { background: var(--m-orange); }
|
.cv-port.is-active::after { background: var(--m-orange); }
|
||||||
.cv-port:focus-visible { outline: 2px solid var(--m-orange); outline-offset: -2px; border-radius: 50%; }
|
.cv-port:focus-visible { outline: 2px solid var(--m-orange); outline-offset: -2px; border-radius: 50%; }
|
||||||
|
|||||||
@@ -781,8 +781,8 @@ defineExpose({
|
|||||||
background-color: var(--app-bg);
|
background-color: var(--app-bg);
|
||||||
background-image: radial-gradient(
|
background-image: radial-gradient(
|
||||||
circle,
|
circle,
|
||||||
color-mix(in srgb, var(--fg) 16%, transparent) 1.4px,
|
color-mix(in srgb, var(--fg) 12%, transparent) 1.3px,
|
||||||
transparent 1.4px
|
transparent 1.3px
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
.cv-stage.is-panning {
|
.cv-stage.is-panning {
|
||||||
@@ -808,21 +808,18 @@ defineExpose({
|
|||||||
/* 常规连线:蓝色虚线;选中时改紫色实线加粗 */
|
/* 常规连线:蓝色虚线;选中时改紫色实线加粗 */
|
||||||
.cv-edge {
|
.cv-edge {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: rgb(95 108 248 / 0.85);
|
stroke: rgb(113 113 122 / 0.75);
|
||||||
stroke-width: 2.4;
|
stroke-width: 2;
|
||||||
stroke-dasharray: 10 6;
|
stroke-dasharray: 8 6;
|
||||||
filter: drop-shadow(0 0 5px rgb(124 92 252 / 0.3));
|
|
||||||
}
|
}
|
||||||
.cv-edge--hot {
|
.cv-edge--hot {
|
||||||
stroke: var(--m-orange);
|
stroke: var(--m-orange);
|
||||||
stroke-width: 2.8;
|
stroke-width: 2.6;
|
||||||
filter: drop-shadow(0 0 7px var(--accent-glow));
|
|
||||||
}
|
}
|
||||||
.cv-edge--sel {
|
.cv-edge--sel {
|
||||||
stroke: var(--m-orange);
|
stroke: var(--m-orange);
|
||||||
stroke-width: 3.5;
|
stroke-width: 3.5;
|
||||||
stroke-dasharray: none;
|
stroke-dasharray: none;
|
||||||
filter: drop-shadow(0 0 8px var(--accent-glow));
|
|
||||||
}
|
}
|
||||||
.cv-edge-hit {
|
.cv-edge-hit {
|
||||||
fill: none;
|
fill: none;
|
||||||
@@ -843,8 +840,8 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
.cv-marquee {
|
.cv-marquee {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border: 2px dashed rgb(124 92 252 / 0.5);
|
border: 2px dashed rgb(24 24 27 / 0.5);
|
||||||
background: rgb(124 92 252 / 0.08);
|
background: rgb(24 24 27 / 0.08);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useRoute } from 'vue-router'
|
|||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import Logo from '../components/Logo.vue'
|
import Logo from '../components/Logo.vue'
|
||||||
import { site } from '../site'
|
import { site } from '../site'
|
||||||
import { isDark, toggleTheme } from '../theme'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -90,12 +89,6 @@ watch(() => route.path, () => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="p-3 border-t border-t-[color:var(--ink-soft)] space-y-1">
|
<div class="p-3 border-t border-t-[color:var(--ink-soft)] 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"
|
<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">
|
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="spark" class="w-3.5 h-3.5" /> 用户端
|
||||||
@@ -106,16 +99,6 @@ watch(() => route.path, () => {
|
|||||||
|
|
||||||
<!-- ===== Main ===== -->
|
<!-- ===== Main ===== -->
|
||||||
<div class="flex-1 min-w-0 flex flex-col relative">
|
<div class="flex-1 min-w-0 flex flex-col relative">
|
||||||
<!-- aurora backdrop: drifting gradient orbs + faint grain -->
|
|
||||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
|
|
||||||
<div class="memphis-dots absolute inset-0 opacity-40"></div>
|
|
||||||
<div class="absolute -top-40 left-1/4 w-[40rem] h-[40rem] rounded-full opacity-[0.45]"
|
|
||||||
style="background: radial-gradient(circle, rgb(139 92 246 / 0.3), transparent 60%); filter: blur(110px)"></div>
|
|
||||||
<div class="absolute top-1/2 -right-48 w-[36rem] h-[36rem] rounded-full opacity-[0.4]"
|
|
||||||
style="background: radial-gradient(circle, rgb(34 211 238 / 0.24), transparent 60%); filter: blur(110px)"></div>
|
|
||||||
<div class="absolute bottom-0 -left-24 w-[32rem] h-[32rem] rounded-full opacity-[0.35]"
|
|
||||||
style="background: radial-gradient(circle, rgb(232 121 249 / 0.2), transparent 60%); filter: blur(110px)"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<header class="relative z-10 h-14 shrink-0 border-b border-b-[color:var(--ink-soft)] bg-[var(--app-bg)]/60 backdrop-blur-xl flex items-center px-8">
|
<header class="relative z-10 h-14 shrink-0 border-b border-b-[color:var(--ink-soft)] bg-[var(--app-bg)]/60 backdrop-blur-xl flex items-center px-8">
|
||||||
<div class="admin-badge text-[10px] uppercase tracking-[0.25em] font-extrabold mr-3 px-2.5 py-1 rounded-lg text-white select-none">Admin</div>
|
<div class="admin-badge text-[10px] uppercase tracking-[0.25em] font-extrabold mr-3 px-2.5 py-1 rounded-lg text-white select-none">Admin</div>
|
||||||
@@ -123,7 +106,7 @@ watch(() => route.path, () => {
|
|||||||
<h1 class="text-sm font-semibold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</h1>
|
<h1 class="text-sm font-semibold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main :class="['theme-text flex-1 overflow-y-auto overscroll-y-none relative z-10', { 'public-dark': isDark }]">
|
<main :class="['theme-text flex-1 overflow-y-auto overscroll-y-none relative z-10']">
|
||||||
<div class="px-8 py-7">
|
<div class="px-8 py-7">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="fade" mode="out-in">
|
<transition name="fade" mode="out-in">
|
||||||
@@ -138,13 +121,13 @@ watch(() => route.path, () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-tag {
|
.admin-tag {
|
||||||
background: linear-gradient(120deg, #7c5cfc, #22d3ee);
|
background: linear-gradient(120deg, #18181b, #52525b);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
.admin-badge {
|
.admin-badge {
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 60%, #22d3ee 140%);
|
background: linear-gradient(120deg, #18181b, #27272a 60%, #52525b 140%);
|
||||||
box-shadow: 0 4px 14px var(--accent-glow);
|
box-shadow: 0 4px 14px var(--accent-glow);
|
||||||
}
|
}
|
||||||
.admin-link {
|
.admin-link {
|
||||||
@@ -159,10 +142,10 @@ watch(() => route.path, () => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
.admin-link:hover { background: var(--hover); color: var(--fg); border-color: rgb(124 92 252 / 0.3); }
|
.admin-link:hover { background: var(--hover); color: var(--fg); border-color: rgb(24 24 27 / 0.3); }
|
||||||
.admin-link.active {
|
.admin-link.active {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
background: linear-gradient(120deg, #18181b, #27272a);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
box-shadow: 0 4px 14px var(--accent-glow);
|
box-shadow: 0 4px 14px var(--accent-glow);
|
||||||
}
|
}
|
||||||
@@ -178,8 +161,8 @@ watch(() => route.path, () => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
.admin-sublink:hover { background: var(--hover); color: var(--fg); border-color: rgb(124 92 252 / 0.3); }
|
.admin-sublink:hover { background: var(--hover); color: var(--fg); border-color: rgb(24 24 27 / 0.3); }
|
||||||
.admin-sublink.active { color: var(--m-orange); background: var(--hover); border-color: rgb(124 92 252 / 0.35); font-weight: 700; }
|
.admin-sublink.active { color: var(--m-orange); background: var(--hover); border-color: rgb(24 24 27 / 0.35); font-weight: 700; }
|
||||||
|
|
||||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
|
||||||
.fade-enter-from { opacity: 0; transform: translateY(4px); }
|
.fade-enter-from { opacity: 0; transform: translateY(4px); }
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { auth, isAuthed, isAdmin, openLogin, openRegister, logout } from '../auth'
|
import { auth, isAuthed, isAdmin, openLogin, openRegister, logout } from '../auth'
|
||||||
import { isDark, toggleTheme } from '../theme'
|
|
||||||
import { site } from '../site'
|
import { site } from '../site'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import Logo from '../components/Logo.vue'
|
import Logo from '../components/Logo.vue'
|
||||||
@@ -39,6 +38,12 @@ const menuOpen = ref(false) // avatar dropdown
|
|||||||
const mobileOpen = ref(false) // hamburger panel
|
const mobileOpen = ref(false) // hamburger panel
|
||||||
watch(() => route.fullPath, () => { menuOpen.value = false; mobileOpen.value = false })
|
watch(() => route.fullPath, () => { menuOpen.value = false; mobileOpen.value = false })
|
||||||
|
|
||||||
|
// 悬浮胶囊顶栏:静止时浮在页面上方(999px 圆角),滚动后展开为通栏。
|
||||||
|
const scrolled = ref(false)
|
||||||
|
function onScroll() { scrolled.value = window.scrollY > 4 }
|
||||||
|
onMounted(() => { onScroll(); window.addEventListener('scroll', onScroll, { passive: true }) })
|
||||||
|
onUnmounted(() => window.removeEventListener('scroll', onScroll))
|
||||||
|
|
||||||
// credits — the logged-in user's real server-side balance (auth.user.credits)
|
// credits — the logged-in user's real server-side balance (auth.user.credits)
|
||||||
const credits = computed(() => Number(auth.user?.credits || 0))
|
const credits = computed(() => Number(auth.user?.credits || 0))
|
||||||
const creditsLabel = computed(() => pointsLabel(credits.value))
|
const creditsLabel = computed(() => pointsLabel(credits.value))
|
||||||
@@ -70,10 +75,11 @@ async function onLogout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="theme-x min-h-screen bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-orange-400/30">
|
<div class="theme-x min-h-screen bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-orange-400/30"
|
||||||
<!-- ===== Top bar ===== -->
|
:class="{ 'home-bg': isHome }">
|
||||||
<header class="fixed top-0 inset-x-0 z-40 backdrop-blur-xl top-bar">
|
<!-- ===== Top bar:悬浮胶囊,滚动展开为通栏 ===== -->
|
||||||
<div class="mx-auto max-w-[1400px] px-4 md:px-8 h-[58px] flex items-center gap-3">
|
<header class="top-nav fixed top-0 inset-x-0 z-40" :class="{ scrolled }">
|
||||||
|
<div class="nav-inner mx-auto flex items-center gap-3">
|
||||||
<!-- brand -->
|
<!-- brand -->
|
||||||
<router-link to="/" class="flex items-center gap-2.5 shrink-0 transition-transform hover:scale-[1.03]">
|
<router-link to="/" class="flex items-center gap-2.5 shrink-0 transition-transform hover:scale-[1.03]">
|
||||||
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="w-8 h-8 object-contain" />
|
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="w-8 h-8 object-contain" />
|
||||||
@@ -98,13 +104,6 @@ async function onLogout() {
|
|||||||
余额 <span class="text-[color:var(--fg)] font-semibold">{{ creditsLabel }}</span>
|
余额 <span class="text-[color:var(--fg)] font-semibold">{{ creditsLabel }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<!-- theme -->
|
|
||||||
<button type="button" @click="toggleTheme" class="round-btn"
|
|
||||||
:title="isDark ? '切换到亮色' : '切换到暗色'">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 公告 -->
|
<!-- 公告 -->
|
||||||
<button v-if="isAuthed() && announcement.content.trim()" type="button"
|
<button v-if="isAuthed() && announcement.content.trim()" type="button"
|
||||||
@click="openAnnouncement" class="round-btn" title="公告">
|
@click="openAnnouncement" class="round-btn" title="公告">
|
||||||
@@ -118,10 +117,7 @@ async function onLogout() {
|
|||||||
|
|
||||||
<!-- guest actions -->
|
<!-- guest actions -->
|
||||||
<template v-if="!isAuthed()">
|
<template v-if="!isAuthed()">
|
||||||
<button type="button" class="hidden sm:inline-flex top-link" @click="openLogin('/user')">登录</button>
|
<button type="button" class="login-pill" @click="openLogin('/user')">登录 / 注册</button>
|
||||||
<button type="button" class="solid-btn" @click="openRegister('', '/user')">
|
|
||||||
免费开始 <span aria-hidden="true">→</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- signed-in: avatar menu -->
|
<!-- signed-in: avatar menu -->
|
||||||
@@ -145,7 +141,7 @@ async function onLogout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- mobile panel -->
|
<!-- mobile panel -->
|
||||||
<div v-if="mobileOpen" class="md:hidden border-t border-[color:var(--ink-soft)] px-3 py-3 bg-[var(--menu-bg)]">
|
<div v-if="mobileOpen" class="mobile-panel md:hidden px-3 py-3">
|
||||||
<router-link v-for="n in nav" :key="n.to" :to="n.to"
|
<router-link v-for="n in nav" :key="n.to" :to="n.to"
|
||||||
:exact-active-class="n.to === '/' ? 'm-on' : ''"
|
:exact-active-class="n.to === '/' ? 'm-on' : ''"
|
||||||
:active-class="n.to === '/' ? '' : 'm-on'"
|
:active-class="n.to === '/' ? '' : 'm-on'"
|
||||||
@@ -167,26 +163,14 @@ async function onLogout() {
|
|||||||
|
|
||||||
<!-- ===== Main column ===== -->
|
<!-- ===== Main column ===== -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<!-- aurora backdrop: drifting gradient orbs + faint grain -->
|
|
||||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
|
|
||||||
<div class="memphis-dots absolute inset-0 opacity-50"></div>
|
|
||||||
<div class="absolute -top-40 left-1/4 w-[42rem] h-[42rem] rounded-full opacity-[0.5]"
|
|
||||||
style="background: radial-gradient(circle, rgb(139 92 246 / 0.35), transparent 60%); filter: blur(100px)"></div>
|
|
||||||
<div class="absolute top-1/3 -right-48 w-[38rem] h-[38rem] rounded-full opacity-[0.45]"
|
|
||||||
style="background: radial-gradient(circle, rgb(34 211 238 / 0.3), transparent 60%); filter: blur(100px)"></div>
|
|
||||||
<div class="absolute bottom-0 -left-24 w-[34rem] h-[34rem] rounded-full opacity-[0.4]"
|
|
||||||
style="background: radial-gradient(circle, rgb(232 121 249 / 0.28), transparent 60%); filter: blur(100px)"></div>
|
|
||||||
<div class="anime-stars"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Route label header — the home page brings its own hero instead. -->
|
<!-- Route label header — the home page brings its own hero instead. -->
|
||||||
<header v-if="!isHome" class="relative z-10 mx-auto max-w-[1400px] px-4 md:px-8 pt-[86px] pb-4">
|
<header v-if="!isHome" class="relative z-10 mx-auto max-w-[1400px] px-4 md:px-8 pt-[104px] pb-4">
|
||||||
<span class="text-[22px] font-bold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</span>
|
<span class="text-[22px] font-bold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main :class="['relative z-10 mx-auto max-w-[1400px] pb-24',
|
<main :class="['relative z-10 mx-auto max-w-[1400px] pb-24',
|
||||||
isHome ? 'pt-[58px] px-0' : 'px-4 md:px-8 pt-2',
|
isHome ? 'pt-0 px-0' : 'px-4 md:px-8 pt-2',]">
|
||||||
{ 'public-dark': isDark }]">
|
|
||||||
<router-view v-slot="{ Component, route: viewRoute }">
|
<router-view v-slot="{ Component, route: viewRoute }">
|
||||||
<component :is="Component" v-if="viewRoute.path === '/canvas'" />
|
<component :is="Component" v-if="viewRoute.path === '/canvas'" />
|
||||||
<transition v-else name="fade" mode="out-in">
|
<transition v-else name="fade" mode="out-in">
|
||||||
@@ -199,30 +183,68 @@ async function onLogout() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.top-bar {
|
/* 悬浮胶囊导航:不压满宽黑线,毛玻璃胶囊浮在页面上方;滚动后展开为通栏 */
|
||||||
background: color-mix(in srgb, var(--app-bg) 62%, transparent);
|
.top-nav {
|
||||||
|
padding-top: 10px;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: padding-top 0.22s ease;
|
||||||
|
}
|
||||||
|
.nav-inner {
|
||||||
|
pointer-events: auto;
|
||||||
|
height: 64px;
|
||||||
|
width: min(1200px, calc(100% - 40px));
|
||||||
|
padding: 0 14px 0 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--menu-bg);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.7);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.7);
|
||||||
|
border: 1px solid var(--ink-soft);
|
||||||
|
box-shadow: 0 8px 30px -14px rgb(15 23 42 / 0.18);
|
||||||
|
transition: width 0.25s ease, border-radius 0.25s ease, border-color 0.25s ease,
|
||||||
|
box-shadow 0.25s ease, background 0.25s ease;
|
||||||
|
}
|
||||||
|
.top-nav.scrolled { padding-top: 0; }
|
||||||
|
.top-nav.scrolled .nav-inner {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
border-color: transparent;
|
||||||
border-bottom: 1px solid var(--ink-soft);
|
border-bottom: 1px solid var(--ink-soft);
|
||||||
transition: border-color 0.2s ease;
|
box-shadow: 0 10px 30px -12px rgb(15 23 42 / 0.12);
|
||||||
|
}
|
||||||
|
html.dark .nav-inner { box-shadow: 0 8px 30px -14px rgb(0 0 0 / 0.5); }
|
||||||
|
|
||||||
|
.mobile-panel {
|
||||||
|
pointer-events: auto;
|
||||||
|
width: min(1200px, calc(100% - 32px));
|
||||||
|
margin: 6px auto 0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--menu-bg);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border: 1px solid var(--ink-soft);
|
||||||
|
box-shadow: var(--glass-shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-link {
|
.top-link {
|
||||||
padding: 0.45rem 0.8rem;
|
padding: 0.55rem 1.1rem;
|
||||||
border-radius: 0.6rem;
|
border-radius: 999px;
|
||||||
font-size: 13.5px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--fg-2);
|
color: var(--fg-2);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
transition: color 0.2s ease, background 0.2s ease, transform 0.15s ease;
|
||||||
}
|
}
|
||||||
.top-link:hover { color: var(--fg); background: var(--hover); border-color: rgb(124 92 252 / 0.3); }
|
.top-link:active { transform: scale(0.95); }
|
||||||
|
.top-link:hover { color: var(--fg); background: var(--hover); }
|
||||||
|
.home-bg .top-link.on { color: #18181b; background: rgba(24, 24, 27, 0.07); }
|
||||||
|
.home-bg .brand-spark { display: none; }
|
||||||
.top-link.on {
|
.top-link.on {
|
||||||
color: #fff;
|
color: var(--m-orange);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
background: rgb(24 24 27 / 0.12);
|
||||||
border-color: transparent;
|
|
||||||
box-shadow: 0 4px 14px var(--accent-glow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-spark {
|
.brand-spark {
|
||||||
@@ -249,11 +271,11 @@ async function onLogout() {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
.m-link:hover { color: var(--fg); background: var(--hover); border-color: rgb(124 92 252 / 0.3); }
|
.m-link:hover { color: var(--fg); background: var(--hover); border-color: rgb(24 24 27 / 0.3); }
|
||||||
.m-link.m-on {
|
.m-link.m-on {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
background: linear-gradient(120deg, #18181b, #27272a);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
box-shadow: 0 4px 14px var(--accent-glow);
|
box-shadow: 0 4px 14px var(--accent-glow);
|
||||||
}
|
}
|
||||||
@@ -271,7 +293,21 @@ async function onLogout() {
|
|||||||
border: 1px solid var(--ink-soft);
|
border: 1px solid var(--ink-soft);
|
||||||
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
|
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
.round-btn:hover { color: var(--fg); background: var(--hover); border-color: rgb(124 92 252 / 0.4); box-shadow: 0 4px 14px rgb(124 92 252 / 0.16); transform: translateY(-1px); }
|
.round-btn:hover { color: var(--fg); background: var(--hover); border-color: rgb(24 24 27 / 0.4); box-shadow: 0 4px 14px rgb(24 24 27 / 0.16); transform: translateY(-1px); }
|
||||||
|
/* 首页整页淡紫渐变底(铺满整个页面高度) */
|
||||||
|
.home-bg {
|
||||||
|
background: #fafaf8 !important;
|
||||||
|
}
|
||||||
|
/* 游客登录入口:vivid 同款渐变胶囊 */
|
||||||
|
.login-pill {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: 8px 18px; border: none; border-radius: 999px;
|
||||||
|
background: #18181b;
|
||||||
|
color: #fff; font-family: inherit; font-size: 13px; font-weight: 700;
|
||||||
|
cursor: pointer; box-shadow: 0 8px 20px -8px rgba(9, 9, 11, 0.5);
|
||||||
|
transition: transform 0.15s ease, filter 0.2s ease;
|
||||||
|
}
|
||||||
|
.login-pill:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
||||||
.round-btn:active { transform: scale(0.95) translateY(1px); box-shadow: none; }
|
.round-btn:active { transform: scale(0.95) translateY(1px); box-shadow: none; }
|
||||||
|
|
||||||
/* mobile-only: .round-btn's display:grid outranks Tailwind's md:hidden, so
|
/* mobile-only: .round-btn's display:grid outranks Tailwind's md:hidden, so
|
||||||
@@ -287,7 +323,7 @@ async function onLogout() {
|
|||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
|
background: linear-gradient(120deg, #18181b, #27272a 55%, #52525b 140%);
|
||||||
border: 1px solid rgb(255 255 255 / 0.16);
|
border: 1px solid rgb(255 255 255 / 0.16);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 6px 18px var(--accent-glow);
|
box-shadow: 0 6px 18px var(--accent-glow);
|
||||||
@@ -316,8 +352,8 @@ async function onLogout() {
|
|||||||
}
|
}
|
||||||
.name-btn:hover {
|
.name-btn:hover {
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
border-color: rgb(124 92 252 / 0.4);
|
border-color: rgb(24 24 27 / 0.4);
|
||||||
box-shadow: 0 4px 14px rgb(124 92 252 / 0.16);
|
box-shadow: 0 4px 14px rgb(24 24 27 / 0.16);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
.name-txt {
|
.name-txt {
|
||||||
|
|||||||
@@ -9,34 +9,34 @@ video {
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
--tw-ring-color: rgb(124 92 252 / 0.25);
|
--tw-ring-color: rgb(24 24 27 / 0.25);
|
||||||
|
|
||||||
/* ===== Theme palette — LIGHT (default): frosted glass / aurora =====
|
/* ===== Theme palette — LIGHT (default): frosted glass / aurora =====
|
||||||
Drives the app chrome (rails/header) and the public marketing pages.
|
Drives the app chrome (rails/header) and the public marketing pages.
|
||||||
The `.dark` class on <html> flips these to the dark values below.
|
The `.dark` class on <html> flips these to the dark values below.
|
||||||
Text over generated images keeps a literal text-white (handled in markup). */
|
Text over generated images keeps a literal text-white (handled in markup). */
|
||||||
--app-bg: #eef0f8; /* cool porcelain */
|
--app-bg: #fafaf8; /* warm paper */
|
||||||
--fg: #181524; /* primary heading/text */
|
--fg: #181524; /* primary heading/text */
|
||||||
--fg-2: rgb(24 21 36 / 0.66); /* body / secondary */
|
--fg-2: rgb(24 21 36 / 0.66); /* body / secondary */
|
||||||
--fg-3: rgb(24 21 36 / 0.48); /* muted */
|
--fg-3: rgb(24 21 36 / 0.48); /* muted */
|
||||||
--fg-faint: rgb(24 21 36 / 0.36); /* labels / hints */
|
--fg-faint: rgb(24 21 36 / 0.36); /* labels / hints */
|
||||||
--surface: rgb(255 255 255 / 0.66); /* frosted card on page bg */
|
--surface: rgb(255 255 255 / 0.9); /* frosted card on page bg */
|
||||||
--surface-solid: #ffffff; /* opaque version where blur is unwanted */
|
--surface-solid: #ffffff; /* opaque version where blur is unwanted */
|
||||||
--surface-2: rgb(24 21 36 / 0.045); /* subtle inset panel (counter cells) */
|
--surface-2: rgb(24 21 36 / 0.045); /* subtle inset panel (counter cells) */
|
||||||
--hairline: rgb(24 21 36 / 0.1); /* faint borders / separators */
|
--hairline: rgb(24 21 36 / 0.1); /* faint borders / separators */
|
||||||
--hover: rgb(124 92 252 / 0.1); /* hover wash — violet tint */
|
--hover: rgb(24 24 27 / 0.06); /* hover wash — violet tint */
|
||||||
/* ink = the hairline edge color; shadow-ink = the soft shadow tint.
|
/* ink = the hairline edge color; shadow-ink = the soft shadow tint.
|
||||||
Kept under their legacy names so existing markup keeps working — only the
|
Kept under their legacy names so existing markup keeps working — only the
|
||||||
values changed from memphis-solid to glass-subtle. */
|
values changed from memphis-solid to glass-subtle. */
|
||||||
--ink: rgb(24 21 36 / 0.14);
|
--ink: rgb(24 21 36 / 0.14);
|
||||||
--ink-soft: rgb(24 21 36 / 0.09);
|
--ink-soft: rgb(24 21 36 / 0.09);
|
||||||
--shadow-ink: rgb(88 80 200 / 0.14);
|
--shadow-ink: rgb(24 24 27 / 0.12);
|
||||||
/* aurora accent crayons — primary violet + soft pastel companions */
|
/* aurora accent crayons — primary violet + soft pastel companions */
|
||||||
--m-orange: #7c5cfc;
|
--m-orange: #18181b;
|
||||||
--m-orange-2: #22d3ee;
|
--m-orange-2: #52525b;
|
||||||
--m-yellow: #ece7fe;
|
--m-yellow: #f4f4f5;
|
||||||
--m-pink: #fad9f0;
|
--m-pink: #f4f4f5;
|
||||||
--m-blue: #d8eefb;
|
--m-blue: #f4f4f5;
|
||||||
--m-mint: #d9f4e8;
|
--m-mint: #d9f4e8;
|
||||||
/* solid CTA button that sits on the page bg — inverts per theme */
|
/* solid CTA button that sits on the page bg — inverts per theme */
|
||||||
--btn-solid-bg: #181524;
|
--btn-solid-bg: #181524;
|
||||||
@@ -45,9 +45,9 @@ video {
|
|||||||
--menu-bg: rgb(255 255 255 / 0.82); /* dropdown / popover panel */
|
--menu-bg: rgb(255 255 255 / 0.82); /* dropdown / popover panel */
|
||||||
/* shared glass treatments */
|
/* shared glass treatments */
|
||||||
--glass-border: rgb(255 255 255 / 0.55);
|
--glass-border: rgb(255 255 255 / 0.55);
|
||||||
--glass-shadow: 0 8px 28px rgb(80 74 190 / 0.1), 0 2px 8px rgb(24 21 36 / 0.05);
|
--glass-shadow: 0 8px 28px rgb(24 24 27 / 0.07), 0 2px 8px rgb(24 21 36 / 0.04);
|
||||||
--glass-shadow-lg: 0 16px 44px rgb(80 74 190 / 0.16), 0 4px 12px rgb(24 21 36 / 0.06);
|
--glass-shadow-lg: 0 16px 44px rgb(24 24 27 / 0.12), 0 4px 12px rgb(24 21 36 / 0.05);
|
||||||
--accent-glow: rgb(124 92 252 / 0.35);
|
--accent-glow: rgb(24 24 27 / 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark {
|
html.dark {
|
||||||
@@ -60,24 +60,24 @@ html.dark {
|
|||||||
--surface-solid: #14131f;
|
--surface-solid: #14131f;
|
||||||
--surface-2: rgb(255 255 255 / 0.05);
|
--surface-2: rgb(255 255 255 / 0.05);
|
||||||
--hairline: rgb(255 255 255 / 0.1);
|
--hairline: rgb(255 255 255 / 0.1);
|
||||||
--hover: rgb(139 92 246 / 0.16);
|
--hover: rgb(24 24 27 / 0.16);
|
||||||
--ink: rgb(255 255 255 / 0.14);
|
--ink: rgb(255 255 255 / 0.14);
|
||||||
--ink-soft: rgb(255 255 255 / 0.09);
|
--ink-soft: rgb(255 255 255 / 0.09);
|
||||||
--shadow-ink: rgb(0 0 0 / 0.4);
|
--shadow-ink: rgb(0 0 0 / 0.4);
|
||||||
--m-orange: #8b5cf6;
|
--m-orange: #27272a;
|
||||||
--m-orange-2: #22d3ee;
|
--m-orange-2: #52525b;
|
||||||
--m-yellow: #a78bfa;
|
--m-yellow: #71717a;
|
||||||
--m-pink: #f0a6d8;
|
--m-pink: #f0a6d8;
|
||||||
--m-blue: #6cd0f2;
|
--m-blue: #6cd0f2;
|
||||||
--m-mint: #5fd9ac;
|
--m-mint: #5fd9ac;
|
||||||
--btn-solid-bg: #8b5cf6;
|
--btn-solid-bg: #27272a;
|
||||||
--btn-solid-bg-h: #7c3aed;
|
--btn-solid-bg-h: #18181b;
|
||||||
--btn-solid-fg: #fff;
|
--btn-solid-fg: #fff;
|
||||||
--menu-bg: rgb(24 23 36 / 0.86);
|
--menu-bg: rgb(24 23 36 / 0.86);
|
||||||
--glass-border: rgb(255 255 255 / 0.12);
|
--glass-border: rgb(255 255 255 / 0.12);
|
||||||
--glass-shadow: 0 8px 28px rgb(0 0 0 / 0.35), 0 2px 8px rgb(0 0 0 / 0.2);
|
--glass-shadow: 0 8px 28px rgb(0 0 0 / 0.35), 0 2px 8px rgb(0 0 0 / 0.2);
|
||||||
--glass-shadow-lg: 0 18px 50px rgb(0 0 0 / 0.5), 0 4px 14px rgb(0 0 0 / 0.3);
|
--glass-shadow-lg: 0 18px 50px rgb(0 0 0 / 0.5), 0 4px 14px rgb(0 0 0 / 0.3);
|
||||||
--accent-glow: rgb(139 92 246 / 0.45);
|
--accent-glow: rgb(24 24 27 / 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth the flip so toggling doesn't hard-cut. */
|
/* Smooth the flip so toggling doesn't hard-cut. */
|
||||||
@@ -87,16 +87,16 @@ body, .theme-x { transition: background-color 0.25s ease, color 0.25s ease; }
|
|||||||
.cta-band {
|
.cta-band {
|
||||||
position: relative;
|
position: relative;
|
||||||
background:
|
background:
|
||||||
radial-gradient(120% 160% at 0% 0%, rgb(124 92 252 / 0.2), transparent 55%),
|
radial-gradient(120% 160% at 0% 0%, rgb(24 24 27 / 0.2), transparent 55%),
|
||||||
radial-gradient(120% 160% at 100% 100%, rgb(34 211 238 / 0.16), transparent 55%),
|
radial-gradient(120% 160% at 100% 100%, rgb(24 24 27 / 0.16), transparent 55%),
|
||||||
var(--surface);
|
var(--surface);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
box-shadow: var(--glass-shadow-lg);
|
box-shadow: var(--glass-shadow-lg);
|
||||||
}
|
}
|
||||||
html.dark .cta-band {
|
html.dark .cta-band {
|
||||||
background:
|
background:
|
||||||
radial-gradient(120% 160% at 0% 0%, rgb(139 92 246 / 0.22), transparent 55%),
|
radial-gradient(120% 160% at 0% 0%, rgb(24 24 27 / 0.22), transparent 55%),
|
||||||
radial-gradient(120% 160% at 100% 100%, rgb(34 211 238 / 0.14), transparent 55%),
|
radial-gradient(120% 160% at 100% 100%, rgb(24 24 27 / 0.14), transparent 55%),
|
||||||
rgb(255 255 255 / 0.04);
|
rgb(255 255 255 / 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ html:not(.dark) .theme-text :is([class*="bg-slate-900"], [class*="bg-slate-800"]
|
|||||||
shell and washes out on white. In LIGHT mode, darken to the -600 tone. The
|
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
|
matcher also catches the /80 opacity variants. Badges keep their pale tinted
|
||||||
bg (bg-*-500/10) which reads fine on white. */
|
bg (bg-*-500/10) which reads fine on white. */
|
||||||
html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(79 70 229); }
|
html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(24 24 27); }
|
||||||
html:not(.dark) .theme-text :is([class*="text-orange-300"], [class*="text-orange-200"]) { color: rgb(124 58 237); }
|
html:not(.dark) .theme-text :is([class*="text-orange-300"], [class*="text-orange-200"]) { color: rgb(124 58 237); }
|
||||||
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-rose-300"], [class*="text-rose-200"]) { color: rgb(225 29 72); }
|
||||||
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-emerald-300"], [class*="text-emerald-200"]) { color: rgb(5 150 105); }
|
||||||
@@ -144,7 +144,7 @@ html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset
|
|||||||
color: var(--fg); background: var(--hover);
|
color: var(--fg); background: var(--hover);
|
||||||
}
|
}
|
||||||
html:not(.dark) .theme-text :is(.fp-on, .pg-on, .seg-on, .opt-on) {
|
html:not(.dark) .theme-text :is(.fp-on, .pg-on, .seg-on, .opt-on) {
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8); color: #fff;
|
background: linear-gradient(120deg, #18181b, #27272a); color: #fff;
|
||||||
box-shadow: 0 4px 16px var(--accent-glow);
|
box-shadow: 0 4px 16px var(--accent-glow);
|
||||||
}
|
}
|
||||||
/* Tag chips (TagInput) — near-white orange text is unreadable on white; keep
|
/* Tag chips (TagInput) — near-white orange text is unreadable on white; keep
|
||||||
@@ -157,7 +157,7 @@ html:not(.dark) .theme-text .chip .chip-x:hover { color: #fff; background: rgb(2
|
|||||||
neutral `.filter-pill`/`.kind-btn` rescue paints selected == unselected and
|
neutral `.filter-pill`/`.kind-btn` rescue paints selected == unselected and
|
||||||
the highlight vanishes on a white page. Equal-or-higher specificity, later. */
|
the highlight vanishes on a white page. Equal-or-higher specificity, later. */
|
||||||
html:not(.dark) .theme-text :is(.filter-pill, .kind-btn).on {
|
html:not(.dark) .theme-text :is(.filter-pill, .kind-btn).on {
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8); color: #fff;
|
background: linear-gradient(120deg, #18181b, #27272a); color: #fff;
|
||||||
box-shadow: 0 4px 16px var(--accent-glow);
|
box-shadow: 0 4px 16px var(--accent-glow);
|
||||||
}
|
}
|
||||||
/* Selected COLORED filter pills — their scoped (dark) selected colors get
|
/* Selected COLORED filter pills — their scoped (dark) selected colors get
|
||||||
@@ -167,7 +167,7 @@ html:not(.dark) .theme-text :is(.filter-pill, .kind-btn).on {
|
|||||||
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-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-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-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-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-orange { 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-orange { background: rgb(24 24 27 / 0.14); color: rgb(24 24 27); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.45); }
|
||||||
html:not(.dark) .theme-text .fp-pink { background: rgb(244 114 182 / 0.14); color: rgb(190 24 93); box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.45); }
|
html:not(.dark) .theme-text .fp-pink { background: rgb(244 114 182 / 0.14); color: rgb(190 24 93); box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.45); }
|
||||||
html:not(.dark) .theme-text .fp-slate { background: rgb(100 116 139 / 0.14); color: rgb(51 65 85); box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.45); }
|
html:not(.dark) .theme-text .fp-slate { background: rgb(100 116 139 / 0.14); color: rgb(51 65 85); box-shadow: inset 0 0 0 1px rgb(100 116 139 / 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-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); }
|
||||||
@@ -193,7 +193,7 @@ html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color
|
|||||||
.chk { border-color: var(--ink); }
|
.chk { border-color: var(--ink); }
|
||||||
.chk:hover:not(:checked) { border-color: var(--m-orange); }
|
.chk:hover:not(:checked) { border-color: var(--m-orange); }
|
||||||
.chk:checked {
|
.chk:checked {
|
||||||
background: linear-gradient(135deg, #7c5cfc, #22d3ee);
|
background: linear-gradient(135deg, #18181b, #52525b);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
box-shadow: 0 2px 10px var(--accent-glow);
|
box-shadow: 0 2px 10px var(--accent-glow);
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color
|
|||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
margin-top: -0.12rem;
|
margin-top: -0.12rem;
|
||||||
}
|
}
|
||||||
.chk:focus-visible { outline: 2px solid rgb(124 92 252 / 0.5); outline-offset: 1px; }
|
.chk:focus-visible { outline: 2px solid rgb(24 24 27 / 0.5); outline-offset: 1px; }
|
||||||
/* Account identity tags (AccountsView: 子号 / 母号 / 普号 / 限额). The colors live
|
/* Account identity tags (AccountsView: 子号 / 母号 / 普号 / 限额). The colors live
|
||||||
here rather than as inline dark-palette utilities, which were invisible on the
|
here rather than as inline dark-palette utilities, which were invisible on the
|
||||||
light theme: light values first, html.dark override after. */
|
light theme: light values first, html.dark override after. */
|
||||||
@@ -222,11 +222,11 @@ html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.acct-tag-sub { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.4); }
|
.acct-tag-sub { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.4); }
|
||||||
.acct-tag-master { background: rgb(139 92 246 / 0.14); color: rgb(109 40 217); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.4); }
|
.acct-tag-master { background: rgb(24 24 27 / 0.14); color: rgb(24 24 27); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.4); }
|
||||||
.acct-tag-free { background: rgb(100 116 139 / 0.14); color: rgb(51 65 85); box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.4); }
|
.acct-tag-free { background: rgb(100 116 139 / 0.14); color: rgb(51 65 85); box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.4); }
|
||||||
.acct-tag-limit { background: rgb(245 158 11 / 0.16); color: rgb(180 83 9); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.4); }
|
.acct-tag-limit { background: rgb(245 158 11 / 0.16); color: rgb(180 83 9); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.4); }
|
||||||
html.dark .acct-tag-sub { background: rgb(14 165 233 / 0.18); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.35); }
|
html.dark .acct-tag-sub { background: rgb(14 165 233 / 0.18); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.35); }
|
||||||
html.dark .acct-tag-master { background: rgb(139 92 246 / 0.22); color: rgb(196 181 253); box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.35); }
|
html.dark .acct-tag-master { background: rgb(24 24 27 / 0.22); color: rgb(161 161 170); box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.35); }
|
||||||
html.dark .acct-tag-free { background: rgb(148 163 184 / 0.22); color: rgb(226 232 240); box-shadow: inset 0 0 0 1px rgb(148 163 184 / 0.35); }
|
html.dark .acct-tag-free { background: rgb(148 163 184 / 0.22); color: rgb(226 232 240); box-shadow: inset 0 0 0 1px rgb(148 163 184 / 0.35); }
|
||||||
html.dark .acct-tag-limit { background: rgb(245 158 11 / 0.18); color: rgb(252 211 77); box-shadow: inset 0 0 0 1px rgb(251 191 36 / 0.35); }
|
html.dark .acct-tag-limit { background: rgb(245 158 11 / 0.18); color: rgb(252 211 77); box-shadow: inset 0 0 0 1px rgb(251 191 36 / 0.35); }
|
||||||
/* Capability chips (ModelsView): all variants need bg + ring + color reset in light mode.
|
/* Capability chips (ModelsView): all variants need bg + ring + color reset in light mode.
|
||||||
@@ -283,7 +283,7 @@ html:not(.dark) .media-card :is([class*="border-white/"], [class*="ring-white/"]
|
|||||||
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-emerald-3"], [class*="text-emerald-2"]) { color: rgb(110 231 183) !important; }
|
||||||
html:not(.dark) .media-card :is([class*="text-rose-3"], [class*="text-rose-2"]) { color: rgb(251 207 232) !important; }
|
html:not(.dark) .media-card :is([class*="text-rose-3"], [class*="text-rose-2"]) { color: rgb(251 207 232) !important; }
|
||||||
html:not(.dark) .media-card :is([class*="text-sky-3"], [class*="text-sky-2"]) { color: rgb(147 197 253) !important; }
|
html:not(.dark) .media-card :is([class*="text-sky-3"], [class*="text-sky-2"]) { color: rgb(147 197 253) !important; }
|
||||||
html:not(.dark) .media-card :is([class*="text-orange-3"], [class*="text-orange-2"]) { color: rgb(196 181 253) !important; }
|
html:not(.dark) .media-card :is([class*="text-orange-3"], [class*="text-orange-2"]) { color: rgb(161 161 170) !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-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; }
|
html:not(.dark) .media-card :is([class*="text-amber-3"], [class*="text-amber-2"]) { color: rgb(252 211 77) !important; }
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ body {
|
|||||||
/* Aurora ambient gradient + grain (used by PublicLayout via .ambient-bg) */
|
/* Aurora ambient gradient + grain (used by PublicLayout via .ambient-bg) */
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection { background: rgb(139 92 246 / 0.32); color: inherit; }
|
::selection { background: rgb(24 24 27 / 0.32); color: inherit; }
|
||||||
html:not(.dark) ::selection { color: #181524; }
|
html:not(.dark) ::selection { color: #181524; }
|
||||||
|
|
||||||
/* Tailwind v4 / preflight leaves <button> on the default arrow cursor, so
|
/* Tailwind v4 / preflight leaves <button> on the default arrow cursor, so
|
||||||
@@ -335,18 +335,18 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
|
|||||||
over-media tables are untouched; specificity outranks the per-view
|
over-media tables are untouched; specificity outranks the per-view
|
||||||
Tailwind hover utilities and the light-mode white rescue. */
|
Tailwind hover utilities and the light-mode white rescue. */
|
||||||
.theme-text .card table thead {
|
.theme-text .card table thead {
|
||||||
background: linear-gradient(180deg, rgb(124 92 252 / 0.07), rgb(124 92 252 / 0.025));
|
background: linear-gradient(180deg, rgb(24 24 27 / 0.07), rgb(24 24 27 / 0.025));
|
||||||
}
|
}
|
||||||
html.dark .theme-text .card table thead {
|
html.dark .theme-text .card table thead {
|
||||||
background: linear-gradient(180deg, rgb(139 92 246 / 0.13), rgb(139 92 246 / 0.05));
|
background: linear-gradient(180deg, rgb(24 24 27 / 0.13), rgb(24 24 27 / 0.05));
|
||||||
}
|
}
|
||||||
.theme-text .card table tbody tr { transition: background-color 0.15s ease; }
|
.theme-text .card table tbody tr { transition: background-color 0.15s ease; }
|
||||||
.theme-text .card table tbody tr:hover { background-color: rgb(124 92 252 / 0.08); }
|
.theme-text .card table tbody tr:hover { background-color: rgb(24 24 27 / 0.08); }
|
||||||
html.dark .theme-text .card table tbody tr:hover { background-color: rgb(139 92 246 / 0.11); }
|
html.dark .theme-text .card table tbody tr:hover { background-color: rgb(24 24 27 / 0.11); }
|
||||||
/* Checkbox column + first data column get a hair more air against the
|
/* Checkbox column + first data column get a hair more air against the
|
||||||
card edge so dense tables don't feel cramped. */
|
card edge so dense tables don't feel cramped. */
|
||||||
.theme-text .card table tbody tr:hover :is(td, th):first-child {
|
.theme-text .card table tbody tr:hover :is(td, th):first-child {
|
||||||
box-shadow: inset 2px 0 0 rgb(124 92 252 / 0.55);
|
box-shadow: inset 2px 0 0 rgb(24 24 27 / 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||||
@@ -359,16 +359,12 @@ html.dark ::-webkit-scrollbar-thumb:hover { background: rgb(255 255 255 / 0.32);
|
|||||||
/* ===== Ambient background — aurora wash used by the public shell ===== */
|
/* ===== Ambient background — aurora wash used by the public shell ===== */
|
||||||
.ambient-bg {
|
.ambient-bg {
|
||||||
background-color: var(--app-bg);
|
background-color: var(--app-bg);
|
||||||
background-image:
|
}
|
||||||
radial-gradient(60rem 40rem at 12% -10%, rgb(124 92 252 / 0.14), transparent 60%),
|
|
||||||
radial-gradient(52rem 36rem at 92% 8%, rgb(34 211 238 / 0.11), transparent 60%),
|
|
||||||
radial-gradient(46rem 32rem at 8% 96%, rgb(240 171 252 / 0.1), transparent 60%);
|
|
||||||
}
|
|
||||||
html.dark .ambient-bg {
|
html.dark .ambient-bg {
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(60rem 40rem at 12% -10%, rgb(139 92 246 / 0.2), transparent 60%),
|
radial-gradient(60rem 40rem at 12% -10%, rgb(24 24 27 / 0.2), transparent 60%),
|
||||||
radial-gradient(52rem 36rem at 92% 8%, rgb(34 211 238 / 0.13), transparent 60%),
|
radial-gradient(52rem 36rem at 92% 8%, rgb(24 24 27 / 0.13), transparent 60%),
|
||||||
radial-gradient(46rem 32rem at 8% 96%, rgb(232 121 249 / 0.1), transparent 60%);
|
radial-gradient(46rem 32rem at 8% 96%, rgb(24 24 27 / 0.1), transparent 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Glass / aurora design kit =====
|
/* ===== Glass / aurora design kit =====
|
||||||
@@ -378,7 +374,7 @@ html.dark .ambient-bg {
|
|||||||
|
|
||||||
/* Gradient headline text — violet → cyan swipe. */
|
/* Gradient headline text — violet → cyan swipe. */
|
||||||
.anime-grad-text {
|
.anime-grad-text {
|
||||||
background: linear-gradient(100deg, #7c5cfc 0%, #a855f7 45%, #22d3ee 100%);
|
background: linear-gradient(100deg, #18181b 0%, #3f3f46 45%, #52525b 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
@@ -386,7 +382,7 @@ html.dark .ambient-bg {
|
|||||||
padding: 0 0.05em;
|
padding: 0 0.05em;
|
||||||
}
|
}
|
||||||
html.dark .anime-grad-text {
|
html.dark .anime-grad-text {
|
||||||
background: linear-gradient(100deg, #a78bfa 0%, #e879f9 45%, #67e8f9 100%);
|
background: linear-gradient(100deg, #71717a 0%, #e879f9 45%, #67e8f9 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
@@ -432,7 +428,7 @@ html.dark .anime-grad-text {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
border: 1px solid rgb(255 255 255 / 0.18);
|
border: 1px solid rgb(255 255 255 / 0.18);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee);
|
background: linear-gradient(120deg, #18181b, #27272a 55%, #52525b);
|
||||||
box-shadow: 0 8px 24px var(--accent-glow);
|
box-shadow: 0 8px 24px var(--accent-glow);
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
|
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -458,7 +454,7 @@ html.dark .anime-grad-text {
|
|||||||
/* Selected pill — gradient fill with a glow. */
|
/* Selected pill — gradient fill with a glow. */
|
||||||
.chip-on {
|
.chip-on {
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8) !important;
|
background: linear-gradient(120deg, #18181b, #27272a) !important;
|
||||||
border: 1px solid transparent !important;
|
border: 1px solid transparent !important;
|
||||||
box-shadow: 0 4px 16px var(--accent-glow) !important;
|
box-shadow: 0 4px 16px var(--accent-glow) !important;
|
||||||
}
|
}
|
||||||
@@ -470,11 +466,11 @@ html.dark .anime-grad-text {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(1.5px 1.5px at 12% 22%, rgb(139 92 246 / 0.7), transparent),
|
radial-gradient(1.5px 1.5px at 12% 22%, rgb(24 24 27 / 0.7), transparent),
|
||||||
radial-gradient(1.5px 1.5px at 78% 12%, rgb(34 211 238 / 0.7), transparent),
|
radial-gradient(1.5px 1.5px at 78% 12%, rgb(24 24 27 / 0.7), transparent),
|
||||||
radial-gradient(1px 1px at 42% 62%, rgb(139 92 246 / 0.6), transparent),
|
radial-gradient(1px 1px at 42% 62%, rgb(24 24 27 / 0.6), transparent),
|
||||||
radial-gradient(1.5px 1.5px at 88% 72%, rgb(232 121 249 / 0.6), transparent),
|
radial-gradient(1.5px 1.5px at 88% 72%, rgb(24 24 27 / 0.6), transparent),
|
||||||
radial-gradient(1px 1px at 24% 84%, rgb(34 211 238 / 0.55), transparent),
|
radial-gradient(1px 1px at 24% 84%, rgb(24 24 27 / 0.55), transparent),
|
||||||
radial-gradient(1px 1px at 62% 34%, rgb(167 139 250 / 0.55), transparent);
|
radial-gradient(1px 1px at 62% 34%, rgb(167 139 250 / 0.55), transparent);
|
||||||
animation: twinkle 4.5s ease-in-out infinite;
|
animation: twinkle 4.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -540,7 +536,7 @@ html.dark .memphis-dots {
|
|||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(135deg, #7c5cfc, #22d3ee);
|
background: linear-gradient(135deg, #18181b, #52525b);
|
||||||
border: 1px solid rgb(255 255 255 / 0.2);
|
border: 1px solid rgb(255 255 255 / 0.2);
|
||||||
border-radius: 1.2rem;
|
border-radius: 1.2rem;
|
||||||
box-shadow: 0 12px 32px var(--accent-glow);
|
box-shadow: 0 12px 32px var(--accent-glow);
|
||||||
@@ -580,9 +576,9 @@ html.dark .memphis-dots {
|
|||||||
a violet glow ring, matching the light theme's focus affordance. */
|
a violet glow ring, matching the light theme's focus affordance. */
|
||||||
.public-dark .field:focus,
|
.public-dark .field:focus,
|
||||||
.public-dark .field:focus-visible {
|
.public-dark .field:focus-visible {
|
||||||
border-color: rgb(139 92 246 / 0.7);
|
border-color: rgb(24 24 27 / 0.7);
|
||||||
background: rgb(255 255 255 / 0.06);
|
background: rgb(255 255 255 / 0.06);
|
||||||
box-shadow: 0 0 0 3px rgb(139 92 246 / 0.25);
|
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.25);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.public-dark .btn-soft {
|
.public-dark .btn-soft {
|
||||||
@@ -590,11 +586,11 @@ html.dark .memphis-dots {
|
|||||||
border-color: rgb(255 255 255 / 0.12);
|
border-color: rgb(255 255 255 / 0.12);
|
||||||
color: rgb(255 255 255 / 0.85);
|
color: rgb(255 255 255 / 0.85);
|
||||||
}
|
}
|
||||||
.public-dark .btn-soft:hover { background: rgb(139 92 246 / 0.25); color: white; }
|
.public-dark .btn-soft:hover { background: rgb(24 24 27 / 0.25); color: white; }
|
||||||
.public-dark .btn-ghost { color: rgb(255 255 255 / 0.65); border-color: transparent; }
|
.public-dark .btn-ghost { color: rgb(255 255 255 / 0.65); border-color: transparent; }
|
||||||
.public-dark .btn-ghost:hover { background: rgb(139 92 246 / 0.18); color: white; }
|
.public-dark .btn-ghost:hover { background: rgb(24 24 27 / 0.18); color: white; }
|
||||||
.public-dark .btn-primary {
|
.public-dark .btn-primary {
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8); color: #fff;
|
background: linear-gradient(120deg, #18181b, #27272a); color: #fff;
|
||||||
box-shadow: 0 6px 18px var(--accent-glow);
|
box-shadow: 0 6px 18px var(--accent-glow);
|
||||||
}
|
}
|
||||||
.public-dark .btn-primary:hover { filter: brightness(1.08); }
|
.public-dark .btn-primary:hover { filter: brightness(1.08); }
|
||||||
@@ -622,7 +618,7 @@ html.dark .memphis-dots {
|
|||||||
/* Tables in dark */
|
/* Tables in dark */
|
||||||
.public-dark .th { color: rgb(255 255 255 / 0.4); }
|
.public-dark .th { color: rgb(255 255 255 / 0.4); }
|
||||||
.public-dark .row { border-color: rgb(255 255 255 / 0.06); }
|
.public-dark .row { border-color: rgb(255 255 255 / 0.06); }
|
||||||
.public-dark .row:hover { background: rgb(139 92 246 / 0.07); }
|
.public-dark .row:hover { background: rgb(24 24 27 / 0.07); }
|
||||||
|
|
||||||
/* Inline code blocks (pill highlight) — only for inline <code>, NOT code inside
|
/* 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. */
|
a <pre> block (docs examples), which would get an unwanted full-width tint. */
|
||||||
@@ -665,7 +661,7 @@ html.dark .memphis-dots {
|
|||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-bold
|
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-bold
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed px-3.5 py-2;
|
disabled:opacity-50 disabled:cursor-not-allowed px-3.5 py-2;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 60%, #22d3ee 140%);
|
background: linear-gradient(120deg, #18181b, #27272a 60%, #52525b 140%);
|
||||||
border: 1px solid rgb(255 255 255 / 0.16);
|
border: 1px solid rgb(255 255 255 / 0.16);
|
||||||
box-shadow: 0 6px 18px var(--accent-glow);
|
box-shadow: 0 6px 18px var(--accent-glow);
|
||||||
transition: transform 0.13s ease, box-shadow 0.13s ease, filter 0.13s ease;
|
transition: transform 0.13s ease, box-shadow 0.13s ease, filter 0.13s ease;
|
||||||
@@ -685,10 +681,10 @@ html.dark .memphis-dots {
|
|||||||
.btn-soft:hover:not(:disabled) {
|
.btn-soft:hover:not(:disabled) {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
border-color: rgb(124 92 252 / 0.45);
|
border-color: rgb(24 24 27 / 0.45);
|
||||||
box-shadow: 0 4px 14px rgb(124 92 252 / 0.16);
|
box-shadow: 0 4px 14px rgb(24 24 27 / 0.16);
|
||||||
}
|
}
|
||||||
html.dark .btn-soft:hover:not(:disabled) { border-color: rgb(139 92 246 / 0.5); box-shadow: 0 4px 14px rgb(139 92 246 / 0.22); }
|
html.dark .btn-soft:hover:not(:disabled) { border-color: rgb(24 24 27 / 0.5); box-shadow: 0 4px 14px rgb(24 24 27 / 0.22); }
|
||||||
.btn-soft:active:not(:disabled) { transform: scale(0.97) translateY(1px); box-shadow: none; }
|
.btn-soft:active:not(:disabled) { transform: scale(0.97) translateY(1px); box-shadow: none; }
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium
|
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium
|
||||||
@@ -700,7 +696,7 @@ html.dark .memphis-dots {
|
|||||||
.btn-ghost:hover:not(:disabled) {
|
.btn-ghost:hover:not(:disabled) {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
border-color: rgb(124 92 252 / 0.3);
|
border-color: rgb(24 24 27 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* text link / quiet action */
|
/* text link / quiet action */
|
||||||
@@ -717,12 +713,12 @@ html.dark .memphis-dots {
|
|||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
}
|
}
|
||||||
.field:focus, .field:focus-visible {
|
.field:focus, .field:focus-visible {
|
||||||
border-color: rgb(124 92 252 / 0.65);
|
border-color: rgb(24 24 27 / 0.65);
|
||||||
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
|
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.18);
|
||||||
}
|
}
|
||||||
html.dark .field:focus, html.dark .field:focus-visible {
|
html.dark .field:focus, html.dark .field:focus-visible {
|
||||||
border-color: rgb(139 92 246 / 0.7);
|
border-color: rgb(24 24 27 / 0.7);
|
||||||
box-shadow: 0 0 0 3px rgb(139 92 246 / 0.25);
|
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Native <select> using .field: drop the OS arrow for a custom chevron that
|
/* Native <select> using .field: drop the OS arrow for a custom chevron that
|
||||||
@@ -758,7 +754,7 @@ html.dark .memphis-dots {
|
|||||||
.th { @apply text-left px-4 py-3 text-[11px] font-bold uppercase tracking-wider; color: var(--fg-faint); }
|
.th { @apply text-left px-4 py-3 text-[11px] font-bold uppercase tracking-wider; color: var(--fg-faint); }
|
||||||
.td { @apply px-4 py-3.5 align-middle; }
|
.td { @apply px-4 py-3.5 align-middle; }
|
||||||
.row { @apply border-t transition-colors; border-color: var(--hairline); }
|
.row { @apply border-t transition-colors; border-color: var(--hairline); }
|
||||||
.row:hover { background: rgb(124 92 252 / 0.06); }
|
.row:hover { background: rgb(24 24 27 / 0.06); }
|
||||||
|
|
||||||
/* avatar chip for identity columns */
|
/* avatar chip for identity columns */
|
||||||
.avatar {
|
.avatar {
|
||||||
|
|||||||
@@ -1,46 +1,7 @@
|
|||||||
// 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'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const KEY = 'gw_theme'
|
localStorage.removeItem('gw_theme')
|
||||||
const mql = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null
|
document.documentElement.classList.remove('dark')
|
||||||
|
|
||||||
function systemTheme() {
|
export const isDark = ref(false)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const contact = computed(() => site.contact || {})
|
|||||||
const powers = [
|
const powers = [
|
||||||
{ icon: 't2i', title: '文字生图', desc: '一句话出图,画幅与分辨率随模型可选。', tint: 'linear-gradient(135deg,#34d399,#2dd4bf)' },
|
{ icon: 't2i', title: '文字生图', desc: '一句话出图,画幅与分辨率随模型可选。', tint: 'linear-gradient(135deg,#34d399,#2dd4bf)' },
|
||||||
{ icon: 'i2i', title: '参考图生图', desc: '上传参考换风格、改构图、做变体。', tint: 'linear-gradient(135deg,#e879f9,#f472b6)' },
|
{ icon: 'i2i', title: '参考图生图', desc: '上传参考换风格、改构图、做变体。', tint: 'linear-gradient(135deg,#e879f9,#f472b6)' },
|
||||||
{ icon: 't2v', title: '文字生视频', desc: '一段描述生成短片,时长分辨率可选。', tint: 'linear-gradient(135deg,#38bdf8,#22d3ee)' },
|
{ icon: 't2v', title: '文字生视频', desc: '一段描述生成短片,时长分辨率可选。', tint: 'linear-gradient(135deg,#38bdf8,#52525b)' },
|
||||||
{ icon: 'i2v', title: '图生视频', desc: '给张参考图,让静态画面动起来。', tint: 'linear-gradient(135deg,#7c5cfc,#a855f7)' },
|
{ icon: 'i2v', title: '图生视频', desc: '给张参考图,让静态画面动起来。', tint: 'linear-gradient(135deg,#18181b,#3f3f46)' },
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ const powers = [
|
|||||||
box-shadow: var(--glass-shadow);
|
box-shadow: var(--glass-shadow);
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
}
|
}
|
||||||
.power-card:hover { transform: translateY(-3px); border-color: rgb(124 92 252 / 0.45); box-shadow: var(--glass-shadow-lg); }
|
.power-card:hover { transform: translateY(-3px); border-color: rgb(24 24 27 / 0.45); box-shadow: var(--glass-shadow-lg); }
|
||||||
.power-ico {
|
.power-ico {
|
||||||
width: 2.6rem;
|
width: 2.6rem;
|
||||||
height: 2.6rem;
|
height: 2.6rem;
|
||||||
@@ -162,7 +162,7 @@ const powers = [
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 6px 16px rgb(124 92 252 / 0.28);
|
box-shadow: 0 6px 16px rgb(24 24 27 / 0.28);
|
||||||
}
|
}
|
||||||
.power-ico svg { width: 1.3rem; height: 1.3rem; }
|
.power-ico svg { width: 1.3rem; height: 1.3rem; }
|
||||||
@media (max-width: 768px) { .ab-cluster { display: none; } }
|
@media (max-width: 768px) { .ab-cluster { display: none; } }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { api, jsonBody, loadImageBlob, loadImageData } from '../api'
|
|||||||
import { auth } from '../auth'
|
import { auth } from '../auth'
|
||||||
import { normalizeDoc, newId, ratioToSize, serializeDoc, screenToWorld, rectsIntersect, DEFAULT_SCALE } from '../utils/canvas'
|
import { normalizeDoc, newId, ratioToSize, serializeDoc, screenToWorld, rectsIntersect, DEFAULT_SCALE } from '../utils/canvas'
|
||||||
import CanvasStage from '../components/canvas/CanvasStage.vue'
|
import CanvasStage from '../components/canvas/CanvasStage.vue'
|
||||||
|
import Icon from '../components/Icon.vue'
|
||||||
import SelectMenu from '../components/SelectMenu.vue'
|
import SelectMenu from '../components/SelectMenu.vue'
|
||||||
|
|
||||||
const stage = ref(null)
|
const stage = ref(null)
|
||||||
@@ -50,7 +51,7 @@ watch(genMsg, (v) => {
|
|||||||
if (v) genMsgTimer = setTimeout(() => { genMsg.value = '' }, 3500)
|
if (v) genMsgTimer = setTimeout(() => { genMsg.value = '' }, 3500)
|
||||||
})
|
})
|
||||||
const kindModels = computed(() =>
|
const kindModels = computed(() =>
|
||||||
(allModels.value || []).filter((m) => m.enabled !== false && m.type === gen.kind),
|
(allModels.value || []).filter((m) => m.sellable && m.type === gen.kind),
|
||||||
)
|
)
|
||||||
const model = computed(() => kindModels.value.find((m) => m.id === gen.modelId) || null)
|
const model = computed(() => kindModels.value.find((m) => m.id === gen.modelId) || null)
|
||||||
const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1'])
|
const ratios = computed(() => model.value?.ratios?.length ? model.value.ratios : ['1:1'])
|
||||||
@@ -916,10 +917,11 @@ const saveLabel = computed(() => ({
|
|||||||
站点固定顶栏(z-40)盖住,画布工具栏无法点击。 -->
|
站点固定顶栏(z-40)盖住,画布工具栏无法点击。 -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div class="cv-page">
|
<div class="cv-page">
|
||||||
<!-- 顶栏 -->
|
<!-- 悬浮岛:左上画布切换 / 右上撤销缩放 -->
|
||||||
<div class="cv-top">
|
<div class="cv-top">
|
||||||
<div class="cv-top__left">
|
<div class="cv-island cv-top__left">
|
||||||
<button class="cv-btn" title="返回" @click="$router.push('/user')">←</button>
|
<button class="cv-btn cv-btn--ghost" title="返回" @click="$router.push('/user')"><Icon name="arrow" :size="15" style="transform: rotate(180deg)" /></button>
|
||||||
|
<span class="cv-sep" />
|
||||||
<SelectMenu
|
<SelectMenu
|
||||||
class="cv-dd cv-dd--canvas"
|
class="cv-dd cv-dd--canvas"
|
||||||
:model-value="currentId"
|
:model-value="currentId"
|
||||||
@@ -927,26 +929,30 @@ const saveLabel = computed(() => ({
|
|||||||
placeholder="选择画布"
|
placeholder="选择画布"
|
||||||
@update:model-value="switchCanvas"
|
@update:model-value="switchCanvas"
|
||||||
/>
|
/>
|
||||||
<button class="cv-btn" title="重命名" @click="renameCanvas">✎</button>
|
<button class="cv-btn cv-btn--ghost" title="重命名" @click="renameCanvas">✎</button>
|
||||||
<button class="cv-btn" title="新建画布" @click="newCanvas">+</button>
|
<button class="cv-btn cv-btn--ghost" title="新建画布" @click="newCanvas"><Icon name="plus" :size="15" /></button>
|
||||||
<button class="cv-btn cv-btn--danger" title="删除画布" @click="deleteCanvas">🗑</button>
|
<button class="cv-btn cv-btn--ghost cv-btn--danger" title="删除画布" @click="deleteCanvas"><Icon name="trash" :size="15" /></button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cv-top__right">
|
|
||||||
<button class="cv-btn" :disabled="!undoCount" title="撤销 (Ctrl+Z)" @click="undo">↩</button>
|
|
||||||
<button class="cv-btn" :disabled="!redoCount" title="重做 (Ctrl+Shift+Z)" @click="redo">↪</button>
|
|
||||||
<span class="cv-sep" />
|
|
||||||
<button class="cv-btn" title="缩小 10%" @click="stage?.zoomStep(-1)">−</button>
|
|
||||||
<span class="cv-zoom">{{ scalePct }}%</span>
|
|
||||||
<button class="cv-btn" title="放大 10%" @click="stage?.zoomStep(1)">+</button>
|
|
||||||
<button class="cv-btn" title="重置视图 (0)" @click="stage?.setViewport({ x: 0, y: 0, scale: DEFAULT_SCALE })">⌂</button>
|
|
||||||
<button class="cv-btn" title="适应内容 (1)" @click="stage?.fitToContent?.()">⤢</button>
|
|
||||||
<span class="cv-sep" />
|
|
||||||
<button class="cv-btn cv-btn--accent" @click="addNote">+ 笔记</button>
|
|
||||||
<button class="cv-btn cv-btn--accent" @click="openPicker">🗂 图片库</button>
|
|
||||||
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="onFilePicked">
|
|
||||||
<span v-if="saveLabel" class="cv-save" :data-state="saveState">{{ saveLabel }}</span>
|
<span v-if="saveLabel" class="cv-save" :data-state="saveState">{{ saveLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="cv-island cv-top__right">
|
||||||
|
<button class="cv-btn cv-btn--ghost" :disabled="!undoCount" title="撤销 (Ctrl+Z)" @click="undo">↩</button>
|
||||||
|
<button class="cv-btn cv-btn--ghost" :disabled="!redoCount" title="重做 (Ctrl+Shift+Z)" @click="redo">↪</button>
|
||||||
|
<span class="cv-sep" />
|
||||||
|
<button class="cv-btn cv-btn--ghost" title="缩小 10%" @click="stage?.zoomStep(-1)">−</button>
|
||||||
|
<span class="cv-zoom">{{ scalePct }}%</span>
|
||||||
|
<button class="cv-btn cv-btn--ghost" title="放大 10%" @click="stage?.zoomStep(1)"><Icon name="plus" :size="15" /></button>
|
||||||
|
<span class="cv-sep" />
|
||||||
|
<button class="cv-btn cv-btn--ghost" title="重置视图 (0)" @click="stage?.setViewport({ x: 0, y: 0, scale: DEFAULT_SCALE })">⌂</button>
|
||||||
|
<button class="cv-btn cv-btn--ghost" title="适应内容 (1)" @click="stage?.fitToContent?.()">⤢</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 左侧工具坞 -->
|
||||||
|
<div class="cv-tools">
|
||||||
|
<button class="cv-tool" title="添加笔记" @click="addNote"><Icon name="plus" :size="18" /><span>笔记</span></button>
|
||||||
|
<button class="cv-tool" title="从图片库添加" @click="openPicker"><Icon name="image" :size="18" /><span>图片库</span></button>
|
||||||
|
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="onFilePicked">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 舞台 + 生成面板 -->
|
<!-- 舞台 + 生成面板 -->
|
||||||
@@ -1119,7 +1125,7 @@ const saveLabel = computed(() => ({
|
|||||||
}
|
}
|
||||||
.cv-btn:hover:not(:disabled) {
|
.cv-btn:hover:not(:disabled) {
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
border-color: rgb(124 92 252 / 0.45);
|
border-color: rgb(24 24 27 / 0.45);
|
||||||
box-shadow: 0 4px 14px var(--accent-glow);
|
box-shadow: 0 4px 14px var(--accent-glow);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
@@ -1136,14 +1142,14 @@ const saveLabel = computed(() => ({
|
|||||||
box-shadow: 0 4px 14px rgb(229 72 77 / 0.25);
|
box-shadow: 0 4px 14px rgb(229 72 77 / 0.25);
|
||||||
}
|
}
|
||||||
.cv-btn--accent {
|
.cv-btn--accent {
|
||||||
background: linear-gradient(120deg, rgb(124 92 252 / 0.16), rgb(168 85 247 / 0.14));
|
background: #fff;
|
||||||
border-color: rgb(124 92 252 / 0.42);
|
border-color: rgb(24 24 27 / 0.28);
|
||||||
color: var(--m-orange);
|
color: #18181b;
|
||||||
}
|
}
|
||||||
.cv-btn--accent:hover:not(:disabled) {
|
.cv-btn--accent:hover:not(:disabled) {
|
||||||
background: linear-gradient(120deg, rgb(124 92 252 / 0.26), rgb(168 85 247 / 0.22));
|
background: rgb(24 24 27 / 0.05);
|
||||||
border-color: rgb(124 92 252 / 0.62);
|
border-color: #18181b;
|
||||||
box-shadow: 0 4px 16px var(--accent-glow);
|
box-shadow: 0 4px 14px rgb(24 24 27 / 0.12);
|
||||||
}
|
}
|
||||||
.cv-select,
|
.cv-select,
|
||||||
.cv-input {
|
.cv-input {
|
||||||
@@ -1157,8 +1163,8 @@ const saveLabel = computed(() => ({
|
|||||||
}
|
}
|
||||||
.cv-select:focus,
|
.cv-select:focus,
|
||||||
.cv-input:focus {
|
.cv-input:focus {
|
||||||
border-color: rgb(124 92 252 / 0.6);
|
border-color: rgb(24 24 27 / 0.6);
|
||||||
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
|
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cv-page {
|
.cv-page {
|
||||||
@@ -1171,25 +1177,77 @@ const saveLabel = computed(() => ({
|
|||||||
background: var(--app-bg);
|
background: var(--app-bg);
|
||||||
}
|
}
|
||||||
.cv-top {
|
.cv-top {
|
||||||
position: relative;
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
z-index: 20; /* 下拉面板不能被舞台盖住 */
|
z-index: 20; /* 下拉面板不能被舞台盖住 */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 9px 14px;
|
pointer-events: none;
|
||||||
border-bottom: 1px solid var(--ink-soft);
|
}
|
||||||
background: var(--surface);
|
.cv-island {
|
||||||
|
pointer-events: auto;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgb(24 24 27 / 0.08);
|
||||||
|
background: rgb(255 255 255 / 0.92);
|
||||||
-webkit-backdrop-filter: blur(14px);
|
-webkit-backdrop-filter: blur(14px);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
flex-wrap: wrap;
|
box-shadow: 0 1px 2px rgb(24 24 27 / 0.04), 0 12px 32px -12px rgb(24 24 27 / 0.2);
|
||||||
}
|
}
|
||||||
|
.cv-btn--ghost {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
width: 32px;
|
||||||
|
padding: 0;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.cv-tools {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 15;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgb(24 24 27 / 0.08);
|
||||||
|
background: rgb(255 255 255 / 0.92);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
box-shadow: 0 1px 2px rgb(24 24 27 / 0.04), 0 12px 32px -12px rgb(24 24 27 / 0.2);
|
||||||
|
}
|
||||||
|
.cv-tool {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
width: 56px;
|
||||||
|
padding: 9px 0 7px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg-2);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
.cv-tool:hover { background: rgb(24 24 27 / 0.06); color: #18181b; }
|
||||||
.cv-top__left,
|
.cv-top__left,
|
||||||
.cv-top__right {
|
.cv-top__right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 2px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
/* 统一自定义下拉的宽度/紧凑度(SelectMenu 内部是 .field 按钮) */
|
/* 统一自定义下拉的宽度/紧凑度(SelectMenu 内部是 .field 按钮) */
|
||||||
.cv-dd {
|
.cv-dd {
|
||||||
@@ -1211,11 +1269,9 @@ const saveLabel = computed(() => ({
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.cv-sep {
|
.cv-sep {
|
||||||
width: 2.5px;
|
width: 1px;
|
||||||
height: 20px;
|
height: 18px;
|
||||||
border-radius: 2px;
|
background: rgb(24 24 27 / 0.12);
|
||||||
background: var(--ink);
|
|
||||||
opacity: 0.25;
|
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
.cv-zoom {
|
.cv-zoom {
|
||||||
@@ -1224,10 +1280,10 @@ const saveLabel = computed(() => ({
|
|||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
min-width: 46px;
|
min-width: 46px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: var(--hover);
|
background: transparent;
|
||||||
border: 1px solid rgb(124 92 252 / 0.35);
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 8px;
|
||||||
padding: 1px 8px;
|
padding: 1px 4px;
|
||||||
}
|
}
|
||||||
.cv-save {
|
.cv-save {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1266,9 +1322,9 @@ const saveLabel = computed(() => ({
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
border: 2px dashed rgb(34 211 238 / 0.6);
|
border: 2px dashed rgb(24 24 27 / 0.6);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: rgb(34 211 238 / 0.1);
|
background: rgb(24 24 27 / 0.1);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.cv-loading,
|
.cv-loading,
|
||||||
@@ -1318,31 +1374,27 @@ const saveLabel = computed(() => ({
|
|||||||
}
|
}
|
||||||
.cv-chip:hover {
|
.cv-chip:hover {
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
border-color: rgb(124 92 252 / 0.4);
|
border-color: rgb(24 24 27 / 0.4);
|
||||||
}
|
}
|
||||||
.cv-chip.is-on {
|
.cv-chip.is-on {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
background: linear-gradient(120deg, #18181b, #27272a);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
box-shadow: 0 4px 14px var(--accent-glow);
|
box-shadow: 0 4px 14px var(--accent-glow);
|
||||||
}
|
}
|
||||||
/* 生成输入框:参考图缩略图在上,文本框居中,控制条在下 */
|
/* 生成输入框:参考图缩略图在上,文本框居中,控制条在下 */
|
||||||
.cv-composer {
|
.cv-composer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid rgb(24 24 27 / 0.1);
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background:
|
background: #fff;
|
||||||
linear-gradient(160deg, rgb(124 92 252 / 0.06), transparent 40%),
|
box-shadow: 0 2px 4px rgb(24 24 27 / 0.04), 0 18px 48px -12px rgb(24 24 27 / 0.2);
|
||||||
var(--surface);
|
|
||||||
-webkit-backdrop-filter: blur(22px) saturate(1.15);
|
|
||||||
backdrop-filter: blur(22px) saturate(1.15);
|
|
||||||
box-shadow: var(--glass-shadow-lg), 0 0 42px var(--accent-glow);
|
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
overflow: visible; /* 下拉面板要从输入框上方弹出,不能被裁切 */
|
overflow: visible; /* 下拉面板要从输入框上方弹出,不能被裁切 */
|
||||||
}
|
}
|
||||||
.cv-composer:focus-within {
|
.cv-composer:focus-within {
|
||||||
border-color: rgb(124 92 252 / 0.55);
|
border-color: rgb(24 24 27 / 0.55);
|
||||||
box-shadow: var(--glass-shadow-lg), 0 0 0 3px rgb(124 92 252 / 0.16), 0 0 42px var(--accent-glow);
|
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.08), 0 18px 48px -12px rgb(24 24 27 / 0.24);
|
||||||
}
|
}
|
||||||
.cv-composer__refs {
|
.cv-composer__refs {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1399,8 +1451,8 @@ const saveLabel = computed(() => ({
|
|||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1.5px dashed rgb(124 92 252 / 0.5);
|
border: 1.5px dashed rgb(24 24 27 / 0.5);
|
||||||
background: rgb(124 92 252 / 0.04);
|
background: rgb(24 24 27 / 0.04);
|
||||||
color: var(--m-orange);
|
color: var(--m-orange);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1414,7 +1466,7 @@ const saveLabel = computed(() => ({
|
|||||||
}
|
}
|
||||||
.cv-refadd:hover {
|
.cv-refadd:hover {
|
||||||
border-color: var(--m-orange);
|
border-color: var(--m-orange);
|
||||||
background: rgb(124 92 252 / 0.1);
|
background: rgb(24 24 27 / 0.1);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 6px 16px var(--accent-glow);
|
box-shadow: 0 6px 16px var(--accent-glow);
|
||||||
}
|
}
|
||||||
@@ -1468,7 +1520,7 @@ const saveLabel = computed(() => ({
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
border: 1px solid rgb(255 255 255 / 0.18);
|
border: 1px solid rgb(255 255 255 / 0.18);
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
|
background: linear-gradient(120deg, #18181b, #27272a 55%, #52525b 140%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -1593,14 +1645,14 @@ const saveLabel = computed(() => ({
|
|||||||
}
|
}
|
||||||
.cv-picker__grid img:hover {
|
.cv-picker__grid img:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: rgb(124 92 252 / 0.55);
|
border-color: rgb(24 24 27 / 0.55);
|
||||||
box-shadow: 0 8px 20px var(--accent-glow);
|
box-shadow: 0 8px 20px var(--accent-glow);
|
||||||
}
|
}
|
||||||
.cv-picker__up {
|
.cv-picker__up {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 2px dashed rgb(124 92 252 / 0.45);
|
border: 2px dashed rgb(24 24 27 / 0.45);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--m-orange);
|
color: var(--m-orange);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1616,7 +1668,7 @@ const saveLabel = computed(() => ({
|
|||||||
.cv-picker__up:hover {
|
.cv-picker__up:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: var(--m-orange);
|
border-color: var(--m-orange);
|
||||||
background: rgb(124 92 252 / 0.08);
|
background: rgb(24 24 27 / 0.08);
|
||||||
box-shadow: 0 8px 20px var(--accent-glow);
|
box-shadow: 0 8px 20px var(--accent-glow);
|
||||||
}
|
}
|
||||||
.cv-picker__up:disabled {
|
.cv-picker__up:disabled {
|
||||||
|
|||||||
@@ -9,20 +9,21 @@ const KIND_LABEL = { image: '图像', video: '视频', text: '文本' }
|
|||||||
const channels = ref([])
|
const channels = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const err = ref('')
|
const err = ref('')
|
||||||
|
const warn = ref('') // 保存后自动同步的非致命告警
|
||||||
|
const okMsg = ref('') // 同步模型成功提示
|
||||||
|
|
||||||
// create/edit form
|
// create/edit form
|
||||||
const form = ref(null)
|
const form = ref(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const formErr = ref('')
|
const formErr = ref('')
|
||||||
|
|
||||||
// fetch-models → pick → import
|
// 同步上游模型弹窗:开关选择 + 别名,批量导入(保存渠道已自动全量同步)
|
||||||
const fetchTarget = ref(null) // channel snapshot being imported into
|
const fetchTarget = ref(null) // saved channel being synced
|
||||||
const fetching = ref(false)
|
const fetching = ref(false)
|
||||||
const fetchErr = ref('')
|
const fetchErr = ref('')
|
||||||
const specs = ref([])
|
const specs = ref([])
|
||||||
const picked = ref(new Set())
|
const picked = ref(new Set())
|
||||||
const importing = ref(false)
|
const importing = ref(false)
|
||||||
const managedIds = ref(new Set())
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -33,17 +34,6 @@ async function load() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadManaged() {
|
|
||||||
const r = await api('/managed-models')
|
|
||||||
managedIds.value = new Set((r.data?.data || []).map((m) => m.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelSummary(c) {
|
|
||||||
const names = c.models || []
|
|
||||||
if (!names.length) return ''
|
|
||||||
return names.length <= 2 ? names.join('、') : `${names[0]}、${names[1]} 等`
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
formErr.value = ''
|
formErr.value = ''
|
||||||
form.value = { id: '', name: '', base_url: '', api_key: '', concurrency: 5, weight: 1, enabled: true }
|
form.value = { id: '', name: '', base_url: '', api_key: '', concurrency: 5, weight: 1, enabled: true }
|
||||||
@@ -61,12 +51,14 @@ async function save() {
|
|||||||
if (saving.value) return
|
if (saving.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
formErr.value = ''
|
formErr.value = ''
|
||||||
|
okMsg.value = ''
|
||||||
const r = await api('/channels', jsonBody('POST', {
|
const r = await api('/channels', jsonBody('POST', {
|
||||||
id: f.id, name: f.name, base_url: f.base_url, api_key: f.api_key,
|
id: f.id, name: f.name, base_url: f.base_url, api_key: f.api_key,
|
||||||
enabled: !!f.enabled, concurrency: f.concurrency || 0, weight: f.weight || 1,
|
enabled: !!f.enabled, concurrency: f.concurrency || 0, weight: f.weight || 1,
|
||||||
}))
|
}))
|
||||||
saving.value = false
|
saving.value = false
|
||||||
if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return }
|
if (!r.ok) { formErr.value = r.data?.detail || '保存失败'; return }
|
||||||
|
warn.value = r.data?.warning || ''
|
||||||
form.value = null
|
form.value = null
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
@@ -78,7 +70,8 @@ async function toggle(c) {
|
|||||||
id: c.id, name: c.name, base_url: c.base_url, api_key: '',
|
id: c.id, name: c.name, base_url: c.base_url, api_key: '',
|
||||||
enabled: c.enabled, concurrency: c.concurrency || 0, weight: c.weight || 1,
|
enabled: c.enabled, concurrency: c.concurrency || 0, weight: c.weight || 1,
|
||||||
}))
|
}))
|
||||||
if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败' }
|
if (!r.ok) { c.enabled = prev; err.value = r.data?.detail || '状态更新失败'; return }
|
||||||
|
warn.value = r.data?.warning || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(c) {
|
async function del(c) {
|
||||||
@@ -88,29 +81,20 @@ async function del(c) {
|
|||||||
channels.value = channels.value.filter((x) => x.id !== c.id)
|
channels.value = channels.value.filter((x) => x.id !== c.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表单内拉模型:编辑态走库内渠道(密钥不用重填);新渠道用表单里的地址+key 预览。
|
// 行内「同步上游模型」走开关 + 别名弹窗(渠道已保存,用库里凭据拉取)。
|
||||||
function fetchFromForm() {
|
// 默认全开:点「同步模型」把勾选项批量导入绑定;上游消失的模型由保存时的
|
||||||
const f = form.value
|
// 全量同步负责解绑。
|
||||||
if (!f) return
|
|
||||||
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
|
|
||||||
if (!f.id && !f.api_key) { formErr.value = '请填写 API Key'; return }
|
|
||||||
openFetch({ id: f.id || '', name: f.name || f.base_url, base_url: f.base_url, api_key: f.api_key, concurrency: f.concurrency, weight: f.weight, enabled: !!f.enabled })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openFetch(c) {
|
async function openFetch(c) {
|
||||||
fetchTarget.value = c
|
fetchTarget.value = c
|
||||||
specs.value = []
|
specs.value = []
|
||||||
picked.value = new Set()
|
picked.value = new Set()
|
||||||
fetchErr.value = ''
|
fetchErr.value = ''
|
||||||
fetching.value = true
|
fetching.value = true
|
||||||
const r = await api('/channels/fetch-models', jsonBody('POST', {
|
const r = await api('/channels/fetch-models', jsonBody('POST', { id: c.id }))
|
||||||
id: c.id || '', base_url: c.base_url || '', api_key: c.api_key || '',
|
|
||||||
}))
|
|
||||||
fetching.value = false
|
fetching.value = false
|
||||||
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
|
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
|
||||||
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', imported: managedIds.value.has(s.id) }))
|
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: '' }))
|
||||||
picked.value = new Set(specs.value.filter((s) => !s.imported).map((s) => s.id))
|
picked.value = new Set(specs.value.map((s) => s.id))
|
||||||
if (!picked.value.size) picked.value = new Set(specs.value.map((s) => s.id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePick(id) {
|
function togglePick(id) {
|
||||||
@@ -119,6 +103,25 @@ function togglePick(id) {
|
|||||||
picked.value = next
|
picked.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doImport() {
|
||||||
|
if (importing.value || !picked.value.size) return
|
||||||
|
const dup = specs.value.find((s) => picked.value.has(s.id) && s.alias && s.alias === s.id)
|
||||||
|
if (dup) { fetchErr.value = `${dup.id}的别名与上游模型名相同,别名只在需要改名时设置,同名请留空`; return }
|
||||||
|
const models = specs.value.filter((s) => picked.value.has(s.id))
|
||||||
|
.map((s) => { const { bound, ...rest } = s; return rest })
|
||||||
|
importing.value = true
|
||||||
|
fetchErr.value = ''
|
||||||
|
try {
|
||||||
|
const r = await api('/channels/import-models', jsonBody('POST', { id: fetchTarget.value.id, models }))
|
||||||
|
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
|
||||||
|
okMsg.value = `已导入 ${models.length} 个模型`
|
||||||
|
fetchTarget.value = null
|
||||||
|
load()
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function specText(s) {
|
function specText(s) {
|
||||||
const parts = []
|
const parts = []
|
||||||
if (s.qualities?.length) parts.push(s.qualities.join('/'))
|
if (s.qualities?.length) parts.push(s.qualities.join('/'))
|
||||||
@@ -135,38 +138,7 @@ function specText(s) {
|
|||||||
return parts.join(' · ') || '-'
|
return parts.join(' · ') || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doImport() {
|
onMounted(load)
|
||||||
if (importing.value || !picked.value.size) return
|
|
||||||
const models = specs.value
|
|
||||||
.filter((s) => picked.value.has(s.id))
|
|
||||||
.map((s) => {
|
|
||||||
const { imported, ...rest } = s
|
|
||||||
return rest
|
|
||||||
})
|
|
||||||
const target = fetchTarget.value
|
|
||||||
importing.value = true
|
|
||||||
try {
|
|
||||||
if (!target.id) {
|
|
||||||
// 表单预览路径:渠道尚未入库,先按快照保存再绑定;失败中止不建孤儿。
|
|
||||||
const sr = await api('/channels', jsonBody('POST', {
|
|
||||||
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
|
|
||||||
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
|
|
||||||
}))
|
|
||||||
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
|
|
||||||
target.id = sr.data?.id
|
|
||||||
form.value = null
|
|
||||||
}
|
|
||||||
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models }))
|
|
||||||
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
|
|
||||||
fetchTarget.value = null
|
|
||||||
load()
|
|
||||||
loadManaged()
|
|
||||||
} finally {
|
|
||||||
importing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => { load(); loadManaged() })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -174,7 +146,7 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
|
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-medium">渠道</div>
|
<div class="text-sm font-medium">渠道</div>
|
||||||
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 模型经「拉取上游模型」导入 · 余额实时探测</p>
|
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 保存时自动同步上游模型 · 余额实时探测</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="openAdd" class="btn-primary">
|
<button @click="openAdd" class="btn-primary">
|
||||||
<Icon name="plus" class="w-4 h-4" /> 添加渠道
|
<Icon name="plus" class="w-4 h-4" /> 添加渠道
|
||||||
@@ -182,6 +154,8 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="err" class="text-xs text-rose-300">{{ err }}</p>
|
<p v-if="err" class="text-xs text-rose-300">{{ err }}</p>
|
||||||
|
<p v-if="warn" class="text-xs text-amber-300">{{ warn }}</p>
|
||||||
|
<p v-if="okMsg" class="text-xs text-emerald-300">{{ okMsg }}</p>
|
||||||
|
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -189,11 +163,11 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>渠道</th>
|
<th>渠道</th>
|
||||||
<th>上游地址</th>
|
|
||||||
<th>模型</th>
|
|
||||||
<th>并发</th>
|
<th>并发</th>
|
||||||
<th>配比</th>
|
<th>配比</th>
|
||||||
<th class="text-right">成功调用</th>
|
<th class="text-right">运行中</th>
|
||||||
|
<th class="text-right">成功数</th>
|
||||||
|
<th class="text-right">失败数</th>
|
||||||
<th class="text-right">累计消耗</th>
|
<th class="text-right">累计消耗</th>
|
||||||
<th class="text-right">上游余额</th>
|
<th class="text-right">上游余额</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
@@ -207,16 +181,11 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
<div class="font-medium text-white/85">{{ c.name }}</div>
|
<div class="font-medium text-white/85">{{ c.name }}</div>
|
||||||
<div class="text-[11px] text-white/35 font-mono">{{ c.id }}</div>
|
<div class="text-[11px] text-white/35 font-mono">{{ c.id }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="tabular-nums">{{ c.concurrency || '不限' }}</td>
|
||||||
<div class="max-w-[240px] truncate font-mono text-xs text-white/55" :title="c.base_url">{{ c.base_url }}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="tabular-nums text-white/80">{{ (c.models || []).length }}</div>
|
|
||||||
<div class="text-[11px] text-white/35 max-w-[180px] truncate" :title="(c.models || []).join('、')">{{ modelSummary(c) }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="tabular-nums">{{ c.concurrency || '默认' }}</td>
|
|
||||||
<td class="tabular-nums">{{ c.weight || 1 }}</td>
|
<td class="tabular-nums">{{ c.weight || 1 }}</td>
|
||||||
|
<td class="text-right tabular-nums" :class="c.running_count > 0 ? 'text-emerald-300' : ''">{{ c.running_count || 0 }}</td>
|
||||||
<td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td>
|
<td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td>
|
||||||
|
<td class="text-right tabular-nums" :class="c.fail_count > 0 ? 'text-rose-300' : ''">{{ (c.fail_count || 0).toLocaleString() }}</td>
|
||||||
<td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td>
|
<td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td>
|
||||||
<td class="text-right tabular-nums">{{ c.upstream_balance == null ? '-' : c.upstream_balance.toLocaleString() }}</td>
|
<td class="text-right tabular-nums">{{ c.upstream_balance == null ? '-' : c.upstream_balance.toLocaleString() }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -228,7 +197,7 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td>
|
<td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td>
|
||||||
<td class="text-right whitespace-nowrap">
|
<td class="text-right whitespace-nowrap">
|
||||||
<button @click="openFetch(c)" class="op" title="拉取上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
|
<button @click="openFetch(c)" class="op" title="同步上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
|
||||||
<button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
|
<button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
|
||||||
<button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
|
<button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
|
||||||
</td>
|
</td>
|
||||||
@@ -265,8 +234,8 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="lbl">并发数</label>
|
<label class="lbl">并发数</label>
|
||||||
<input v-model.number="form.concurrency" type="number" min="0" max="100" class="field" />
|
<input v-model.number="form.concurrency" type="number" min="0" max="2147483647" class="field" />
|
||||||
<p class="mt-1 text-[11px] text-white/35">0 = 默认 5</p>
|
<p class="mt-1 text-[11px] text-white/35">0 = 不限</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="lbl">配比</label>
|
<label class="lbl">配比</label>
|
||||||
@@ -282,12 +251,6 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
{{ form.enabled ? '已启用' : '已停用' }}
|
{{ form.enabled ? '已启用' : '已停用' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button type="button" @click="fetchFromForm" :disabled="fetching" class="btn-ghost text-xs">
|
|
||||||
{{ fetching ? '拉取中…' : '获取模型列表' }}
|
|
||||||
</button>
|
|
||||||
<span class="text-[11px] text-white/35">{{ form.id ? '用已保存的地址与密钥拉取' : '用上方的地址与 API Key 拉取;导入时会先自动保存渠道' }}</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p>
|
<p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
|
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
|
||||||
@@ -297,40 +260,40 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 拉取上游模型 → 勾选导入 -->
|
<!-- 同步上游模型:开关选择 + 别名,批量导入 -->
|
||||||
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
|
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
|
||||||
<div class="card !shadow-2xl my-12 w-full max-w-2xl">
|
<div class="card !shadow-2xl my-12 w-full max-w-2xl">
|
||||||
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||||
<h2 class="text-sm font-semibold">上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2>
|
<h2 class="text-sm font-semibold">同步上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2>
|
||||||
<button @click="fetchTarget = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
|
<button @click="fetchTarget = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
|
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
|
||||||
<div v-else-if="fetchErr" class="py-10 text-center text-rose-300 text-sm">{{ fetchErr }}</div>
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-xs text-white/45 mb-3">勾选要导入的模型:能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名);已导入的模型自动标记(重复导入会刷新参数)。</p>
|
<p class="text-xs text-white/45 mb-3 leading-relaxed">开启的模型会同步到本站模型列表,参数随上游带入;到「模型管理」配价后上架。别名即本站模型标识,留空 = 与上游同名。</p>
|
||||||
|
<p v-if="fetchErr" class="text-xs text-rose-300 mb-3">{{ fetchErr }}</p>
|
||||||
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
|
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
|
||||||
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto">
|
<div v-else class="space-y-2 max-h-[400px] overflow-y-auto p-0.5">
|
||||||
<div v-for="s in specs" :key="s.id" @click="togglePick(s.id)"
|
<div v-for="s in specs" :key="s.id" @click="togglePick(s.id)"
|
||||||
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors"
|
class="p-3 rounded-xl ring-1 ring-white/10 bg-white/[0.02] hover:bg-white/[0.04] hover:ring-white/20 cursor-pointer transition-all space-y-2"
|
||||||
:class="!picked.has(s.id) && 'opacity-50'">
|
:class="!picked.has(s.id) && 'opacity-45'">
|
||||||
<input type="checkbox" :checked="picked.has(s.id)" @click.stop @change="togglePick(s.id)" class="mt-1 accent-sky-400" />
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
<div class="flex-1 min-w-0 space-y-1.5">
|
<span class="w-2 h-2 rounded-full shrink-0"
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
:class="s.kind === 'video' ? 'bg-sky-400' : s.kind === 'text' ? 'bg-amber-400' : 'bg-zinc-500'"></span>
|
||||||
<code class="font-mono text-xs text-white/85">{{ s.id }}</code>
|
<code class="font-mono text-[13px] font-semibold text-white/90 truncate">{{ s.id }}</code>
|
||||||
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold"
|
<span class="text-[11px] font-semibold text-white/40 shrink-0">{{ KIND_LABEL[s.kind] || s.kind }}</span>
|
||||||
:class="s.kind === 'video' ? 'bg-rose-400/15 text-rose-300' : 'bg-sky-400/15 text-sky-300'">
|
<button type="button" class="ml-auto sw" :class="picked.has(s.id) && 'sw-on'"
|
||||||
{{ KIND_LABEL[s.kind] || s.kind }}
|
:aria-pressed="picked.has(s.id)" @click.stop="togglePick(s.id)">
|
||||||
</span>
|
<span class="sw-thumb"></span>
|
||||||
<span class="text-[11px] text-white/40">{{ specText(s) }}</span>
|
</button>
|
||||||
<span v-if="s.imported" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已导入</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2" @click.stop>
|
<div class="pl-[18px] text-[11px] text-white/50">{{ specText(s) }}</div>
|
||||||
<span class="text-[11px] text-white/35 shrink-0">别名</span>
|
<div class="pl-[18px]" @click.stop>
|
||||||
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)"
|
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)"
|
||||||
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40"
|
class="w-full px-2.5 py-1.5 rounded-lg bg-white/[0.03] border border-dashed border-white/15 text-xs text-white/80
|
||||||
placeholder="留空 = 与上游同名" />
|
hover:border-solid focus:border-solid focus:border-white/30 focus:bg-white/[0.05] focus:outline-none
|
||||||
</div>
|
disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
||||||
|
placeholder="别名(留空 = 与上游同名)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,7 +302,7 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
|
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
|
||||||
<button @click="fetchTarget = null" class="btn-ghost">关闭</button>
|
<button @click="fetchTarget = null" class="btn-ghost">关闭</button>
|
||||||
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
|
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
|
||||||
{{ importing ? '导入中…' : (fetchTarget?.id ? `导入选中(${picked.size})` : `保存渠道并导入(${picked.size})`) }}
|
{{ importing ? '同步中…' : `同步模型(${picked.size})` }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,4 +322,10 @@ onMounted(() => { load(); loadManaged() })
|
|||||||
.op { display: inline-grid; place-items: center; min-width: 28px; height: 26px; padding: 0 7px; margin-left: 4px; border-radius: 7px; background: rgb(255 255 255 / 0.05); color: rgb(255 255 255 / 0.6); }
|
.op { display: inline-grid; place-items: center; min-width: 28px; height: 26px; padding: 0 7px; margin-left: 4px; border-radius: 7px; background: rgb(255 255 255 / 0.05); color: rgb(255 255 255 / 0.6); }
|
||||||
.op:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
.op:hover { background: rgb(255 255 255 / 0.1); color: white; }
|
||||||
.op.danger:hover { background: rgb(244 63 94 / 0.15); color: rgb(251 113 133); }
|
.op.danger:hover { background: rgb(244 63 94 / 0.15); color: rgb(251 113 133); }
|
||||||
|
|
||||||
|
/* iOS-style switch for 模型开关 in the sync modal */
|
||||||
|
.sw { position: relative; width: 2rem; height: 1.15rem; 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; flex-shrink: 0; }
|
||||||
|
.sw-thumb { position: absolute; top: 2px; left: 2px; width: calc(1.15rem - 4px); height: calc(1.15rem - 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(2rem - 1.15rem)); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ html.dark .hint b { color: rgb(253 164 175); } /* rose — visible warning, no
|
|||||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.2);
|
box-shadow: 0 1px 2px rgb(15 23 42 / 0.2);
|
||||||
transition: transform 0.18s ease;
|
transition: transform 0.18s ease;
|
||||||
}
|
}
|
||||||
.sw:checked { background: #4f46e5; } /* sky-600 — matches btn-primary */
|
.sw:checked { background: #27272a; } /* sky-600 — matches btn-primary */
|
||||||
.sw:checked::after { transform: translateX(calc(2.25rem - 1.3rem)); }
|
.sw:checked::after { transform: translateX(calc(2.25rem - 1.3rem)); }
|
||||||
.sw:focus-visible { box-shadow: 0 0 0 3px rgb(236 91 19 / 0.3); }
|
.sw:focus-visible { box-shadow: 0 0 0 3px rgb(236 91 19 / 0.3); }
|
||||||
.sw:disabled { cursor: not-allowed; opacity: 0.55; }
|
.sw:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||||
@@ -677,8 +677,8 @@ html.dark .num, html.dark .txt, html.dark .field {
|
|||||||
}
|
}
|
||||||
.num::placeholder, .txt::placeholder, .field::placeholder { color: var(--fg-faint); }
|
.num::placeholder, .txt::placeholder, .field::placeholder { color: var(--fg-faint); }
|
||||||
.num:focus, .txt:focus, .field:focus {
|
.num:focus, .txt:focus, .field:focus {
|
||||||
border-color: rgb(124 92 252 / 0.65);
|
border-color: rgb(24 24 27 / 0.65);
|
||||||
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
|
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.18);
|
||||||
}
|
}
|
||||||
.num:disabled, .txt:disabled, .field:disabled { opacity: 0.45; cursor: not-allowed; }
|
.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; -moz-appearance: textfield; appearance: textfield; }
|
.num { width: 6rem; padding: 0.4rem 0.55rem; font-size: 0.8rem; text-align: right; -moz-appearance: textfield; appearance: textfield; }
|
||||||
@@ -692,7 +692,7 @@ html.dark .num, html.dark .txt, html.dark .field {
|
|||||||
.btn-primary {
|
.btn-primary {
|
||||||
padding: 0.55rem 1.15rem; border-radius: 0.6rem;
|
padding: 0.55rem 1.15rem; border-radius: 0.6rem;
|
||||||
font-size: 0.8rem; font-weight: 800; color: white;
|
font-size: 0.8rem; font-weight: 800; color: white;
|
||||||
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
|
background: linear-gradient(120deg, #18181b, #27272a);
|
||||||
border: 1px solid rgb(255 255 255 / 0.16);
|
border: 1px solid rgb(255 255 255 / 0.16);
|
||||||
box-shadow: 0 6px 18px var(--accent-glow);
|
box-shadow: 0 6px 18px var(--accent-glow);
|
||||||
transition: filter 0.15s, transform 0.12s, box-shadow 0.15s, opacity 0.15s;
|
transition: filter 0.15s, transform 0.12s, box-shadow 0.15s, opacity 0.15s;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const models = ref([])
|
|||||||
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源)
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')])
|
const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')])
|
||||||
if (r.ok) models.value = (r.data?.data || []).filter((m) => m.enabled !== false)
|
if (r.ok) models.value = (r.data?.data || []).filter((m) => m.sellable)
|
||||||
if (p.ok) presets.value = presetMap(p.data?.data)
|
if (p.ok) presets.value = presetMap(p.data?.data)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ const videoModels = computed(() => models.value.filter((m) => m.type === 'video'
|
|||||||
function pubName(m) {
|
function pubName(m) {
|
||||||
return m?.alias || m?.id || ''
|
return m?.alias || m?.id || ''
|
||||||
}
|
}
|
||||||
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'gpt-image-2')
|
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'image-model')
|
||||||
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'video-model')
|
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'video-model')
|
||||||
const sampleQuality = computed(() => (imageModels.value[0]?.resolutions || [])[0] || '1K')
|
const sampleQuality = computed(() => (imageModels.value[0]?.resolutions || [])[0] || '1K')
|
||||||
const sampleVQuality = computed(() => (videoModels.value[0]?.resolutions || [])[0] || '720p')
|
const sampleVQuality = computed(() => (videoModels.value[0]?.resolutions || [])[0] || '720p')
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
|||||||
<span v-if="f.resolution" class="text-emerald-300/90">{{ f.resolution }}</span>
|
<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.ratio" class="text-white/40">{{ f.ratio }}</span>
|
||||||
<span v-if="f.kind === 'video' && f.duration" class="text-rose-300/80">{{ f.duration }}</span>
|
<span v-if="f.kind === 'video' && f.duration" class="text-rose-300/80">{{ f.duration }}</span>
|
||||||
<span v-if="f.deai" class="text-[#a78bfa]">去AI</span>
|
<span v-if="f.deai" class="text-[#71717a]">去AI</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtSize(f.size) }} · {{ fmtTs(f.mtime) }}</div>
|
<div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtSize(f.size) }} · {{ fmtTs(f.mtime) }}</div>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function toast(m) {
|
|||||||
<section class="relative rounded-2xl p-7 md:p-8 overflow-hidden ink-card">
|
<section class="relative rounded-2xl p-7 md:p-8 overflow-hidden ink-card">
|
||||||
<div class="memphis-dots absolute inset-0 opacity-30 pointer-events-none"></div>
|
<div class="memphis-dots absolute inset-0 opacity-30 pointer-events-none"></div>
|
||||||
<div class="relative inline-grid w-11 h-11 rounded-xl text-white place-items-center"
|
<div class="relative inline-grid w-11 h-11 rounded-xl text-white place-items-center"
|
||||||
style="background: linear-gradient(135deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">
|
style="background: linear-gradient(135deg, #18181b, #52525b); box-shadow: 0 8px 20px var(--accent-glow)">
|
||||||
<Icon name="accounts" class="w-5 h-5" />
|
<Icon name="accounts" class="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ function toast(m) {
|
|||||||
</button>
|
</button>
|
||||||
<button @click="copyInvite"
|
<button @click="copyInvite"
|
||||||
class="rounded-xl border border-white/20 text-white px-5 py-3 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
|
class="rounded-xl border border-white/20 text-white px-5 py-3 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
|
||||||
style="background: linear-gradient(120deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">
|
style="background: linear-gradient(120deg, #18181b, #52525b); box-shadow: 0 8px 20px var(--accent-glow)">
|
||||||
复制链接
|
复制链接
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ const sourcePill = (s) => ({
|
|||||||
<span v-else class="text-white/25">—</span>
|
<span v-else class="text-white/25">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3.5 align-middle">
|
<td class="px-3 py-3.5 align-middle">
|
||||||
<span class="chip ring-1" :class="statusPill(e.status)">
|
<span class="chip ring-1" :class="statusPill(e.status)" :title="e.error || ''">
|
||||||
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(e.status)"></span>
|
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(e.status)"></span>
|
||||||
{{ statusLabel(e.status) }}
|
{{ statusLabel(e.status) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -327,7 +327,7 @@ const sourcePill = (s) => ({
|
|||||||
</template>
|
</template>
|
||||||
<template v-if="e.deai">
|
<template v-if="e.deai">
|
||||||
<span class="text-white/25 mx-1.5">·</span>
|
<span class="text-white/25 mx-1.5">·</span>
|
||||||
<span class="text-[#a78bfa]">去AI特征</span>
|
<span class="text-[#71717a]">去AI特征</span>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap">
|
<td class="px-3 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const editing = ref(null) // null = add, object = edit
|
|||||||
const testing = ref(null) // model being tested, or null
|
const testing = ref(null) // model being tested, or null
|
||||||
|
|
||||||
const kindFilter = ref('') // '' | 'image' | 'video'
|
const kindFilter = ref('') // '' | 'image' | 'video'
|
||||||
const statusFilter = ref('') // '' | 'enabled' | 'disabled'
|
const saleFilter = ref('') // '' | 'sellable' | 'unsellable'
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
|
|
||||||
const TYPE_LABEL = { image: '生图', video: '生视频' }
|
const TYPE_LABEL = { image: '生图', video: '生视频' }
|
||||||
@@ -42,20 +42,6 @@ async function loadModels() {
|
|||||||
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
function openEdit(m) { editing.value = { ...m }; showForm.value = true }
|
||||||
function onSaved() { showForm.value = false; loadModels() }
|
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) {
|
async function remove(m) {
|
||||||
if (!confirm(`确认删除模型 ${m.id}?`)) return
|
if (!confirm(`确认删除模型 ${m.id}?`)) return
|
||||||
await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' })
|
await api(`/managed-models/${encodeURIComponent(m.id)}`, { method: 'DELETE' })
|
||||||
@@ -66,16 +52,16 @@ const stats = computed(() => {
|
|||||||
const total = models.value.length
|
const total = models.value.length
|
||||||
const image = models.value.filter((m) => m.type === 'image').length
|
const image = models.value.filter((m) => m.type === 'image').length
|
||||||
const video = models.value.filter((m) => m.type === 'video').length
|
const video = models.value.filter((m) => m.type === 'video').length
|
||||||
const enabled = models.value.filter((m) => m.enabled !== false).length
|
const sellable = models.value.filter((m) => m.sellable).length
|
||||||
return { total, image, video, enabled, disabled: total - enabled }
|
return { total, image, video, sellable }
|
||||||
})
|
})
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
const q = search.value.trim().toLowerCase()
|
const q = search.value.trim().toLowerCase()
|
||||||
return models.value.filter((m) => {
|
return models.value.filter((m) => {
|
||||||
if (kindFilter.value && m.type !== kindFilter.value) return false
|
if (kindFilter.value && m.type !== kindFilter.value) return false
|
||||||
if (statusFilter.value === 'enabled' && m.enabled === false) return false
|
if (saleFilter.value === 'sellable' && !m.sellable) return false
|
||||||
if (statusFilter.value === 'disabled' && m.enabled !== false) return false
|
if (saleFilter.value === 'unsellable' && m.sellable) return false
|
||||||
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q))) return false
|
if (q && !(m.id.toLowerCase().includes(q) || (m.alias || '').toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q))) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -101,8 +87,8 @@ onMounted(loadModels)
|
|||||||
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div>
|
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">启用</div>
|
<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 class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.sellable }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,23 +101,20 @@ onMounted(loadModels)
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-px h-5 bg-white/10"></div>
|
<div class="w-px h-5 bg-white/10"></div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button @click="statusFilter = ''" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
|
<button @click="saleFilter = ''" class="fp" :class="saleFilter === '' && 'fp-on'">全部</button>
|
||||||
<button @click="statusFilter = 'enabled'" class="fp" :class="statusFilter === 'enabled' && 'fp-emerald'">
|
<button @click="saleFilter = 'sellable'" class="fp" :class="saleFilter === 'sellable' && 'fp-emerald'">
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>启用
|
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>上架
|
||||||
</button>
|
</button>
|
||||||
<button @click="statusFilter = 'disabled'" class="fp" :class="statusFilter === 'disabled' && 'fp-white'">
|
<button @click="saleFilter = 'unsellable'" class="fp" :class="saleFilter === 'unsellable' && 'fp-white'">
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>停用
|
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>未上架
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-[200px]">
|
||||||
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名 / Provider…" />
|
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名…" />
|
||||||
</div>
|
</div>
|
||||||
<button @click="loadModels" class="btn-soft">
|
<button @click="loadModels" class="btn-soft">
|
||||||
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
|
||||||
</button>
|
</button>
|
||||||
<router-link to="/admin/channels" class="btn-primary inline-flex items-center gap-1">
|
|
||||||
<Icon name="plug" class="w-3.5 h-3.5" /> 渠道导入模型
|
|
||||||
</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
@@ -139,7 +122,8 @@ onMounted(loadModels)
|
|||||||
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
|
<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">
|
<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="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>
|
<span v-if="models.length" class="text-sm">没有匹配的模型</span>
|
||||||
|
<router-link v-else to="/admin/channels" class="text-sm text-sky-300 underline underline-offset-2">还没有模型,先到「渠道管理」添加渠道,保存后会自动同步上游模型</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table v-else class="w-full text-sm table-fixed">
|
<table v-else class="w-full text-sm table-fixed">
|
||||||
@@ -150,7 +134,7 @@ onMounted(loadModels)
|
|||||||
<col /> <!-- capability -->
|
<col /> <!-- capability -->
|
||||||
<col class="w-20" /> <!-- weight -->
|
<col class="w-20" /> <!-- weight -->
|
||||||
<col class="w-24" /> <!-- generation count -->
|
<col class="w-24" /> <!-- generation count -->
|
||||||
<col class="w-20" /> <!-- status switch -->
|
<col class="w-20" /> <!-- sellable badge -->
|
||||||
<col class="w-36" /> <!-- actions -->
|
<col class="w-36" /> <!-- actions -->
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -161,7 +145,7 @@ onMounted(loadModels)
|
|||||||
<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-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-right px-5 py-3 font-medium">操作</th>
|
<th class="text-right px-5 py-3 font-medium">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -268,13 +252,15 @@ onMounted(loadModels)
|
|||||||
{{ (m.generation_count || 0).toLocaleString('en-US') }}
|
{{ (m.generation_count || 0).toLocaleString('en-US') }}
|
||||||
</td>
|
</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">
|
<td class="px-3 py-3.5 align-middle">
|
||||||
<button class="sw" :class="m.enabled !== false && 'sw-on'"
|
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
|
||||||
:aria-pressed="m.enabled !== false" @click="toggleEnabled(m)">
|
:class="m.sellable
|
||||||
<span class="sw-thumb"></span>
|
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
|
||||||
</button>
|
: 'bg-white/[0.05] text-white/40 ring-white/10'"
|
||||||
|
:title="m.sellable ? '全部档位有价且有启用渠道,对前台可见' : '配齐全部档位价格并绑定启用渠道后自动上架'">
|
||||||
|
{{ m.sellable ? '已上架' : '未上架' }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Actions — small soft buttons, danger variant for delete -->
|
<!-- Actions — small soft buttons, danger variant for delete -->
|
||||||
@@ -378,27 +364,6 @@ onMounted(loadModels)
|
|||||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
|
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) */
|
/* --- danger variant for 删除 (kept for any other .btn-soft callers) */
|
||||||
.btn-soft.danger {
|
.btn-soft.danger {
|
||||||
color: rgb(253 164 175);
|
color: rgb(253 164 175);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const displayItems = computed(() => [...tasks.value, ...history.value].slice(0,
|
|||||||
|
|
||||||
// ---- derived ----
|
// ---- derived ----
|
||||||
const models = computed(() =>
|
const models = computed(() =>
|
||||||
allModels.value.filter((m) => m.enabled !== false && m.type === mode.value),
|
allModels.value.filter((m) => m.sellable && m.type === mode.value),
|
||||||
)
|
)
|
||||||
const modelOptions = computed(() =>
|
const modelOptions = computed(() =>
|
||||||
models.value.map((m) => ({ value: m.id, label: m.alias || m.name || m.id })),
|
models.value.map((m) => ({ value: m.id, label: m.alias || m.name || m.id })),
|
||||||
@@ -116,9 +116,8 @@ const maxRefs = computed(() => {
|
|||||||
const extra = familyPreset.value ? 0 : maxVideos.value + maxAudios.value
|
const extra = familyPreset.value ? 0 : maxVideos.value + maxAudios.value
|
||||||
return Math.max(a, b) + extra
|
return Math.max(a, b) + extra
|
||||||
}
|
}
|
||||||
// Image-to-image: honor the model's configured max (gpt-image-2=3,
|
// Image-to-image: honor the model's configured max (e.g. 3 or 6 refs, set per
|
||||||
// seedream-4.5=6, flux-klein-2=4 …). Fall back to 1 when image_to_image is on
|
// model). Fall back to 1 when image_to_image is on but no count was set.
|
||||||
// but no count was set.
|
|
||||||
const m = Number(model.value?.max_reference_images || 0)
|
const m = Number(model.value?.max_reference_images || 0)
|
||||||
if (m > 0) return m
|
if (m > 0) return m
|
||||||
return model.value?.image_to_image ? 1 : 0
|
return model.value?.image_to_image ? 1 : 0
|
||||||
@@ -231,7 +230,7 @@ function setMode(m) {
|
|||||||
mode.value = m
|
mode.value = m
|
||||||
clearRefs() // 切换生图/生视频清空参考文件
|
clearRefs() // 切换生图/生视频清空参考文件
|
||||||
// pick a default model of the new kind, if any
|
// pick a default model of the new kind, if any
|
||||||
const first = allModels.value.find((x) => x.enabled !== false && x.type === m)
|
const first = allModels.value.find((x) => x.sellable && x.type === m)
|
||||||
modelId.value = first?.id || ''
|
modelId.value = first?.id || ''
|
||||||
applyModelDefaults()
|
applyModelDefaults()
|
||||||
}
|
}
|
||||||
@@ -789,17 +788,17 @@ onMounted(async () => {
|
|||||||
if (qPrompt) prompt.value = qPrompt
|
if (qPrompt) prompt.value = qPrompt
|
||||||
let selected = null
|
let selected = null
|
||||||
if (qModel) {
|
if (qModel) {
|
||||||
selected = allModels.value.find((m) => m.id === qModel && m.enabled !== false)
|
selected = allModels.value.find((m) => m.id === qModel && m.sellable)
|
||||||
}
|
}
|
||||||
// If the draft already points at a still-available model AND no fresher
|
// 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
|
// intent came in from the URL, keep the draft as-is. Otherwise fall back to
|
||||||
// the first usable model.
|
// the first usable model.
|
||||||
const draftModel = !qModel && modelId.value
|
const draftModel = !qModel && modelId.value
|
||||||
? allModels.value.find((m) => m.id === modelId.value && m.enabled !== false)
|
? allModels.value.find((m) => m.id === modelId.value && m.sellable)
|
||||||
: null
|
: null
|
||||||
if (!selected && !draftModel) {
|
if (!selected && !draftModel) {
|
||||||
selected = allModels.value.find((m) => m.enabled !== false && m.type === 'image')
|
selected = allModels.value.find((m) => m.sellable && m.type === 'image')
|
||||||
|| allModels.value.find((m) => m.enabled !== false)
|
|| allModels.value.find((m) => m.sellable)
|
||||||
}
|
}
|
||||||
// Always re-apply defaults — even when restoring the persisted draft model —
|
// 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
|
// so a stale ratio/resolution that the model no longer supports (e.g. a saved
|
||||||
@@ -1077,7 +1076,7 @@ onUnmounted(() => {
|
|||||||
<div class="pg-cap text-[11px] leading-tight font-medium line-clamp-2 transition-colors"
|
<div class="pg-cap text-[11px] leading-tight font-medium line-clamp-2 transition-colors"
|
||||||
:class="item.prompt ? 'pointer-events-auto cursor-pointer' : ''"
|
:class="item.prompt ? 'pointer-events-auto cursor-pointer' : ''"
|
||||||
:title="item.prompt ? '点击复制提示词' : ''" @click.stop="copyPrompt(item)">{{ item.prompt }}</div>
|
:title="item.prompt ? '点击复制提示词' : ''" @click.stop="copyPrompt(item)">{{ item.prompt }}</div>
|
||||||
<div class="pg-cap-sub text-[9px] mt-0.5 font-mono truncate">{{ item.model }}<span v-if="item.deai"> · <span class="text-[#a78bfa]">去AI</span></span><span v-if="item.elapsed_ms"> · {{ (item.elapsed_ms / 1000).toFixed(1) }}s</span></div>
|
<div class="pg-cap-sub text-[9px] mt-0.5 font-mono truncate">{{ item.model }}<span v-if="item.deai"> · <span class="text-[#71717a]">去AI</span></span><span v-if="item.elapsed_ms"> · {{ (item.elapsed_ms / 1000).toFixed(1) }}s</span></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- pending / running -->
|
<!-- pending / running -->
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- API Key (REAL: minted + verified server-side; OpenAI-compatible) ----
|
// ---- API Key (REAL: minted + verified server-side; vivid /v1 protocol) ----
|
||||||
// The full plaintext is returned only once, right after minting. On reload we
|
// 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).
|
// only have the server's masked preview (the plaintext is never stored).
|
||||||
const apiKey = ref('') // full plaintext — present only right after minting
|
const apiKey = ref('') // full plaintext — present only right after minting
|
||||||
@@ -273,7 +273,7 @@ async function recharge() {
|
|||||||
<section class="lg:col-span-2 card overflow-hidden">
|
<section class="lg:col-span-2 card overflow-hidden">
|
||||||
<div class="p-7 md:p-8 relative">
|
<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"
|
<div class="absolute -top-16 -right-16 w-48 h-48 rounded-full opacity-30 blur-3xl"
|
||||||
style="background: radial-gradient(circle,#8b5cf6,transparent 60%)"></div>
|
style="background: radial-gradient(circle,#27272a,transparent 60%)"></div>
|
||||||
<div class="relative grid md:grid-cols-[260px_1fr] gap-8 items-start">
|
<div class="relative grid md:grid-cols-[260px_1fr] gap-8 items-start">
|
||||||
<div>
|
<div>
|
||||||
<div class="inline-grid w-10 h-10 rounded-xl bg-orange-500/15 ring-1 ring-orange-400/30 grid place-items-center text-orange-300">
|
<div class="inline-grid w-10 h-10 rounded-xl bg-orange-500/15 ring-1 ring-orange-400/30 grid place-items-center text-orange-300">
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// Admin → 首页内容
|
// Admin → 首页内容
|
||||||
// CRUD for the cards rendered on the public home page:
|
// CRUD for the cards rendered on the public home page:
|
||||||
// - hero : top-3-by-weight stacked deck in the hero
|
// - hero : top-4-by-weight collage in the hero
|
||||||
// - bento : "从一个起点开始" grid
|
|
||||||
// - work : "我们的作品" marquee — admin-curated featured outputs
|
// - work : "我们的作品" marquee — admin-curated featured outputs
|
||||||
// All three kinds use a real image as the background; admins pick one from
|
// Both kinds use a real image as the background; admins pick one from
|
||||||
// the already-generated files or paste an external URL.
|
// the already-generated files or paste an external URL.
|
||||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
import { api, jsonBody, generatedUrl } from '../api'
|
import { api, jsonBody, generatedUrl } from '../api'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const filter = ref('all') // 'all' | 'hero' | 'bento' | 'work'
|
const filter = ref('all') // 'all' | 'hero' | 'work'
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const editing = ref(null) // truthy when the form modal is open
|
const editing = ref(null) // truthy when the form modal is open
|
||||||
const picking = ref(false) // truthy when the image-picker modal is open
|
const picking = ref(false) // truthy when the image-picker modal is open
|
||||||
@@ -70,7 +69,7 @@ const pageNumbers = computed(() => {
|
|||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
const KIND_DEFAULT_WEIGHT = { hero: 200, bento: 300, work: 100 }
|
const KIND_DEFAULT_WEIGHT = { hero: 200, work: 100 }
|
||||||
|
|
||||||
function openNew(kind) {
|
function openNew(kind) {
|
||||||
editing.value = { kind }
|
editing.value = { kind }
|
||||||
@@ -174,7 +173,6 @@ function bgFor(image) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPAN_PRESETS = ['', 'md:col-span-2', 'md:row-span-2', 'md:col-span-2 md:row-span-2']
|
|
||||||
|
|
||||||
onMounted(refresh)
|
onMounted(refresh)
|
||||||
</script>
|
</script>
|
||||||
@@ -186,12 +184,10 @@ onMounted(refresh)
|
|||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<button @click="setFilter('all')" class="filter-pill" :class="filter === 'all' && 'on'">全部</button>
|
<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('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>
|
<button @click="setFilter('work')" class="filter-pill" :class="filter === 'work' && 'on'">我们的作品</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button @click="openNew('hero')" class="btn-soft">+ Hero</button>
|
<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>
|
<button @click="openNew('work')" class="btn-soft">+ Work</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +205,6 @@ onMounted(refresh)
|
|||||||
<div class="absolute top-3 left-3 flex items-center gap-1.5">
|
<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"
|
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded ring-1"
|
||||||
:class="rec.kind === 'hero' ? 'bg-rose-500/20 text-rose-200 ring-rose-400/30'
|
:class="rec.kind === 'hero' ? 'bg-rose-500/20 text-rose-200 ring-rose-400/30'
|
||||||
: rec.kind === 'bento' ? 'bg-orange-500/20 text-orange-200 ring-orange-400/30'
|
|
||||||
: 'bg-sky-500/20 text-sky-200 ring-sky-400/30'">
|
: 'bg-sky-500/20 text-sky-200 ring-sky-400/30'">
|
||||||
{{ rec.kind }}
|
{{ rec.kind }}
|
||||||
</span>
|
</span>
|
||||||
@@ -255,7 +250,7 @@ onMounted(refresh)
|
|||||||
<div class="px-5 py-3 border-b border-[color:var(--hairline)] flex items-center justify-between">
|
<div class="px-5 py-3 border-b border-[color:var(--hairline)] flex items-center justify-between">
|
||||||
<h2 class="text-sm font-semibold">
|
<h2 class="text-sm font-semibold">
|
||||||
{{ form.id ? '编辑' : '新增' }} ·
|
{{ form.id ? '编辑' : '新增' }} ·
|
||||||
{{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }}
|
{{ form.kind === 'hero' ? 'Hero 卡片' : '作品' }}
|
||||||
</h2>
|
</h2>
|
||||||
<button @click="closeForm" class="text-[color:var(--fg-faint)] hover:text-[color:var(--fg)]">
|
<button @click="closeForm" class="text-[color:var(--fg-faint)] hover:text-[color:var(--fg)]">
|
||||||
<Icon name="close" class="w-4 h-4" />
|
<Icon name="close" class="w-4 h-4" />
|
||||||
@@ -293,7 +288,6 @@ onMounted(refresh)
|
|||||||
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">类型</label>
|
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">类型</label>
|
||||||
<div class="flex gap-1.5">
|
<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 = '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>
|
<button type="button" @click="form.kind = 'work'" class="kind-btn" :class="form.kind === 'work' && 'on'">Work</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,7 +309,7 @@ onMounted(refresh)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">提示词 <span class="text-[color:var(--fg-faint)]">(点 Bento 后会预填到画图)</span></label>
|
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">提示词 <span class="text-[color:var(--fg-faint)]">(点图后会预填到画图)</span></label>
|
||||||
<textarea v-model="form.prompt" rows="3" class="field resize-none"
|
<textarea v-model="form.prompt" rows="3" class="field resize-none"
|
||||||
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
|
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,19 +320,6 @@ onMounted(refresh)
|
|||||||
<input v-model="form.title" class="field" placeholder="留空则只展示图片" />
|
<input v-model="form.title" class="field" placeholder="留空则只展示图片" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="form.kind === 'bento'">
|
|
||||||
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">网格跨度 <span class="text-[color:var(--fg-faint)]">(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-[color:var(--hairline)] hover:bg-[color:var(--hover)]"
|
|
||||||
:class="form.span === s ? 'bg-[color:var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)]' : 'bg-[color:var(--surface-2)] text-[color:var(--fg-2)]'">
|
|
||||||
{{ 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-500">{{ error }}</p>
|
<p v-if="error" class="text-xs text-rose-500">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ const params = (e) => {
|
|||||||
:title="e.error + ' — 点击复制'"
|
:title="e.error + ' — 点击复制'"
|
||||||
@click.stop="copyError(e)">⚠ {{ e.error }}</div>
|
@click.stop="copyError(e)">⚠ {{ e.error }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3 align-middle text-xs text-slate-500 tabular-nums">{{ params(e) || '—' }}<span v-if="e.deai"> · <span class="text-[#a78bfa]">去AI特征</span></span></td>
|
<td class="px-3 py-3 align-middle text-xs text-slate-500 tabular-nums">{{ params(e) || '—' }}<span v-if="e.deai"> · <span class="text-[#71717a]">去AI特征</span></span></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-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>
|
<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>
|
</tr>
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ onUnmounted(() => {
|
|||||||
<span v-if="e.resolution" class="text-emerald-300/90">{{ e.resolution }}</span>
|
<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.ratio" class="text-white/40">{{ e.ratio }}</span>
|
||||||
<span v-if="e.kind === 'video' && e.duration" class="text-rose-300/80">{{ e.duration }}</span>
|
<span v-if="e.kind === 'video' && e.duration" class="text-rose-300/80">{{ e.duration }}</span>
|
||||||
<span v-if="e.deai" class="text-[#a78bfa]">去AI</span>
|
<span v-if="e.deai" class="text-[#71717a]">去AI</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 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>
|
||||||
|
|||||||