This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 00:19:08 +08:00
parent 3215d39a32
commit 3b0a8d777c
63 changed files with 12483 additions and 1114 deletions

View File

@@ -0,0 +1,364 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../../stores/auth.js";
import {
listAdminUsersApi,
createAdminUserApi,
updateAdminUserApi,
deleteAdminUserApi,
} from "../../api/adminUsers.js";
const auth = useAuthStore();
const ROLE_OPTIONS = [
{ label: "普通用户", value: 0 },
{ label: "管理员", value: 1 },
{ label: "代理", value: 2 },
];
const ROLE_LABELS = {
0: "普通用户",
[ROLE_ADMIN]: "管理员",
[ROLE_AGENT]: "代理",
};
const users = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const saving = ref(false);
const feedback = ref({ severity: "", message: "" });
const dialogVisible = ref(false);
const dialogMode = ref("create");
const editingId = ref(null);
const emptyForm = () => ({
username: "",
password: "",
phone: "",
role_id: 0,
vip_end_time: "",
clear_vip_end_time: false,
});
const form = ref(emptyForm());
const dialogTitle = computed(() =>
dialogMode.value === "create" ? "新建用户" : "编辑用户",
);
function formatDateTime(value) {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("zh-CN", { hour12: false });
}
function toDatetimeLocalValue(iso) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function roleLabel(roleId) {
return ROLE_LABELS[roleId] ?? `角色 ${roleId}`;
}
function roleSeverity(roleId) {
if (roleId === ROLE_ADMIN) return "info";
if (roleId === ROLE_AGENT) return "warn";
return "secondary";
}
function showMessage(severity, message) {
feedback.value = { severity, message };
}
async function loadUsers() {
loading.value = true;
feedback.value = { severity: "", message: "" };
const res = await listAdminUsersApi(auth.token, {
page: page.value,
pageSize: pageSize.value,
});
loading.value = false;
if (!res.ok) {
showMessage("error", res.message || "加载用户列表失败");
return;
}
const data = res.data;
users.value = Array.isArray(data?.items) ? data.items : [];
total.value = typeof data?.total === "number" ? data.total : 0;
if (typeof data?.page === "number") page.value = data.page;
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
}
async function onPage(event) {
page.value = event.page + 1;
pageSize.value = event.rows;
await loadUsers();
}
function openCreateDialog() {
dialogMode.value = "create";
editingId.value = null;
form.value = emptyForm();
dialogVisible.value = true;
}
function openEditDialog(row) {
dialogMode.value = "edit";
editingId.value = row.id;
form.value = {
username: row.username,
password: "",
phone: row.phone || "",
role_id: row.role_id,
vip_end_time: toDatetimeLocalValue(row.vip_end_time),
clear_vip_end_time: false,
};
dialogVisible.value = true;
}
function buildPayload() {
const payload = {
username: form.value.username.trim(),
phone: form.value.phone.trim() || null,
role_id: form.value.role_id,
};
if (form.value.password.trim()) {
payload.password = form.value.password;
}
if (form.value.clear_vip_end_time) {
payload.clear_vip_end_time = true;
} else if (form.value.vip_end_time) {
payload.vip_end_time = new Date(form.value.vip_end_time).toISOString();
}
return payload;
}
async function saveUser() {
if (!form.value.username.trim()) {
showMessage("warn", "用户名不能为空");
return;
}
if (dialogMode.value === "create" && form.value.password.length < 6) {
showMessage("warn", "密码至少 6 位");
return;
}
saving.value = true;
feedback.value = { severity: "", message: "" };
const payload = buildPayload();
let res;
if (dialogMode.value === "create") {
if (!payload.password) {
saving.value = false;
showMessage("warn", "请设置初始密码");
return;
}
res = await createAdminUserApi(auth.token, payload);
} else {
res = await updateAdminUserApi(auth.token, editingId.value, payload);
}
saving.value = false;
if (!res.ok) {
showMessage("error", res.message || "保存失败");
return;
}
dialogVisible.value = false;
showMessage("success", res.message || "保存成功");
await loadUsers();
}
async function removeUser(row) {
if (row.id === auth.currentUser?.id) {
showMessage("warn", "不能删除当前登录账号");
return;
}
const ok = window.confirm(`确定删除用户「${row.username}」?此操作不可恢复。`);
if (!ok) return;
const res = await deleteAdminUserApi(auth.token, row.id);
if (!res.ok) {
showMessage("error", res.message || "删除失败");
return;
}
showMessage("success", res.message || "已删除");
if (users.value.length === 1 && page.value > 1) {
page.value -= 1;
}
await loadUsers();
}
onMounted(() => {
loadUsers();
});
</script>
<template>
<div class="admin-page admin-users">
<div class="admin-users__header">
<div>
<h1 class="admin-page__title gradient-text">用户管理</h1>
</div>
<Button label="新建用户" @click="openCreateDialog" />
</div>
<Message
v-if="feedback.message"
:severity="feedback.severity"
:closable="true"
class="admin-users__message"
@close="feedback.message = ''"
>
{{ feedback.message }}
</Message>
<DataTable
:value="users"
:loading="loading"
lazy
paginator
:rows="pageSize"
:total-records="total"
:first="(page - 1) * pageSize"
:rows-per-page-options="[10, 20, 50]"
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
current-page-report-template=" {totalRecords} "
striped-rows
size="small"
class="admin-users__table"
data-key="id"
@page="onPage"
>
<Column field="id" header="ID" style="width: 4rem" />
<Column field="username" header="用户名" />
<Column field="phone" header="手机号">
<template #body="{ data }">
{{ data.phone || "—" }}
</template>
</Column>
<Column field="role_id" header="角色" style="width: 7rem">
<template #body="{ data }">
<Tag :value="roleLabel(data.role_id)" :severity="roleSeverity(data.role_id)" />
</template>
</Column>
<Column field="vip_end_time" header="VIP 到期">
<template #body="{ data }">
{{ formatDateTime(data.vip_end_time) }}
</template>
</Column>
<Column field="created_at" header="注册时间">
<template #body="{ data }">
{{ formatDateTime(data.created_at) }}
</template>
</Column>
<Column header="操作" style="width: 9rem">
<template #body="{ data }">
<div class="admin-users__actions">
<Button
label="编辑"
size="small"
severity="secondary"
text
@click="openEditDialog(data)"
/>
<Button
label="删除"
size="small"
severity="danger"
text
:disabled="data.id === auth.currentUser?.id"
@click="removeUser(data)"
/>
</div>
</template>
</Column>
<template #empty>
<p class="py-6 text-center text-sm text-text-muted">暂无用户</p>
</template>
</DataTable>
<Dialog
v-model:visible="dialogVisible"
:header="dialogTitle"
modal
class="admin-users-dialog"
:style="{ width: '28rem' }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">用户名</label>
<InputText v-model="form.username" class="w-full" autocomplete="off" />
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">
{{ dialogMode === "create" ? "密码" : "新密码(留空不修改)" }}
</label>
<Password
v-model="form.password"
:feedback="false"
toggle-mask
fluid
input-class="w-full"
:placeholder="dialogMode === 'create' ? '至少 6 位' : '留空则不修改'"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">手机号可选</label>
<InputText v-model="form.phone" class="w-full" autocomplete="off" />
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">角色</label>
<Select
v-model="form.role_id"
:options="ROLE_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">VIP 到期时间</label>
<InputText
v-model="form.vip_end_time"
type="datetime-local"
class="w-full"
:disabled="form.clear_vip_end_time"
/>
<label v-if="dialogMode === 'edit'" class="flex items-center gap-2 text-sm text-text-muted">
<Checkbox v-model="form.clear_vip_end_time" :binary="true" input-id="clear-vip" />
<span>清除 VIP 到期时间</span>
</label>
</div>
</div>
<template #footer>
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
<Button label="保存" :loading="saving" @click="saveUser" />
</template>
</Dialog>
</div>
</template>