M0-M4: 推倒重来基线(基建+用户/密钥/核心代理+前端+管理后台+三协议互转)

- 后端 Go+Gin+GORM: 配置(OT_ env)/SQLite/Postgres 双驱动、用户体系(argon2id+JWT access/refresh)、
  API Key(sk- 48位, 仅存 SHA-256 哈希)
- 代理网关: /v1/chat/completions、/v1/responses、/v1/messages、/v1/models;错误按客户端协议返回
- 三协议互转(convert 包): Chat↔Messages↔Responses 请求/响应 + 流式 SSE 逐事件转换(直通优先)
- 用量计费: 异步批量记账、余额扣减、balance_logs、usage_daily 日聚合
- 管理 API: 用户/渠道 CRUD+测试+模型导入/模型定价+绑定/统计/系统配置
- 前端 Vue3+TS+Tailwind(taste-skill 设计 tokens): Landing/登录注册/控制台/管理后台,
  自建组件+Phosphor 图标+自建 SVG 趋势图, 已过 web-design-guidelines 复查
- mock 上游: OpenAI+Anthropic 双协议模拟(含流式)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 15:34:06 +08:00
co-authored by Claude
parent b25e9ec8a7
commit ec4de8d913
92 changed files with 6203 additions and 3064 deletions
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ShellLayout from '@/components/layout/ShellLayout.vue'
const auth = useAuthStore()
const sections = computed(() => {
const s: { title: string; items: { to: string; label: string }[] }[] = [
{ title: '管理', items: [
{ to: '/admin/overview', label: '运营总览' },
{ to: '/admin/channels', label: '渠道' },
{ to: '/admin/models', label: '模型与定价' },
{ to: '/admin/users', label: '用户' },
{ to: '/admin/config', label: '系统配置' },
] },
]
if (auth.user) {
s.push({ title: '控制台', items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API Keys' },
{ to: '/console/usage', label: '用量' },
] })
}
return s
})
</script>
<template>
<ShellLayout :sections="sections">
<router-view />
</ShellLayout>
</template>
+216
View File
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { Channel } from '@/types'
const toast = useToastStore()
const channels = ref<Channel[]>([])
const editOpen = ref(false)
const editing = ref<Channel | null>(null)
const saving = ref(false)
const busyId = ref<number | null>(null)
const form = reactive({
name: '',
provider: 'openai' as 'openai' | 'anthropic' | 'compatible',
base_url: '',
api_key: '',
weight: 1,
priority: 0,
timeout_ms: 120000,
max_concurrency: 16,
enabled: true,
})
const providerMap: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
compatible: '兼容',
}
async function load() {
try {
const { data } = await http.get('/admin/channels')
channels.value = data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
function openCreate() {
editing.value = null
Object.assign(form, {
name: '', provider: 'openai', base_url: '', api_key: '',
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
})
editOpen.value = true
}
function openEdit(ch: Channel) {
editing.value = ch
Object.assign(form, {
name: ch.name, provider: ch.provider, base_url: ch.base_url, api_key: '',
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
})
editOpen.value = true
}
async function save() {
saving.value = true
const payload = {
...form,
weight: Number(form.weight),
priority: Number(form.priority),
timeout_ms: Number(form.timeout_ms),
max_concurrency: Number(form.max_concurrency),
}
try {
if (editing.value) {
await http.put(`/admin/channels/${editing.value.id}`, payload)
toast.ok('渠道已更新')
} else {
await http.post('/admin/channels', payload)
toast.ok('渠道已创建')
}
editOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
} finally {
saving.value = false
}
}
async function remove(ch: Channel) {
if (!confirm(`删除渠道 ${ch.name}?关联的模型绑定也会清除。`)) return
try {
await http.delete(`/admin/channels/${ch.id}`)
toast.ok('渠道已删除')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
async function testChannel(ch: Channel) {
busyId.value = ch.id
try {
await http.post(`/admin/channels/${ch.id}/test`)
toast.ok(`渠道 ${ch.name} 连接正常`)
} catch (e) {
toast.err(`连接失败: ${errMsg(e)}`)
} finally {
busyId.value = null
await load()
}
}
async function importModels(ch: Channel) {
busyId.value = ch.id
try {
const { data } = await http.post(`/admin/channels/${ch.id}/models/import`)
toast.ok(`已导入 ${data.data.imported} 个模型`)
} catch (e) {
toast.err(errMsg(e))
} finally {
busyId.value = null
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold">渠道</h1>
<p class="text-sm text-zinc-500">接入上游服务,API Key 加密存储</p>
</div>
<Button @click="openCreate">添加渠道</Button>
</div>
<div class="card">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">类型</th>
<th scope="col" class="px-4 py-2.5 font-medium">Base URL</th>
<th scope="col" class="px-4 py-2.5 font-medium">Key</th>
<th scope="col" class="px-4 py-2.5 font-medium">健康</th>
<th scope="col" class="px-4 py-2.5 font-medium">启用</th>
<th scope="col" class="px-4 py-2.5" />
</tr>
</thead>
<tbody>
<tr v-for="ch in channels" :key="ch.id" class="table-row">
<td class="px-4 py-2.5 text-zinc-200">{{ ch.name }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ providerMap[ch.provider] }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ ch.base_url }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600">{{ ch.api_key_masked || '****' }}</td>
<td class="px-4 py-2.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }}
</Badge>
</td>
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ ch.enabled ? '是' : '否' }}</td>
<td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2">
<button class="text-xs text-zinc-500 hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button class="text-xs text-zinc-500 hover:text-accent" @click="importModels(ch)">导入模型</button>
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(ch)">编辑</button>
<button class="text-xs text-zinc-500 hover:text-red-400" @click="remove(ch)">删除</button>
</div>
</td>
</tr>
<tr v-if="channels.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">还没有渠道,点击「添加渠道」</td>
</tr>
</tbody>
</table>
</div>
</div>
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<Input v-model="form.name" label="名称" placeholder="openai" />
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">API 类型</span>
<select v-model="form.provider" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="compatible">兼容</option>
</select>
</label>
</div>
<Input v-model="form.base_url" label="Base URL" placeholder="https://api.openai.com" />
<Input
v-model="form.api_key"
label="上游 API Key"
:placeholder="editing ? '留空则不修改' : 'sk-...'"
/>
<div class="grid grid-cols-2 gap-4">
<Input v-model="form.weight" label="权重" type="number" />
<Input v-model="form.priority" label="优先级" type="number" />
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
<Input v-model="form.max_concurrency" label="最大并发" type="number" />
</div>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template>
</Modal>
</div>
</template>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import Button from '@/components/ui/Button.vue'
const toast = useToastStore()
const config = reactive<Record<string, string>>({})
const loading = ref(false)
const saving = ref(false)
async function load() {
loading.value = true
try {
const { data } = await http.get('/admin/config')
Object.keys(config).forEach((k) => delete config[k])
Object.assign(config, data.data.config)
} catch (e) {
toast.err(errMsg(e))
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
await http.put('/admin/config', config)
toast.ok('配置已保存')
} catch (e) {
toast.err(errMsg(e))
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-2xl">
<div class="mb-6">
<h1 class="text-lg font-semibold">系统配置</h1>
<p class="text-sm text-zinc-500">注册策略等平台级配置</p>
</div>
<div class="card space-y-5 p-6">
<div v-if="loading" class="py-8 text-center text-sm text-zinc-600">加载中…</div>
<template v-else>
<div class="space-y-4">
<div>
<p class="mb-1.5 text-xs font-medium text-zinc-400">注册模式</p>
<div class="flex gap-2">
<button
class="flex-1 rounded-md border px-3 py-2 text-sm transition"
:class="config.registration_mode === 'open' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'"
@click="config.registration_mode = 'open'"
>
开放注册
</button>
<button
class="flex-1 rounded-md border px-3 py-2 text-sm transition"
:class="config.registration_mode === 'invite' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'"
@click="config.registration_mode = 'invite'"
>
邀请码
</button>
</div>
<p class="mt-1.5 text-xs text-zinc-600">邀请模式下注册需填写有效邀请码(invite_codes 配置)</p>
</div>
<div>
<p class="mb-1.5 text-xs font-medium text-zinc-400">邀请码(逗号分隔)</p>
<input
v-model="config.invite_codes"
placeholder="code1,code2"
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 font-mono text-xs outline-none focus:border-accent"
/>
</div>
<div class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-900/60 px-4 py-3">
<div>
<p class="text-sm text-zinc-300">其他配置项</p>
<p class="text-xs text-zinc-600">汇率、限流阈值、维护开关在后续里程碑开放</p>
</div>
<span class="font-mono text-xs text-zinc-600">M3+</span>
</div>
</div>
<div class="flex justify-end">
<Button :loading="saving" @click="save">保存</Button>
</div>
</template>
</div>
</div>
</template>
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { Channel, Model } from '@/types'
const toast = useToastStore()
const models = ref<Model[]>([])
const channels = ref<Channel[]>([])
const editOpen = ref(false)
const editing = ref<Model | null>(null)
const saving = ref(false)
const bindOpen = ref(false)
const bindModel = ref<Model | null>(null)
const binding = reactive({ channel_id: 0, upstream_model: '', weight: 1 })
const form = reactive({
name: '',
display_name: '',
input_price: 0,
output_price: 0,
cache_read_price: 0,
enabled: true,
})
async function load() {
try {
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')])
models.value = m.data.data.items
channels.value = c.data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
function openCreate() {
editing.value = null
Object.assign(form, { name: '', display_name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
editOpen.value = true
}
function openEdit(m: Model) {
editing.value = m
Object.assign(form, {
name: m.name, display_name: m.display_name,
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
enabled: m.enabled,
})
editOpen.value = true
}
async function save() {
saving.value = true
const payload = {
display_name: form.display_name || form.name,
input_price: Number(form.input_price),
output_price: Number(form.output_price),
cache_read_price: Number(form.cache_read_price),
enabled: form.enabled,
}
try {
if (editing.value) {
await http.put(`/admin/models/${editing.value.id}`, payload)
toast.ok('模型已更新')
} else {
await http.post('/admin/models', { name: form.name, ...payload })
toast.ok('模型已创建')
}
editOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
} finally {
saving.value = false
}
}
async function removeModel(m: Model) {
if (!confirm(`删除模型 ${m.name}?`)) return
try {
await http.delete(`/admin/models/${m.id}`)
toast.ok('模型已删除')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
function openBind(m: Model) {
bindModel.value = m
Object.assign(binding, { channel_id: channels.value[0]?.id ?? 0, upstream_model: m.name, weight: 1 })
bindOpen.value = true
}
async function saveBinding() {
if (!bindModel.value) return
try {
await http.post(`/admin/models/${bindModel.value.id}/bindings`, {
...binding,
weight: Number(binding.weight),
})
toast.ok('绑定已添加')
bindOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
async function removeBinding(m: Model, bid: number) {
try {
await http.delete(`/admin/models/${m.id}/bindings/${bid}`)
toast.ok('绑定已移除')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold">模型与定价</h1>
<p class="text-sm text-zinc-500">价格按每百万 token (USD),历史用量按当时价格入账</p>
</div>
<Button @click="openCreate">添加模型</Button>
</div>
<div class="space-y-3">
<div v-for="m in models" :key="m.id" class="card">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<span class="font-mono text-sm text-zinc-100">{{ m.name }}</span>
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
</div>
<div class="flex items-center gap-3">
<span class="mono-num text-xs text-zinc-400">入 {{ m.input_price }}</span>
<span class="mono-num text-xs text-zinc-400">出 {{ m.output_price }}</span>
<span class="mono-num text-xs text-zinc-500">缓存读 {{ m.cache_read_price }}</span>
</div>
<div class="flex gap-2">
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBind(m)">绑定渠道</button>
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(m)">编辑</button>
<button class="text-xs text-zinc-500 hover:text-red-400" @click="removeModel(m)">删除</button>
</div>
</div>
<div v-if="m.channels.length" class="border-t border-zinc-800/70 px-4 py-2">
<div class="flex flex-wrap gap-2">
<span
v-for="b in m.channels"
:key="b.id"
class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 font-mono text-[11px] text-zinc-400"
>
{{ b.channel_name }} → {{ b.upstream_model }}
<button class="text-zinc-400 hover:text-red-400" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button>
</span>
</div>
</div>
<p v-else class="border-t border-zinc-800/70 px-4 py-2 text-xs text-zinc-600">
未绑定渠道,客户端无法调用该模型
</p>
</div>
<p v-if="models.length === 0" class="card px-4 py-10 text-center text-sm text-zinc-600">
还没有模型,点击「添加模型」或到渠道页「导入模型」
</p>
</div>
<!-- 模型编辑 -->
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
<div class="space-y-4">
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
<Input v-model="form.display_name" label="展示名" />
<div class="grid grid-cols-2 gap-4">
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
</div>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template>
</Modal>
<!-- 绑定渠道 -->
<Modal :open="bindOpen" title="绑定渠道" @close="bindOpen = false">
<div class="space-y-4">
<p class="text-xs text-zinc-500">模型 <span class="font-mono text-emerald-300">{{ bindModel?.name }}</span> 通过以下渠道提供</p>
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">渠道</span>
<select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent">
<option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.name }}</option>
</select>
</label>
<Input v-model="binding.upstream_model" label="上游模型名" placeholder="与渠道侧一致" />
<Input v-model="binding.weight" label="权重" type="number" />
</div>
<template #footer>
<Button variant="ghost" @click="bindOpen = false">取消</Button>
<Button @click="saveBinding">绑定</Button>
</template>
</Modal>
</div>
</template>
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue'
import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types'
const toast = useToastStore()
const data = ref({
total_users: 0, total_keys: 0, total_channels: 0, total_models: 0,
today: { requests: 0, cost: 0, tokens: 0 },
month: { requests: 0, cost: 0, tokens: 0 },
trend_14d: [] as { date: string; requests: number; cost: number }[],
})
const logs = ref<UsageLog[]>([])
async function load() {
try {
const [o, u] = await Promise.all([http.get('/admin/stats/overview'), http.get('/admin/usage?page_size=8')])
data.value = o.data.data
logs.value = u.data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl space-y-6">
<div class="mb-2">
<h1 class="text-lg font-semibold">运营总览</h1>
<p class="text-sm text-zinc-500">全局用户、渠道与营收</p>
</div>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="card p-4">
<p class="text-xs text-zinc-500">用户 / 密钥</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_users) }} / {{ fmtNum(data.total_keys) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-zinc-500">渠道 / 模型</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_channels) }} / {{ fmtNum(data.total_models) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-zinc-500">今日请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.today.requests) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-zinc-500">本月营收</p>
<p class="mono-num mt-1 text-xl text-emerald-300">{{ fmtCost(data.month.cost) }}</p>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-5">
<div class="card p-5 lg:col-span-3">
<div class="mb-4 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">近 14 天全局成本</h2>
<span class="font-mono text-[11px] text-zinc-600">{{ fmtCost(data.month.cost) }} / 本月</span>
</div>
<TrendChart
:points="data.trend_14d.map((t) => ({ label: t.date.slice(5), value: t.cost }))"
:format="(v) => '$' + v.toExponential(2)"
/>
</div>
<div class="card p-5 lg:col-span-2">
<div class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">全局最近请求</h2>
<span class="font-mono text-[11px] text-zinc-600">共 {{ data.month.requests }} / 月</span>
</div>
<ul class="divide-y divide-zinc-800/70">
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2">
<div class="min-w-0">
<p class="truncate text-xs text-zinc-300">
<span class="font-mono text-emerald-400/80">{{ l.user }}</span> · {{ l.model }}
</p>
<p class="font-mono text-[11px] text-zinc-600">{{ fmtTime(l.created_at) }}</p>
</div>
<div class="flex items-center gap-2">
<span class="mono-num text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span>
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
</div>
</li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600">暂无请求</li>
</ul>
</div>
</div>
</div>
</template>
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { useAuthStore } from '@/stores/auth'
import { fmtMoney, fmtTime } from '@/lib/format'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { User } from '@/types'
const toast = useToastStore()
const auth = useAuthStore()
const users = ref<User[]>([])
const total = ref(0)
const page = ref(1)
const q = ref('')
const pageSize = 15
const editOpen = ref(false)
const editing = ref<User | null>(null)
const editForm = reactive({ role: 'user', status: 'active' })
const balanceOpen = ref(false)
const balanceUser = ref<User | null>(null)
const balanceForm = reactive({ amount: 0, remark: '' })
async function load() {
try {
const { data } = await http.get(`/admin/users?page=${page.value}&page_size=${pageSize}${q.value ? '&q=' + q.value : ''}`)
users.value = data.data.items
total.value = data.data.total
} catch (e) {
toast.err(errMsg(e))
}
}
function search() {
page.value = 1
load()
}
function openEdit(u: User) {
editing.value = u
Object.assign(editForm, { role: u.role, status: u.status })
editOpen.value = true
}
async function saveEdit() {
if (!editing.value) return
try {
await http.patch(`/admin/users/${editing.value.id}`, editForm)
toast.ok('已更新')
editOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
function openBalance(u: User) {
balanceUser.value = u
Object.assign(balanceForm, { amount: 0, remark: '' })
balanceOpen.value = true
}
async function saveBalance() {
if (!balanceUser.value || !balanceForm.amount) return
try {
await http.post(`/admin/users/${balanceUser.value.id}/balance`, {
amount: Number(balanceForm.amount),
remark: balanceForm.remark,
})
toast.ok('余额已调整')
balanceOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
function goPage(p: number) {
page.value = p
load()
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold">用户</h1>
<p class="text-sm text-zinc-500">管理角色、状态与余额</p>
</div>
<div class="flex gap-2">
<input
v-model="q"
placeholder="搜索用户名 / 邮箱"
class="h-10 w-56 rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent"
@keyup.enter="search"
/>
<Button variant="ghost" @click="search">搜索</Button>
</div>
</div>
<div class="card">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th scope="col" class="px-4 py-2.5 font-medium">ID</th>
<th scope="col" class="px-4 py-2.5 font-medium">用户名</th>
<th scope="col" class="px-4 py-2.5 font-medium">邮箱</th>
<th scope="col" class="px-4 py-2.5 font-medium">角色</th>
<th scope="col" class="px-4 py-2.5 font-medium">余额</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
<th scope="col" class="px-4 py-2.5 font-medium">注册时间</th>
<th scope="col" class="px-4 py-2.5" />
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id" class="table-row">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ u.id }}</td>
<td class="px-4 py-2.5 text-zinc-200">
{{ u.username }}
<span v-if="u.id === auth.user?.id" class="text-[11px] text-zinc-600">(我)</span>
</td>
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ u.email }}</td>
<td class="px-4 py-2.5">
<Badge :variant="u.role === 'admin' ? 'accent' : 'neutral'">{{ u.role }}</Badge>
</td>
<td class="px-4 py-2.5 mono-num text-xs text-emerald-300/90">{{ fmtMoney(u.balance) }}</td>
<td class="px-4 py-2.5">
<Badge :variant="u.status === 'active' ? 'ok' : 'warn'">{{ u.status }}</Badge>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(u.created_at) }}</td>
<td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2">
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(u)">编辑</button>
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBalance(u)">调余额</button>
</div>
</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="8" class="px-4 py-10 text-center text-sm text-zinc-600">无用户</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3">
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 人</span>
<div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
</div>
</div>
</div>
<!-- 编辑用户 -->
<Modal :open="editOpen" title="编辑用户" @close="editOpen = false">
<div class="space-y-4">
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">角色</span>
<select v-model="editForm.role" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</label>
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">状态</span>
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent">
<option value="active">active</option>
<option value="disabled">disabled</option>
</select>
</label>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button @click="saveEdit">保存</Button>
</template>
</Modal>
<!-- 调整余额 -->
<Modal :open="balanceOpen" :title="`调整余额 · ${balanceUser?.username}`" @close="balanceOpen = false">
<div class="space-y-4">
<p class="text-xs text-zinc-500">当前余额 {{ fmtMoney(balanceUser?.balance ?? 0) }}</p>
<Input v-model="balanceForm.amount" label="调整金额" type="number" hint="正数增加,负数扣减" />
<Input v-model="balanceForm.remark" label="备注" placeholder="可选" />
</div>
<template #footer>
<Button variant="ghost" @click="balanceOpen = false">取消</Button>
<Button @click="saveBalance">确认</Button>
</template>
</Modal>
</div>
</template>