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:
@@ -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>
|
||||
Reference in New Issue
Block a user