Files
openteam/web/src/views/admin/UsersView.vue
T
SakurasanandClaude ec4de8d913 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>
2026-08-15 15:34:06 +08:00

200 lines
7.4 KiB
Vue

<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>