- AdminPatchUser 扩展 username/email/password 更新, 含唯一性与格式校验 - 编辑弹窗补充用户名/邮箱/重置密码字段(留空不改密码) Co-Authored-By: Claude <noreply@anthropic.com>
217 lines
7.9 KiB
Vue
217 lines
7.9 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({ username: '', email: '', password: '', 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, {
|
|
username: u.username,
|
|
email: u.email,
|
|
password: '',
|
|
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-muted">管理角色、状态与余额</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<input
|
|
v-model="q"
|
|
placeholder="搜索用户名 / 邮箱"
|
|
class="h-10 w-56 rounded-md border border-edge2 bg-surface 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-edge text-left text-xs text-muted">
|
|
<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-muted">{{ u.id }}</td>
|
|
<td class="px-4 py-2.5 text-ink">
|
|
{{ u.username }}
|
|
<span v-if="u.id === auth.user?.id" class="text-[11px] text-muted">(我)</span>
|
|
</td>
|
|
<td class="px-4 py-2.5 text-xs text-muted">{{ 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-accent">{{ 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-muted">{{ 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-muted hover:text-ink" @click="openEdit(u)">编辑</button>
|
|
<button class="text-xs text-muted 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-muted">无用户</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="flex items-center justify-between border-t border-edge px-4 py-3">
|
|
<span class="font-mono text-xs text-muted">共 {{ 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="`编辑用户 · ${editing?.username}`" @close="editOpen = false">
|
|
<div class="space-y-4">
|
|
<Input v-model="editForm.username" label="用户名" />
|
|
<Input v-model="editForm.email" label="邮箱" type="email" />
|
|
<Input
|
|
v-model="editForm.password"
|
|
label="重置密码"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
hint="留空则不修改"
|
|
/>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<label class="block">
|
|
<span class="mb-1.5 block text-xs font-medium text-muted">角色</span>
|
|
<select v-model="editForm.role" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink 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-muted">状态</span>
|
|
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
|
|
<option value="active">active</option>
|
|
<option value="disabled">disabled</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</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-muted">当前余额 {{ 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>
|