412 lines
11 KiB
Vue
412 lines
11 KiB
Vue
<script setup>
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT,ROLE_OEM } from "../../stores/auth.js";
|
||
import {
|
||
listOemUsersApi,
|
||
createOemUserApi,
|
||
updateOemUserApi,
|
||
deleteOemUserApi,
|
||
} from "../../api/oemUsers.js";
|
||
|
||
const auth = useAuthStore();
|
||
|
||
/** 新建/编辑时可分配的角色(仅普通用户、代理) */
|
||
const FORM_ROLE_OPTIONS = [
|
||
{ label: "普通用户", value: 0 },
|
||
{ label: "代理", value: 2 },
|
||
];
|
||
|
||
const ROLE_LABELS = {
|
||
0: "普通用户",
|
||
[ROLE_ADMIN]: "管理员",
|
||
[ROLE_AGENT]: "代理",
|
||
[ROLE_OEM]: "OEM",
|
||
};
|
||
|
||
const users = ref([]);
|
||
const total = ref(0);
|
||
const page = ref(1);
|
||
const pageSize = ref(20);
|
||
const loading = ref(false);
|
||
const oemOptions = ref([]);
|
||
const agentOptions = ref([]);
|
||
|
||
const filters = ref({
|
||
username: "",
|
||
oem_id: null,
|
||
agent_id: null,
|
||
});
|
||
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,
|
||
});
|
||
|
||
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 roleLabel(roleId) {
|
||
return ROLE_LABELS[roleId] ?? `角色 ${roleId}`;
|
||
}
|
||
|
||
function roleSeverity(roleId) {
|
||
if (roleId === ROLE_ADMIN) return "info";
|
||
if (roleId === ROLE_AGENT) return "warn";
|
||
if (roleId === ROLE_OEM) return "success";
|
||
return "secondary";
|
||
}
|
||
|
||
const ownerNameById = computed(() => {
|
||
const map = new Map();
|
||
for (const u of oemOptions.value) map.set(u.id, u.username);
|
||
for (const u of agentOptions.value) map.set(u.id, u.username);
|
||
return map;
|
||
});
|
||
|
||
function ownerLabel(userId) {
|
||
if (userId == null) return "—";
|
||
return ownerNameById.value.get(userId) ?? `#${userId}`;
|
||
}
|
||
|
||
function showMessage(severity, message) {
|
||
feedback.value = { severity, message };
|
||
}
|
||
|
||
async function loadFilterOptions() {
|
||
const [ agentRes] = await Promise.all([
|
||
|
||
listOemUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }),
|
||
]);
|
||
|
||
if (agentRes.ok && Array.isArray(agentRes.data?.items)) {
|
||
agentOptions.value = agentRes.data.items;
|
||
}
|
||
}
|
||
|
||
async function loadUsers() {
|
||
loading.value = true;
|
||
feedback.value = { severity: "", message: "" };
|
||
const res = await listOemUsersApi(auth.token, {
|
||
page: page.value,
|
||
pageSize: pageSize.value,
|
||
username: filters.value.username,
|
||
oemId: filters.value.oem_id,
|
||
agentId: filters.value.agent_id,
|
||
});
|
||
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();
|
||
}
|
||
|
||
async function applyFilters() {
|
||
page.value = 1;
|
||
await loadUsers();
|
||
}
|
||
|
||
async function resetFilters() {
|
||
filters.value = { username: "", oem_id: null, agent_id: null };
|
||
page.value = 1;
|
||
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,
|
||
};
|
||
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;
|
||
}
|
||
|
||
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 createOemUserApi(auth.token, payload);
|
||
} else {
|
||
res = await updateOemUserApi(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 deleteOemUserApi(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(async () => {
|
||
await loadFilterOptions();
|
||
await 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>
|
||
|
||
<div class="admin-users__filters">
|
||
<InputText
|
||
v-model="filters.username"
|
||
placeholder="搜索用户名"
|
||
class="admin-users__search"
|
||
@keyup.enter="applyFilters"
|
||
/>
|
||
|
||
<Select
|
||
v-model="filters.agent_id"
|
||
:options="agentOptions"
|
||
option-label="username"
|
||
option-value="id"
|
||
placeholder="全部代理"
|
||
show-clear
|
||
filter
|
||
class="admin-users__filter-select"
|
||
/>
|
||
<Button label="搜索" @click="applyFilters" />
|
||
<Button label="重置" severity="secondary" text @click="resetFilters" />
|
||
</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="oem_id" header="OEM" style="width: 8rem">
|
||
<template #body="{ data }">
|
||
{{ ownerLabel(data.oem_id) }}
|
||
</template>
|
||
</Column>
|
||
<Column field="agent_id" header="代理" style="width: 8rem">
|
||
<template #body="{ data }">
|
||
{{ ownerLabel(data.agent_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="FORM_ROLE_OPTIONS"
|
||
option-label="label"
|
||
option-value="value"
|
||
class="w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||
<Button label="保存" :loading="saving" @click="saveUser" />
|
||
</template>
|
||
</Dialog>
|
||
</div>
|
||
</template>
|