feat: 并发分组系统 + 品牌/Logo 上传 + 兑换码开关 + 文档分辨率表

并发分组(新功能):
- 新表 concurrency_groups(名称/上限/默认),用户加 concurrency_group_id
- 启动自动建「默认并发」组(上限10、默认),老用户回填、新注册自动绑定
- 并发计数改用 Redis(自愈 sorted-set + TTL + fail-open):
  · 用户并发(画图台 + API key 合计)受其分组上限限制,0=不限制 → 超返回 429
  · 账号级并发也从内存 gate 换成同一套 Redis(6 处调用点)
  · 移除旧的「已有正在生成的任务」单任务锁
- 后台「并发分组」新菜单:增删改、设默认、用户数;默认组不可删(删别的组成员转默认)
- 用户管理:并发列 + 新建/编辑可选分组
- 个人设置页:账户信息卡(用户名/邮箱/角色/余额/并发);/me 暴露 concurrency_group/limit

品牌 / Logo(上传到 RustFS):
- Logo 改成拖拽/点击上传,点保存才上传;替换自动删旧;branding/ 设为公开且被清理任务 pin 住(永不删)
- 有自定义就用:前台左侧 nav + 后台侧栏 + favicon(浏览器标签);没有则默认 V 图标
- 前台页头还原成文字;首页 Hero 子标题用 site.subtitle(默认那句宣传语,设置页预填)
- 邮件验证码标题用站点名;新增 POST/DELETE /settings/logo + POST /settings/asset(首页底图上传)

兑换码开关:
- 系统设置→积分 新增「开启兑换码」(默认开);关闭后后端拒绝兑换、前台隐藏兑换入口(/site 暴露 cdk_redeem_enabled)

文档 / 分辨率:
- 去掉 quality 参数:size(宽x高)同时决定比例 + 分辨率档(长边映射 1K/2K/4K)
- 文档加「分辨率对照表」(14 个比例 × 1K/2K/4K → size 该传的值);guessRatio 与自定义模型 RATIO_OPTS 对齐到 14 个

其它:
- 删模型时同步清掉各上游账号「支持模型」里的该 id
- 首页设置/兑换码弹窗去固定高度滚动条;展示位弹窗浅色主题适配

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:52:31 +08:00
co-authored by Claude Opus 4.8
parent 1137d0bd1d
commit 5cf6206ee9
37 changed files with 1257 additions and 161 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ const contact = computed(() => site.contact || {})
class="group flex items-center justify-between gap-3 rounded-2xl bg-[var(--surface)] ring-1 ring-[color:var(--hairline)] p-5 hover:ring-[color:var(--fg-faint)] transition-all">
<div>
<div class="text-[10px] uppercase tracking-[0.25em] text-fuchsia-300/80">商店</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 group-hover:text-fuchsia-400 transition-colors">前往充值商店</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-1 group-hover:text-fuchsia-400 transition-colors">前往商店</div>
</div>
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform"></span>
</a>
+143
View File
@@ -0,0 +1,143 @@
<script setup>
import { ref, onMounted } from 'vue'
import { api, jsonBody } from '../api'
import Icon from '../components/Icon.vue'
const items = ref([])
const loading = ref(false)
const toast = ref('')
let toastTimer = null
function flash(msg) { toast.value = msg; clearTimeout(toastTimer); toastTimer = setTimeout(() => (toast.value = ''), 1800) }
async function load() {
loading.value = true
const r = await api('/concurrency-groups')
items.value = r.data?.data || []
loading.value = false
}
// add / edit modal
const editing = ref(null) // { id?, name, max_concurrency }
function openNew() { editing.value = { id: '', name: '', max_concurrency: 10 } }
function openEdit(g) { editing.value = { id: g.id, name: g.name, max_concurrency: g.max_concurrency } }
async function save() {
const e = editing.value
const body = { name: (e.name || '').trim(), max_concurrency: Math.max(0, Number(e.max_concurrency) || 0) }
if (!body.name) { flash('名称不能为空'); return }
const r = e.id
? await api(`/concurrency-groups/${e.id}`, jsonBody('PATCH', body))
: await api('/concurrency-groups', jsonBody('POST', body))
if (r.ok) { editing.value = null; flash('已保存'); load() }
else flash(r.data?.detail || '保存失败')
}
async function setDefault(g) {
if (g.is_default) return
const r = await api(`/concurrency-groups/${g.id}/default`, jsonBody('POST', {}))
if (r.ok) { flash('已设为默认注册分组'); load() }
else flash(r.data?.detail || '操作失败')
}
async function del(g) {
if (g.is_default) { flash('默认分组不可删除'); return }
if (!confirm(`删除分组「${g.name}」?其下 ${g.user_count} 个用户将转入默认分组。`)) return
const r = await api(`/concurrency-groups/${g.id}`, { method: 'DELETE' })
if (r.ok) { flash('已删除'); load() }
else flash(r.data?.detail || '删除失败')
}
onMounted(load)
</script>
<template>
<section class="theme-text space-y-4">
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-sm font-semibold">并发分组</h2>
<p class="text-xs text-white/45 mt-0.5">每个分组限制成员用户的<strong class="text-white/70">同时生成数</strong>(画图台 + API key 合计)<strong class="text-white/70">0 = 不限制</strong>新用户自动进入默认注册分组</p>
</div>
<button @click="openNew" class="btn-primary shrink-0">+ 新增分组</button>
</div>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
<th class="text-left px-5 py-3 font-medium">名称</th>
<th class="text-right px-3 py-3 font-medium">并发上限</th>
<th class="text-right px-3 py-3 font-medium">用户数</th>
<th class="text-left px-3 py-3 font-medium">默认注册</th>
<th class="text-right px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="5" class="text-center text-xs text-white/40 py-10">加载中</td></tr>
<tr v-else-if="!items.length"><td colspan="5" class="text-center text-xs text-white/40 py-10">还没有分组</td></tr>
<tr v-for="g in items" :key="g.id" class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90">{{ g.name }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums">
<span v-if="g.max_concurrency > 0" class="text-white/85">{{ g.max_concurrency }}</span>
<span v-else class="text-emerald-300/90">不限制</span>
</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/70">{{ g.user_count }}</td>
<td class="px-3 py-3.5 align-middle">
<span v-if="g.is_default" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium bg-fuchsia-500/10 text-fuchsia-300 ring-1 ring-fuchsia-400/30">
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span> 默认
</span>
<button v-else @click="setDefault(g)" class="btn-soft text-xs">设为默认</button>
</td>
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
<div class="inline-flex items-center gap-1">
<button @click="openEdit(g)" class="act" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
<button @click="del(g)" :disabled="g.is_default" class="act danger disabled:opacity-30 disabled:cursor-not-allowed" :title="g.is_default ? '默认分组不可删除' : '删除'"><Icon name="trash" class="w-3.5 h-3.5" /></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- add / edit modal -->
<div v-if="editing" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto" @click.self="editing = null">
<div class="card !shadow-2xl my-12 w-full max-w-sm">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">{{ editing.id ? '编辑分组' : '新增分组' }}</h2>
<button @click="editing = null" class="text-white/40 hover:text-white"><Icon name="close" class="w-5 h-5" /></button>
</div>
<div class="p-5 space-y-3">
<div>
<label class="block text-xs text-white/55 mb-1.5">名称</label>
<input v-model="editing.name" class="field" placeholder="如:VIP 并发 / 试用" />
</div>
<div>
<label class="block text-xs text-white/55 mb-1.5">并发上限 <span class="text-white/35">(0 = 不限制)</span></label>
<input v-model.number="editing.max_concurrency" type="number" min="0" class="field" />
</div>
<div class="flex justify-end gap-2 pt-1">
<button @click="editing = null" class="btn-soft">取消</button>
<button @click="save" class="btn-primary">保存</button>
</div>
</div>
</div>
</div>
<transition name="fade">
<div v-if="toast" class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg">{{ toast }}</div>
</transition>
</section>
</template>
<style scoped>
.act {
display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem; border-radius: 0.5rem;
color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s, color 0.15s;
}
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
.act.danger { color: rgb(253 164 175); background: rgb(244 63 94 / 0.12); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); }
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
+62 -17
View File
@@ -1,8 +1,9 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { api, jsonBody } from '../api'
import { site } from '../site'
import { site, applyFavicon } from '../site'
import TagInput from '../components/TagInput.vue'
import Logo from '../components/Logo.vue'
// ---- logs (retention window) ----
const logsCfg = reactive({ retention_days: 30 })
@@ -37,6 +38,9 @@ async function saveMedia() {
}
// ---- site (branding shown across the app) ----
// Default homepage 子标题 — shown on the home Hero when unset, and pre-filled
// into the input so the admin edits from it (same idea as 标题 defaulting to Vivid).
const DEFAULT_SUBTITLE = '把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。'
const siteForm = reactive({ title: '', logo: '', subtitle: '', qq: '', qq_link: '', qq_group: '', qq_group_link: '', email: '', shop: '' })
const siteBusy = ref(false); const siteSaved = ref(false)
async function loadSite() {
@@ -44,7 +48,7 @@ async function loadSite() {
if (r.ok && r.data) {
siteForm.title = r.data.title || ''
siteForm.logo = r.data.logo || ''
siteForm.subtitle = r.data.subtitle || ''
siteForm.subtitle = r.data.subtitle || DEFAULT_SUBTITLE
const c = r.data.contact || {}
siteForm.qq = c.qq || ''; siteForm.qq_link = c.qq_link || ''
siteForm.qq_group = c.qq_group || ''
@@ -52,27 +56,53 @@ async function loadSite() {
siteForm.email = c.email || ''; siteForm.shop = c.shop || ''
}
}
// ---- logo (drag/click to stage; uploaded to RustFS only on 保存) ----
const logoStaged = ref('') // dataUrl of a newly picked logo, pending save
const logoRemove = ref(false) // true → delete logo on save (back to default)
const logoDragOver = ref(false)
const logoInput = ref(null)
// What the preview shows: staged file > (removed → none) > current saved logo.
const logoPreview = computed(() => logoStaged.value || (logoRemove.value ? '' : (siteForm.logo || '')))
function pickLogo() { logoInput.value && logoInput.value.click() }
function readLogo(f) {
if (!f || !f.type || !f.type.startsWith('image/')) return
if (f.size > 4 * 1024 * 1024) { flashSite('logo 不能超过 4MB'); return }
const reader = new FileReader()
reader.onload = () => { logoStaged.value = reader.result; logoRemove.value = false }
reader.readAsDataURL(f)
}
function onLogoInput(ev) { readLogo((ev.target.files || [])[0]); if (ev.target) ev.target.value = '' }
function onLogoDrop(ev) { ev.preventDefault(); logoDragOver.value = false; readLogo((ev.dataTransfer?.files || [])[0]) }
function clearLogo() { logoStaged.value = ''; logoRemove.value = true } // 恢复默认
const siteErr = ref('')
function flashSite(msg) { siteErr.value = msg; setTimeout(() => (siteErr.value = ''), 2500) }
async function saveSite() {
siteBusy.value = true; siteSaved.value = false
siteBusy.value = true; siteSaved.value = false; siteErr.value = ''
// 1) logo first — upload the staged file (or delete) to RustFS, only on 保存.
if (logoStaged.value) {
const lr = await api('/settings/logo', jsonBody('POST', { data: logoStaged.value }))
if (lr.ok && lr.data?.logo != null) { siteForm.logo = lr.data.logo; site.logo = lr.data.logo; applyFavicon(site.logo); logoStaged.value = '' }
else { siteBusy.value = false; flashSite(lr.data?.detail || 'Logo 上传失败'); return }
} else if (logoRemove.value) {
await api('/settings/logo', { method: 'DELETE' })
siteForm.logo = ''; site.logo = ''; applyFavicon(''); logoRemove.value = false
}
// 2) the rest of the site form (logo is managed above, not sent here).
const r = await api('/settings/site', jsonBody('PUT', {
title: siteForm.title,
logo: siteForm.logo,
subtitle: siteForm.subtitle,
contact: { qq: siteForm.qq, qq_link: siteForm.qq_link, qq_group: siteForm.qq_group, qq_group_link: siteForm.qq_group_link, email: siteForm.email, shop: siteForm.shop },
}))
siteBusy.value = false
if (r.ok && r.data) {
site.logo = r.data.data?.logo ?? siteForm.logo.trim()
site.subtitle = r.data.data?.subtitle ?? siteForm.subtitle.trim()
// Mirror the change into the shared `site` store so every header /
// wordmark / tab title updates without a reload. The PUT response is
// nested ({ ok, data: { title } }) unlike the flat GET, so read the
// saved value from there — falling back to the input we just submitted.
// Mirror into the shared `site` store so headers / wordmark update without a reload.
site.title = r.data.data?.title || siteForm.title.trim()
site.contact = r.data.data?.contact || site.contact
siteSaved.value = true
setTimeout(() => (siteSaved.value = false), 2000)
}
} else flashSite(r.data?.detail || '保存失败')
}
// ---- registration ----
@@ -87,7 +117,7 @@ const smtp = reactive({ host: '', port: 587, username: '', password: '', from_ad
const smtpBusy = ref(false); const smtpSaved = ref(false)
// ---- rewards ----
const credits = reactive({ checkin_enabled: true, checkin_reward: 3, invite_enabled: true, invite_reward: 3 })
const credits = reactive({ checkin_enabled: true, checkin_reward: 3, invite_enabled: true, invite_reward: 3, cdk_redeem_enabled: true })
const credBusy = ref(false); const credSaved = ref(false)
// ---- proxy (carried when calling upstream during generation) ----
@@ -197,6 +227,7 @@ async function saveCredits() {
checkin_reward: Number(credits.checkin_reward) || 0,
invite_enabled: credits.invite_enabled,
invite_reward: Number(credits.invite_reward) || 0,
cdk_redeem_enabled: credits.cdk_redeem_enabled,
}))
credBusy.value = false
if (r.ok) { credSaved.value = true; setTimeout(() => (credSaved.value = false), 2000) }
@@ -218,13 +249,23 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadProxy();
<span><span class="lbl">网页主标题</span><span class="hint">显示在浏览器标签首页 Logo侧栏和登录卡上未设置时默认显示 "Vivid"</span></span>
<input v-model="siteForm.title" placeholder="Vivid" class="txt" />
</label>
<div class="row">
<span><span class="lbl">Logo</span><span class="hint">侧栏 / 公开页头部 / 浏览器标签的 Logo点击或拖拽图片到下图替换,保存设置后才上传到存储(替换会自动删旧图)下图当前显示的就是默认 Logo</span></span>
<div class="flex items-center gap-3">
<div @click="pickLogo" @drop="onLogoDrop" @dragover.prevent="logoDragOver = true" @dragleave="logoDragOver = false"
title="点击或拖拽图片替换"
class="w-16 h-16 rounded-xl grid place-items-center overflow-hidden shrink-0 cursor-pointer transition-all"
:class="logoDragOver ? 'ring-2 ring-indigo-400 bg-indigo-50/40' : ''">
<img v-if="logoPreview" :src="logoPreview" class="w-full h-full object-cover" />
<Logo v-else :size="64" class="w-full h-full" />
</div>
<input ref="logoInput" type="file" accept="image/*" class="hidden" @change="onLogoInput" />
</div>
</div>
<p v-if="siteErr" class="text-xs text-rose-500 -mt-1">{{ siteErr }}</p>
<label class="row">
<span><span class="lbl">Logo 图片地址</span><span class="hint">侧栏 / 公开页头部显示的 Logo 图片 URL留空则用文字主标题</span></span>
<input v-model="siteForm.logo" placeholder="https://.../logo.png" class="txt" />
</label>
<label class="row">
<span><span class="lbl">子标题</span><span class="hint">主标题下方的副标题 / slogan,公开页展示留空则不显示</span></span>
<input v-model="siteForm.subtitle" placeholder="如:聚合顶级 AI 模型的生图生视频平台" class="txt" />
<span><span class="lbl">子标题</span><span class="hint">首页 Hero 大标题下方那句话留空则显示默认:把脑海里的画面写成一句话,GPTGeminiFireflyFlux 等顶级模型替你变成图像与视频</span></span>
<input v-model="siteForm.subtitle" placeholder="留空 = 默认那句宣传语" class="txt" />
</label>
<label class="row">
<span><span class="lbl">联系 QQ</span><span class="hint">QQ (显示用)留空则不显示该项</span></span>
@@ -377,6 +418,10 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadProxy();
<span><span class="lbl">邀请奖励</span><span class="hint">被邀请好友首次生图后,邀请人获得的积分</span></span>
<input type="number" min="0" v-model.number="credits.invite_reward" :disabled="!credits.invite_enabled" class="num" />
</label>
<label class="row">
<span><span class="lbl">开启兑换码</span><span class="hint">关闭后用户无法兑换兑换码,前台也不再显示兑换入口</span></span>
<input type="checkbox" v-model="credits.cdk_redeem_enabled" class="sw" />
</label>
</div>
<div class="mt-4"><button @click="saveCredits" :disabled="credBusy" class="btn-primary">{{ credBusy ? '保存中…' : '保存设置' }}</button></div>
</div>
+57 -10
View File
@@ -43,15 +43,13 @@ function priceOf(m) {
const imageParams = [
['model', 'string', '必填', '模型 id,见上表(图像)'],
['prompt', 'string', '必填', '文字描述'],
['size', 'string', '可选', '"1024x1024" / "1536x1024" / "1024x1536" / "auto" → 决定比例'],
['quality', 'string', '可选', '"low"|"medium"|"high"|"auto" → 画质档 1K/2K/4K(钳到模型支持档)'],
['size', 'string', '可选', '宽x高,如 "1024x1024"。同时决定「比例」+「分辨率档」(按长边)。具体怎么填见下方对照表;留空 = 1:1 · 2K'],
]
const editParams = [
['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'],
['prompt', 'string', '必填', '编辑/参考描述'],
['model', 'string', '必填', '模型 id(需支持图生图)'],
['size', 'string', '可选', '同图像:决定比例'],
['quality', 'string', '可选', '同图像:决定画质档'],
['size', 'string', '可选', '同图像:决定比例 + 分辨率档(见下方对照表)'],
]
const videoParams = [
['model', 'string', '必填', '模型 id,见上表(视频)'],
@@ -61,6 +59,25 @@ const videoParams = [
['input_reference', 'file', '可选', '首帧/参考图(multipart 文件;runway 图生视频必填 1 张)'],
]
// ---- size → 比例 × 分辨率档 对照表(用 size 该传的值)----
// size 的长边映射档位:<1800→1K · 18003499→2K · ≥3500→4K;宽高比映射比例。
const sizeTable = [
{ ratio: '1:1 · 方', k1: '1024x1024', k2: '2048x2048', k4: '4096x4096' },
{ ratio: '5:4 · 横', k1: '1280x1024', k2: '2560x2048', k4: '3840x3072' },
{ ratio: '4:3 · 横', k1: '1024x768', k2: '2048x1536', k4: '4096x3072' },
{ ratio: '3:2 · 横', k1: '1200x800', k2: '2400x1600', k4: '3600x2400' },
{ ratio: '16:9 · 横', k1: '1280x720', k2: '2048x1152', k4: '4096x2304' },
{ ratio: '2:1 · 横', k1: '1440x720', k2: '2880x1440', k4: '4096x2048' },
{ ratio: '21:9 · 超宽', k1: '1680x720', k2: '2520x1080', k4: '5040x2160' },
{ ratio: '3:1 · 超宽', k1: '1536x512', k2: '2304x768', k4: '3840x1280' },
{ ratio: '4:5 · 竖', k1: '1024x1280', k2: '2048x2560', k4: '3072x3840' },
{ ratio: '3:4 · 竖', k1: '768x1024', k2: '1536x2048', k4: '3072x4096' },
{ ratio: '2:3 · 竖', k1: '800x1200', k2: '1600x2400', k4: '2400x3600' },
{ ratio: '9:16 · 竖', k1: '720x1280', k2: '1152x2048', k4: '2304x4096' },
{ ratio: '9:21 · 竖超宽', k1: '720x1680', k2: '1080x2520', k4: '2160x5040' },
{ ratio: '1:3 · 竖', k1: '512x1536', k2: '768x2304', k4: '1280x3840' },
]
// ---- examples (built in script so refs resolve correctly) ----
const examples = computed(() => [
{
@@ -72,8 +89,7 @@ const examples = computed(() => [
-d '{
"model": "${sampleImage.value}",
"prompt": "a corgi running in a golden wheat field, cinematic",
"size": "1024x1024",
"quality": "high"
"size": "2048x2048"
}'`,
},
{
@@ -87,8 +103,7 @@ client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
resp = client.images.generate(
model="${sampleImage.value}",
prompt="a corgi running in a golden wheat field, cinematic",
size="1024x1024",
quality="high",
size="2048x2048", # 2K · 1:1,见下方对照表
)
# 结果是 base64(无 URL)
with open("out.png", "wb") as f:
@@ -101,7 +116,7 @@ with open("out.png", "wb") as f:
-H "Authorization: Bearer ${keyHint.value}" \\
-F model="${sampleImage.value}" \\
-F prompt="把这张图改成赛博朋克风格" \\
-F quality="high" \\
-F size="2048x2048" \\
-F image=@input.png
# 多张参考图:重复 -F image=@a.png -F image=@b.png`,
},
@@ -314,6 +329,38 @@ async function copy(text) {
</div>
</section>
<!-- size 对照表(课时表) 解决"传错分辨率" -->
<section>
<h2 class="text-lg font-semibold mb-1">分辨率对照表 · <code class="text-white/70 text-sm">size</code> 该传什么</h2>
<p class="text-xs text-white/45 mb-3">
左边选比例,上面选分辨率档,交叉格里就是 <code class="text-white/70">size</code> 要传的值(直接复制)
没有 <code class="text-white/70">quality</code> 参数,分辨率只看 <code class="text-white/70">size</code> 的长边
档位必须是该模型支持的(见上方可用模型的分辨率列),不支持会自动回退到该模型最低档
</p>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">比例</th>
<th class="px-4 py-2.5 font-medium">1K</th>
<th class="px-4 py-2.5 font-medium">2K</th>
<th class="px-4 py-2.5 font-medium">4K</th>
</tr></thead>
<tbody>
<tr v-for="row in sizeTable" :key="row.ratio" class="border-b border-white/[0.04] last:border-0">
<td class="px-4 py-2.5 text-white/75">{{ row.ratio }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k1 }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k2 }}</td>
<td class="px-4 py-2.5 font-mono text-white/85">{{ row.k4 }}</td>
</tr>
</tbody>
</table>
</div>
<p class="text-xs text-white/40 mt-2">
:想要 <strong class="text-white/70">2K 16:9 横图</strong> <code class="text-white/70">"size": "2048x1152"</code>
留空 size = 默认 <strong class="text-white/70">1:1 · 2K</strong>
</p>
</section>
<!-- examples -->
<section class="space-y-4">
<h2 class="text-lg font-semibold">调用示例</h2>
@@ -340,7 +387,7 @@ async function copy(text) {
<li>完成后 <code class="text-white/85 font-mono">GET /v1/videos/{id}/content</code> 返回 <strong class="text-white/90">mp4 原始二进制</strong>( base64 URL)</li>
</ol>
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90"></strong>按上表价格从你的 Key 账号预扣积分;图像或视频上游失败会自动退回 失败不扣费</p>
<p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>比例,<code class="text-white/70">quality</code>(low/medium/high)画质档(1K/2K/4K,钳到模型支持档),<code class="text-white/70">seconds</code>视频时长参数须落在该模型定价表内,否则 400;余额不足 402</p>
<p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>(宽x高)同时决定<strong class="text-white/90">比例 + 分辨率档</strong>(长边:&lt;18001K · 180034992K · 35004K),<code class="text-white/70">seconds</code>视频时长<strong class="text-white/90">没有 quality 参数</strong>,分辨率只看 size档位须是该模型支持的(不支持会回退到该模型最低档);参数须落在定价表内否则 400,余额不足 402</p>
<div class="pt-2 grid sm:grid-cols-2 gap-2 text-xs">
<div class="flex items-center gap-2"><span class="badge-err">401</span> Key 无效 / 上游需重新授权</div>
<div class="flex items-center gap-2"><span class="badge-err">404</span> 未知 model / 视频任务不存在</div>
+1 -1
View File
@@ -139,7 +139,7 @@ function useExample(ex) {
</h1>
<p class="mt-8 text-base md:text-lg text-[color:var(--fg-2)] max-w-md leading-relaxed">
把脑海里的画面写成一句话,GPTGeminiFireflyFlux 等顶级模型替你变成图像与视频
{{ site.subtitle || '把脑海里的画面写成一句话,GPT、Gemini、Firefly、Flux 等顶级模型替你变成图像与视频。' }}
</p>
<div class="mt-10 flex items-center gap-4">
+39 -2
View File
@@ -97,6 +97,13 @@ async function changePwd() {
// ---- Credits: the REAL server-side balance of the logged-in user ----
// (admin adjustments in 用户管理 write this same field). Top-up is via CDK.
const balance = computed(() => Number(auth.user?.credits || 0))
const roleLabel = computed(() => ({ user: '普通用户', agent: '代理', admin: '管理员' }[auth.user?.role] || '普通用户'))
const concurrencyLabel = computed(() => {
const n = Number(auth.user?.concurrency_limit || 0)
const g = auth.user?.concurrency_group
if (n <= 0) return g ? `不限制 · ${g}` : '不限制'
return g ? `${n} · ${g}` : String(n)
})
const cdkCode = ref('')
const cdkBusy = ref(false)
@@ -184,6 +191,36 @@ function toast(m) {
</button>
</header>
<!-- ===== Account info ===== -->
<section class="card p-6">
<div class="flex items-center gap-2 mb-4">
<Icon name="accounts" class="w-4 h-4 text-violet-300" />
<h2 class="text-sm font-semibold">账户信息</h2>
</div>
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 text-sm">
<div>
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">用户名</div>
<div class="text-white/90 font-medium truncate" :title="auth.user?.name || ''">{{ auth.user?.name || '—' }}</div>
</div>
<div class="col-span-2 lg:col-span-1">
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">绑定邮箱</div>
<div class="text-white/90 font-mono text-xs break-all" :title="auth.user?.email || ''">{{ auth.user?.email || '—' }}</div>
</div>
<div>
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">角色</div>
<div class="text-white/90">{{ roleLabel }}</div>
</div>
<div>
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">积分余额</div>
<div class="text-amber-300 font-semibold tabular-nums">{{ pointsLabel(balance) }}</div>
</div>
<div>
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">并发上限</div>
<div class="text-white/90 truncate" :title="concurrencyLabel">{{ concurrencyLabel }}</div>
</div>
</div>
</section>
<!-- ===== Two-column grid ===== -->
<div class="grid lg:grid-cols-2 gap-5">
@@ -280,8 +317,8 @@ function toast(m) {
</button>
</section>
<!-- CDK REDEEM -->
<section class="relative card p-7 md:p-8 overflow-hidden">
<!-- CDK REDEEM hidden when the admin turns the 兑换码 switch off -->
<section v-if="site.cdkRedeemEnabled" class="relative card p-7 md:p-8 overflow-hidden">
<div class="inline-grid w-10 h-10 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 grid place-items-center text-emerald-300">
<Icon name="spark" class="w-4 h-4" />
</div>
+39 -17
View File
@@ -141,6 +141,28 @@ function pickImage(file) {
picking.value = false
}
// Upload a NEW image as 底图 — click/drag the preview. Stored public under
// branding/ in RustFS; form.image is set to the returned path immediately.
const scImgInput = ref(null)
const scDragOver = ref(false)
const uploadingImg = ref(false)
function pickShowcaseImg() { scImgInput.value && scImgInput.value.click() }
async function uploadShowcaseImg(f) {
if (!f || !f.type || !f.type.startsWith('image/')) return
if (f.size > 8 * 1024 * 1024) { error.value = '图片不能超过 8MB'; return }
uploadingImg.value = true; error.value = ''
const dataUrl = await new Promise((res, rej) => {
const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(f)
}).catch(() => '')
if (!dataUrl) { uploadingImg.value = false; error.value = '读取图片失败'; return }
const r = await api('/settings/asset', jsonBody('POST', { data: dataUrl }))
uploadingImg.value = false
if (r.ok && r.data?.path) form.image = r.data.path
else error.value = r.data?.detail || '上传失败'
}
function onScImgInput(ev) { uploadShowcaseImg((ev.target.files || [])[0]); if (ev.target) ev.target.value = '' }
function onScDrop(ev) { ev.preventDefault(); scDragOver.value = false; uploadShowcaseImg((ev.dataTransfer?.files || [])[0]) }
function bgFor(image) {
if (!image) return {}
const src = /^https?:\/\//i.test(image) ? image : generatedUrl(image)
@@ -240,19 +262,30 @@ onMounted(refresh)
</div>
<div class="p-5 space-y-4">
<!-- live preview -->
<div class="relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[5/2] bg-white/[0.04]"
:style="bgFor(form.image)">
<!-- live preview click or drag an image here to upload as 底图 -->
<div class="relative rounded-2xl overflow-hidden ring-1 ring-white/10 aspect-[5/2] bg-white/[0.04] cursor-pointer transition-all"
:class="scDragOver ? 'ring-2 ring-indigo-400' : ''"
:style="bgFor(form.image)"
@click="pickShowcaseImg" @drop="onScDrop" @dragover.prevent="scDragOver = true" @dragleave="scDragOver = false">
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent"></div>
<div v-if="!form.image" class="absolute inset-0 grid place-items-center text-xs text-white/40">
未选择底图
<button type="button" @click.stop="openPicker"
class="absolute top-2 right-2 z-10 inline-flex items-center gap-1 rounded-lg bg-black/55 ring-1 ring-white/15 hover:bg-black/75 text-white text-[11px] px-2.5 py-1.5 transition-colors">
<Icon name="files" class="w-3.5 h-3.5" /> 选择已生成
</button>
<div v-if="!form.image" class="absolute inset-0 grid place-items-center text-xs text-white/60">
<div class="text-center">
<Icon name="plus" class="w-6 h-6 mx-auto mb-1 opacity-80" />
{{ uploadingImg ? '上传中…' : '点击或拖拽图片上传底图' }}
</div>
</div>
<div class="absolute inset-x-0 bottom-0 p-5">
<div v-else-if="uploadingImg" class="absolute inset-0 grid place-items-center bg-black/40 text-xs text-white">上传中</div>
<div class="absolute inset-x-0 bottom-0 p-5 pointer-events-none">
<div v-if="form.subtitle" class="text-[10px] uppercase tracking-[0.3em] text-white/55">{{ form.subtitle }}</div>
<div v-if="form.title" class="text-xl font-bold text-white mt-1">{{ form.title }}</div>
<div v-if="form.prompt" class="text-xs text-white/65 mt-1 line-clamp-2">{{ form.prompt }}</div>
</div>
</div>
<input ref="scImgInput" type="file" accept="image/*" class="hidden" @change="onScImgInput" />
<div class="grid sm:grid-cols-2 gap-3">
<div>
@@ -269,17 +302,6 @@ onMounted(refresh)
</div>
</div>
<!-- image picker (the central change admins pick a real image) -->
<div>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">底图</label>
<div class="flex gap-2">
<input v-model="form.image" class="field font-mono text-[11px]"
placeholder="user/abc.png 或 https://…" />
<button type="button" @click="openPicker" class="btn-soft shrink-0">选择已生成</button>
</div>
<p class="text-[11px] text-[color:var(--fg-faint)] mt-1">填写 /generated 下的相对路径,或粘贴一个外链 URL</p>
</div>
<template v-if="form.kind !== 'work'">
<div class="grid sm:grid-cols-2 gap-3">
<div>
+38 -3
View File
@@ -20,7 +20,7 @@ const showAdd = ref(false)
const editing = ref(null)
const toast = ref('')
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0, notes: '' })
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0, notes: '', concurrency_group_id: '' })
const STATUS_OPTIONS = [
{ value: 'active', label: '正常' },
@@ -35,6 +35,26 @@ const ROLE_OPTIONS = [
]
const roleLabel = (r) => ({ user: '用户', agent: '代理', admin: '管理员' }[r] || '用户')
// Concurrency groups — resolve a user's group id → name/limit for the table,
// and offer them in the edit form.
const cgroups = ref([])
const cgroupOptions = computed(() => cgroups.value.map((g) => ({ value: g.id, label: g.name })))
const cgroupById = computed(() => Object.fromEntries(cgroups.value.map((g) => [g.id, g])))
function cgroupLabel(id) {
const g = cgroupById.value[id]
if (!g) return '—'
return g.max_concurrency > 0 ? `${g.name} · ${g.max_concurrency}` : `${g.name} · 不限`
}
async function loadGroups() {
const r = await api('/concurrency-groups')
cgroups.value = r.data?.data || []
// Default the 新建用户 form to the default registration group.
if (!addForm.value.concurrency_group_id) {
const def = cgroups.value.find((g) => g.is_default) || cgroups.value[0]
if (def) addForm.value.concurrency_group_id = def.id
}
}
async function load() {
loading.value = true
const r = await api('/users')
@@ -42,7 +62,7 @@ async function load() {
stats.value = r.data?.stats || stats.value
loading.value = false
}
onMounted(load)
onMounted(() => { load(); loadGroups() })
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
@@ -100,7 +120,8 @@ async function createUser() {
const r = await api('/users', jsonBody('POST', addForm.value))
if (r.ok) {
showAdd.value = false
addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0, notes: '' }
const def = cgroups.value.find((g) => g.is_default) || cgroups.value[0]
addForm.value = { email: '', name: '', password: '', role: 'user', credits: 0, notes: '', concurrency_group_id: def ? def.id : '' }
flash('用户已创建')
load()
} else flash(r.data?.detail || '创建失败')
@@ -116,6 +137,7 @@ async function saveEdit() {
credits: u.credits,
role: u.role,
notes: u.notes || '',
concurrency_group_id: u.concurrency_group_id || '',
}
if (u._newPassword) patch.password = u._newPassword
const r = await api(`/users/${u.id}`, jsonBody('PATCH', patch))
@@ -246,6 +268,7 @@ async function quickCredits(u, delta) {
<col class="w-40" /> <!-- username -->
<col /> <!-- email (flex) -->
<col class="w-36" /> <!-- notes -->
<col class="w-28" /> <!-- concurrency -->
<col class="w-20" /> <!-- role -->
<col class="w-16" /> <!-- status switch -->
<col class="w-24" /> <!-- credits -->
@@ -264,6 +287,7 @@ async function quickCredits(u, delta) {
<th class="text-left px-5 py-3 font-medium">用户名</th>
<th class="text-left px-3 py-3 font-medium">邮箱</th>
<th class="text-left px-3 py-3 font-medium">备注</th>
<th class="text-left px-3 py-3 font-medium">并发</th>
<th class="text-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>
@@ -290,6 +314,9 @@ async function quickCredits(u, delta) {
<td class="px-3 py-3.5 align-middle text-xs truncate" :class="u.notes ? 'text-white/70' : 'text-white/25'" :title="u.notes || ''">
{{ u.notes || '—' }}
</td>
<td class="px-3 py-3.5 align-middle text-xs truncate text-white/70" :title="cgroupLabel(u.concurrency_group_id)">
{{ cgroupLabel(u.concurrency_group_id) }}
</td>
<td class="px-3 py-3.5 align-middle">
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
:class="u.role === 'admin'
@@ -396,6 +423,10 @@ async function quickCredits(u, delta) {
<label class="lbl">角色</label>
<SelectMenu v-model="addForm.role" :options="ROLE_OPTIONS" />
</div>
<div>
<label class="lbl">并发分组</label>
<SelectMenu v-model="addForm.concurrency_group_id" :options="cgroupOptions" placeholder="选择分组" />
</div>
<div>
<label class="lbl">备注 <span class="text-white/35">(可选)</span></label>
<textarea v-model="addForm.notes" rows="2" class="field resize-none" placeholder="给该用户加个备注,仅管理员可见"></textarea>
@@ -444,6 +475,10 @@ async function quickCredits(u, delta) {
<label class="lbl">积分</label>
<input v-model.number="editing.credits" type="number" min="0" step="1" class="field" />
</div>
<div>
<label class="lbl">并发分组 <span class="text-white/35">(限制同时生成数)</span></label>
<SelectMenu v-model="editing.concurrency_group_id" :options="cgroupOptions" placeholder="选择分组" />
</div>
<div>
<label class="lbl">备注 <span class="text-white/35">(可选)</span></label>
<textarea v-model="editing.notes" rows="2" class="field resize-none" placeholder="给该用户加个备注,仅管理员可见"></textarea>