11
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-close",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-maximize",
|
"core:window:allow-maximize",
|
||||||
"core:window:allow-unmaximize",
|
"core:window:allow-unmaximize",
|
||||||
|
|||||||
72
src/api/oemCardKeys.js
Normal file
72
src/api/oemCardKeys.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { apiRequest } from "./client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {RequestInit} [options]
|
||||||
|
*/
|
||||||
|
function oemRequest(token, path, options = {}) {
|
||||||
|
return apiRequest(path, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {{
|
||||||
|
* page?: number,
|
||||||
|
* pageSize?: number,
|
||||||
|
* status?: string,
|
||||||
|
* username?: string,
|
||||||
|
* oemId?: number | null,
|
||||||
|
* agentId?: number | null,
|
||||||
|
* }} [params]
|
||||||
|
*/
|
||||||
|
export function listAdminCardKeysApi(
|
||||||
|
token,
|
||||||
|
{ page = 1, pageSize = 20, status = "all", username, oemId, agentId } = {},
|
||||||
|
) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
const trimmed = username?.trim();
|
||||||
|
if (trimmed) query.set("username", trimmed);
|
||||||
|
if (oemId != null) query.set("oem_id", String(oemId));
|
||||||
|
if (agentId != null) query.set("agent_id", String(agentId));
|
||||||
|
return oemRequest(token, `/oem/card-keys?${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {object} payload
|
||||||
|
*/
|
||||||
|
export function createAdminCardKeysApi(token, payload) {
|
||||||
|
return oemRequest(token, "/oem/card-keys", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {number} cardId
|
||||||
|
* @param {object} payload
|
||||||
|
*/
|
||||||
|
export function updateAdminCardKeyApi(token, cardId, payload) {
|
||||||
|
return oemRequest(token, `/oem/card-keys/${cardId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} token @param {number} cardId */
|
||||||
|
export function deleteAdminCardKeyApi(token, cardId) {
|
||||||
|
return oemRequest(token, `/oem/card-keys/${cardId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ function oemRequest(token, path, options = {}) {
|
|||||||
* roleId?: number | null,
|
* roleId?: number | null,
|
||||||
* }} [params]
|
* }} [params]
|
||||||
*/
|
*/
|
||||||
export function listOemUsersApi(
|
export function listAgentUsersApi(
|
||||||
token,
|
token,
|
||||||
{ page = 1, pageSize = 20, username, oemId, agentId, roleId } = {},
|
{ page = 1, pageSize = 20, username, oemId, agentId, roleId } = {},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ async function onToggleMaximize() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onClose() {
|
||||||
|
if (!isTauri()) return;
|
||||||
|
try {
|
||||||
|
await getCurrentWindow().close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onLogout() {
|
async function onLogout() {
|
||||||
if (loggingOut.value) return;
|
if (loggingOut.value) return;
|
||||||
loggingOut.value = true;
|
loggingOut.value = true;
|
||||||
@@ -213,6 +222,15 @@ onUnmounted(() => {
|
|||||||
:class="isMaximized ? 'pi-window-minimize' : 'pi-window-maximize'"
|
:class="isMaximized ? 'pi-window-minimize' : 'pi-window-maximize'"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="app-titlebar__btn app-titlebar__btn--close"
|
||||||
|
title="关闭"
|
||||||
|
aria-label="关闭"
|
||||||
|
@click="onClose"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times text-xs" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
101
src/components/agent/AgentImageUpload.vue
Normal file
101
src/components/agent/AgentImageUpload.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: "" },
|
||||||
|
label: { type: String, required: true },
|
||||||
|
accept: { type: String, default: "image/png,image/jpeg,image/webp,image/gif" },
|
||||||
|
maxSizeMb: { type: Number, default: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue", "error"]);
|
||||||
|
|
||||||
|
const fileInputRef = ref(null);
|
||||||
|
const reading = ref(false);
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
emit("update:modelValue", "");
|
||||||
|
if (fileInputRef.value) fileInputRef.value.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileChange(event) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
emit("error", "请选择图片文件");
|
||||||
|
event.target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = props.maxSizeMb * 1024 * 1024;
|
||||||
|
if (file.size > maxBytes) {
|
||||||
|
emit("error", `图片不能超过 ${props.maxSizeMb}MB`);
|
||||||
|
event.target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reading.value = true;
|
||||||
|
try {
|
||||||
|
const dataUrl = await readAsDataUrl(file);
|
||||||
|
emit("update:modelValue", dataUrl);
|
||||||
|
} catch {
|
||||||
|
emit("error", "读取图片失败");
|
||||||
|
} finally {
|
||||||
|
reading.value = false;
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAsDataUrl(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ""));
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-image-upload">
|
||||||
|
<label class="admin-image-upload__label">{{ label }}</label>
|
||||||
|
<div class="admin-image-upload__body">
|
||||||
|
<div
|
||||||
|
class="admin-image-upload__preview"
|
||||||
|
:class="{ 'admin-image-upload__preview--empty': !modelValue }"
|
||||||
|
>
|
||||||
|
<img v-if="modelValue" :src="modelValue" alt="" />
|
||||||
|
<span v-else class="text-xs text-text-muted">未上传</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-image-upload__actions">
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
class="admin-image-upload__input"
|
||||||
|
:accept="accept"
|
||||||
|
@change="onFileChange"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="选择图片"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
:loading="reading"
|
||||||
|
@click="openPicker"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="modelValue"
|
||||||
|
label="清除"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
@click="clearImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
31
src/components/agent/AgentSubNav.vue
Normal file
31
src/components/agent/AgentSubNav.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
|
||||||
|
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
|
||||||
|
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
|
||||||
|
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
|
||||||
|
{ name: "admin-overview", label: "软件配置", to: "/admin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeName = computed(() => route.name);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="admin-sub-nav" aria-label="管理后台导航">
|
||||||
|
<p class="admin-sub-nav__title"></p>
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in menuItems"
|
||||||
|
:key="item.name"
|
||||||
|
:to="item.to"
|
||||||
|
class="admin-sub-nav__item"
|
||||||
|
:class="{ 'admin-sub-nav__item--active': activeName === item.name }"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -264,8 +264,39 @@ const mainChildren = [
|
|||||||
|
|
||||||
meta: { title: "代理", requiresRole: ROLE_AGENT },
|
meta: { title: "代理", requiresRole: ROLE_AGENT },
|
||||||
|
|
||||||
|
children: [
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
path: "users",
|
||||||
|
|
||||||
|
name: "agent-users",
|
||||||
|
|
||||||
|
component: () => import("../views/agent/AgentUsersView.vue"),
|
||||||
|
|
||||||
|
meta: { title: "用户管理" },
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
path: "card-keys",
|
||||||
|
|
||||||
|
name: "agent-card-keys",
|
||||||
|
|
||||||
|
component: () => import("../views/agent/AgentCardKeysView.vue"),
|
||||||
|
|
||||||
|
meta: { title: "卡密管理" },
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -420,6 +420,16 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-titlebar__btn--close:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-titlebar__btn--close:active {
|
||||||
|
background: rgba(220, 38, 38, 0.9);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-image-upload {
|
.admin-image-upload {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
517
src/views/agent/AgentCardKeysView.vue
Normal file
517
src/views/agent/AgentCardKeysView.vue
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { useAuthStore, ROLE_AGENT, ROLE_OEM } from "../../stores/auth.js";
|
||||||
|
import {
|
||||||
|
listAdminCardKeysApi,
|
||||||
|
createAdminCardKeysApi,
|
||||||
|
updateAdminCardKeyApi,
|
||||||
|
deleteAdminCardKeyApi,
|
||||||
|
} from "../../api/adminCardKeys.js";
|
||||||
|
import { listAdminUsersApi } from "../../api/adminUsers.js";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ label: "全部", value: "all" },
|
||||||
|
{ label: "未使用", value: "unused" },
|
||||||
|
{ label: "已激活", value: "used" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ASSIGN_OPTIONS = [
|
||||||
|
{ label: "不指定", value: "none" },
|
||||||
|
{ label: "分配给 OEM", value: "oem" },
|
||||||
|
{ label: "分配给代理", value: "agent" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const cards = ref([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(20);
|
||||||
|
const statusFilter = ref("all");
|
||||||
|
const oemOptions = ref([]);
|
||||||
|
const agentOptions = ref([]);
|
||||||
|
const filters = ref({
|
||||||
|
username: "",
|
||||||
|
oem_id: null,
|
||||||
|
agent_id: null,
|
||||||
|
});
|
||||||
|
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 = () => ({
|
||||||
|
serial_number: "",
|
||||||
|
duration_days: 30,
|
||||||
|
count: 1,
|
||||||
|
remark: "",
|
||||||
|
assign_type: "none",
|
||||||
|
assign_id: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = ref(emptyForm());
|
||||||
|
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
dialogMode.value === "create" ? "生成卡密" : "编辑卡密",
|
||||||
|
);
|
||||||
|
|
||||||
|
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
|
||||||
|
const editingActivatedAt = ref(null);
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignSelectOptions = computed(() => {
|
||||||
|
if (form.value.assign_type === "oem") return oemOptions.value;
|
||||||
|
if (form.value.assign_type === "agent") return agentOptions.value;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function ownerLabel(userId) {
|
||||||
|
if (userId == null) return "—";
|
||||||
|
return ownerNameById.value.get(userId) ?? `#${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 showMessage(severity, message) {
|
||||||
|
feedback.value = { severity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFilterOptions() {
|
||||||
|
const [oemRes, agentRes] = await Promise.all([
|
||||||
|
listAdminUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_OEM }),
|
||||||
|
listAdminUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }),
|
||||||
|
]);
|
||||||
|
if (oemRes.ok && Array.isArray(oemRes.data?.items)) {
|
||||||
|
oemOptions.value = oemRes.data.items;
|
||||||
|
}
|
||||||
|
if (agentRes.ok && Array.isArray(agentRes.data?.items)) {
|
||||||
|
agentOptions.value = agentRes.data.items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCards() {
|
||||||
|
loading.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
const res = await listAdminCardKeysApi(auth.token, {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
status: statusFilter.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;
|
||||||
|
cards.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 loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatusChange() {
|
||||||
|
page.value = 1;
|
||||||
|
loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyFilters() {
|
||||||
|
page.value = 1;
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetFilters() {
|
||||||
|
filters.value = { username: "", oem_id: null, agent_id: null };
|
||||||
|
page.value = 1;
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
dialogMode.value = "create";
|
||||||
|
editingId.value = null;
|
||||||
|
editingActivatedAt.value = null;
|
||||||
|
form.value = emptyForm();
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row) {
|
||||||
|
dialogMode.value = "edit";
|
||||||
|
editingId.value = row.id;
|
||||||
|
editingActivatedAt.value = row.activated_at;
|
||||||
|
form.value = {
|
||||||
|
serial_number: row.serial_number,
|
||||||
|
duration_days: row.duration_days,
|
||||||
|
count: 1,
|
||||||
|
remark: row.remark || "",
|
||||||
|
assign_type: "none",
|
||||||
|
assign_id: null,
|
||||||
|
};
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySerial(serial) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(serial);
|
||||||
|
showMessage("success", "序列号已复制");
|
||||||
|
} catch {
|
||||||
|
showMessage("warn", "复制失败,请手动复制");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssignPayload() {
|
||||||
|
if (form.value.assign_type === "oem") {
|
||||||
|
return { oem_id: form.value.assign_id, agent_id: null };
|
||||||
|
}
|
||||||
|
if (form.value.assign_type === "agent") {
|
||||||
|
return { oem_id: null, agent_id: form.value.assign_id };
|
||||||
|
}
|
||||||
|
return { oem_id: null, agent_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCard() {
|
||||||
|
if (form.value.duration_days < 1) {
|
||||||
|
showMessage("warn", "时长至少 1 天");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (dialogMode.value === "create") {
|
||||||
|
if (form.value.assign_type !== "none" && form.value.assign_id == null) {
|
||||||
|
saving.value = false;
|
||||||
|
showMessage("warn", "请选择分配对象");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
duration_days: form.value.duration_days,
|
||||||
|
count: form.value.count,
|
||||||
|
remark: form.value.remark.trim() || null,
|
||||||
|
...buildAssignPayload(),
|
||||||
|
};
|
||||||
|
const serial = form.value.serial_number.trim();
|
||||||
|
if (serial) {
|
||||||
|
if (form.value.count !== 1) {
|
||||||
|
saving.value = false;
|
||||||
|
showMessage("warn", "指定序列号时数量只能为 1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload.serial_number = serial.toUpperCase();
|
||||||
|
}
|
||||||
|
res = await createAdminCardKeysApi(auth.token, payload);
|
||||||
|
} else {
|
||||||
|
res = await updateAdminCardKeyApi(auth.token, editingId.value, {
|
||||||
|
duration_days: isActivated.value ? undefined : form.value.duration_days,
|
||||||
|
remark: form.value.remark.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "保存失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogVisible.value = false;
|
||||||
|
showMessage("success", res.message || "保存成功");
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCard(row) {
|
||||||
|
if (row.activated_at) {
|
||||||
|
showMessage("warn", "已激活卡密不能删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = window.confirm(`确定删除卡密「${row.serial_number}」?`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const res = await deleteAdminCardKeyApi(auth.token, row.id);
|
||||||
|
if (!res.ok) {
|
||||||
|
showMessage("error", res.message || "删除失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage("success", res.message || "已删除");
|
||||||
|
if (cards.value.length === 1 && page.value > 1) {
|
||||||
|
page.value -= 1;
|
||||||
|
}
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(statusFilter, onStatusChange);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.value.assign_type,
|
||||||
|
() => {
|
||||||
|
form.value.assign_id = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadFilterOptions();
|
||||||
|
await loadCards();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-page admin-card-keys">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="admin-users__filters">
|
||||||
|
<InputText
|
||||||
|
v-model="filters.username"
|
||||||
|
placeholder="搜索使用者用户名"
|
||||||
|
class="admin-users__search"
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
v-model="filters.oem_id"
|
||||||
|
:options="oemOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="全部 OEM"
|
||||||
|
show-clear
|
||||||
|
filter
|
||||||
|
class="admin-users__filter-select"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
v-model="filters.agent_id"
|
||||||
|
:options="agentOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="全部代理"
|
||||||
|
show-clear
|
||||||
|
filter
|
||||||
|
class="admin-users__filter-select"
|
||||||
|
/>
|
||||||
|
<label class="text-sm text-text-muted">状态</label>
|
||||||
|
<Select
|
||||||
|
v-model="statusFilter"
|
||||||
|
:options="STATUS_OPTIONS"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="admin-card-keys__status-select"
|
||||||
|
/>
|
||||||
|
<Button label="搜索" @click="applyFilters" />
|
||||||
|
<Button label="重置" severity="secondary" text @click="resetFilters" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="cards"
|
||||||
|
: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="serial_number" header="序列号" style="min-width: 11rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="admin-card-keys__serial">
|
||||||
|
<code class="text-xs">{{ data.serial_number }}</code>
|
||||||
|
<Button
|
||||||
|
label="复制"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
@click="copySerial(data.serial_number)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="duration_days" header="时长(天)" style="width: 6rem" />
|
||||||
|
<Column header="OEM" style="width: 7rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ ownerLabel(data.oem_id) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="代理" style="width: 7rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ ownerLabel(data.agent_id) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="created_at" header="创建时间">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDateTime(data.created_at) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="activated_at" header="激活时间">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
v-if="data.activated_at"
|
||||||
|
:value="formatDateTime(data.activated_at)"
|
||||||
|
severity="success"
|
||||||
|
/>
|
||||||
|
<Tag v-else value="未使用" severity="secondary" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="username" header="使用者">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.username || "—" }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="remark" header="备注">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.remark || "—" }}
|
||||||
|
</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.activated_at"
|
||||||
|
@click="removeCard(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
|
||||||
|
:style="{ width: '28rem' }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<template v-if="dialogMode === 'create'">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">时长(天)</label>
|
||||||
|
<InputNumber v-model="form.duration_days" :min="1" :max="3650" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">生成数量</label>
|
||||||
|
<InputNumber v-model="form.count" :min="1" :max="100" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">自定义序列号(可选,仅 1 张)</label>
|
||||||
|
<InputText
|
||||||
|
v-model="form.serial_number"
|
||||||
|
class="w-full font-mono uppercase"
|
||||||
|
placeholder="留空则自动生成"
|
||||||
|
:disabled="form.count > 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">预分配</label>
|
||||||
|
<Select
|
||||||
|
v-model="form.assign_type"
|
||||||
|
:options="ASSIGN_OPTIONS"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.assign_type !== 'none'" class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">
|
||||||
|
{{ form.assign_type === "oem" ? "选择 OEM" : "选择代理" }}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
v-model="form.assign_id"
|
||||||
|
:options="assignSelectOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="请选择"
|
||||||
|
filter
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">序列号</label>
|
||||||
|
<InputText
|
||||||
|
:model-value="form.serial_number"
|
||||||
|
class="w-full font-mono"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">时长(天)</label>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.duration_days"
|
||||||
|
:min="1"
|
||||||
|
:max="3650"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="isActivated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-text-muted uppercase">备注</label>
|
||||||
|
<Textarea v-model="form.remark" rows="2" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||||
|
<Button label="保存" :loading="saving" @click="saveCard" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
455
src/views/agent/AgentUsersView.vue
Normal file
455
src/views/agent/AgentUsersView.vue
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT,ROLE_OEM } 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 },
|
||||||
|
{ label: "OEM", value: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
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,
|
||||||
|
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";
|
||||||
|
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 [oemRes, agentRes] = await Promise.all([
|
||||||
|
listAdminUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_OEM }),
|
||||||
|
listAdminUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }),
|
||||||
|
]);
|
||||||
|
if (oemRes.ok && Array.isArray(oemRes.data?.items)) {
|
||||||
|
oemOptions.value = oemRes.data.items;
|
||||||
|
}
|
||||||
|
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 listAdminUsersApi(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,
|
||||||
|
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(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.oem_id"
|
||||||
|
:options="oemOptions"
|
||||||
|
option-label="username"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="全部 OEM"
|
||||||
|
show-clear
|
||||||
|
filter
|
||||||
|
class="admin-users__filter-select"
|
||||||
|
/>
|
||||||
|
<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="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>
|
||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
createAdminCardKeysApi,
|
createAdminCardKeysApi,
|
||||||
updateAdminCardKeyApi,
|
updateAdminCardKeyApi,
|
||||||
deleteAdminCardKeyApi,
|
deleteAdminCardKeyApi,
|
||||||
} from "../../api/adminCardKeys.js";
|
} from "../../api/oemCardKeys.js";
|
||||||
import { listAdminUsersApi } from "../../api/adminUsers.js";
|
import { listAgentUsersApi } from "../../api/oemUsers.js";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
@@ -91,13 +91,11 @@ function showMessage(severity, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFilterOptions() {
|
async function loadFilterOptions() {
|
||||||
const [oemRes, agentRes] = await Promise.all([
|
const [ agentRes] = await Promise.all([
|
||||||
listAdminUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_OEM }),
|
|
||||||
listAdminUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }),
|
listAgentUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }),
|
||||||
]);
|
]);
|
||||||
if (oemRes.ok && Array.isArray(oemRes.data?.items)) {
|
|
||||||
oemOptions.value = oemRes.data.items;
|
|
||||||
}
|
|
||||||
if (agentRes.ok && Array.isArray(agentRes.data?.items)) {
|
if (agentRes.ok && Array.isArray(agentRes.data?.items)) {
|
||||||
agentOptions.value = agentRes.data.items;
|
agentOptions.value = agentRes.data.items;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT,ROLE_OEM } from "../../stores/auth.js";
|
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT,ROLE_OEM } from "../../stores/auth.js";
|
||||||
import {
|
import {
|
||||||
listOemUsersApi,
|
listAgentUsersApi,
|
||||||
createOemUserApi,
|
createOemUserApi,
|
||||||
updateOemUserApi,
|
updateOemUserApi,
|
||||||
deleteOemUserApi,
|
deleteOemUserApi,
|
||||||
@@ -102,13 +102,11 @@ function showMessage(severity, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFilterOptions() {
|
async function loadFilterOptions() {
|
||||||
const [oemRes, agentRes] = await Promise.all([
|
const [ agentRes] = await Promise.all([
|
||||||
listOemUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_OEM }),
|
|
||||||
listOemUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }),
|
listAgentUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }),
|
||||||
]);
|
]);
|
||||||
if (oemRes.ok && Array.isArray(oemRes.data?.items)) {
|
|
||||||
oemOptions.value = oemRes.data.items;
|
|
||||||
}
|
|
||||||
if (agentRes.ok && Array.isArray(agentRes.data?.items)) {
|
if (agentRes.ok && Array.isArray(agentRes.data?.items)) {
|
||||||
agentOptions.value = agentRes.data.items;
|
agentOptions.value = agentRes.data.items;
|
||||||
}
|
}
|
||||||
@@ -117,25 +115,27 @@ async function loadFilterOptions() {
|
|||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
feedback.value = { severity: "", message: "" };
|
feedback.value = { severity: "", message: "" };
|
||||||
const res = await listOemUsersApi(auth.token, {
|
// const res = await listOemUsersApi(auth.token, {
|
||||||
page: page.value,
|
// page: page.value,
|
||||||
pageSize: pageSize.value,
|
// pageSize: pageSize.value,
|
||||||
username: filters.value.username,
|
// username: filters.value.username,
|
||||||
oemId: filters.value.oem_id,
|
// oemId: filters.value.oem_id,
|
||||||
agentId: filters.value.agent_id,
|
// agentId: filters.value.agent_id,
|
||||||
});
|
// });
|
||||||
loading.value = false;
|
// loading.value = false;
|
||||||
|
|
||||||
if (!res.ok) {
|
// if (!res.ok) {
|
||||||
showMessage("error", res.message || "加载用户列表失败");
|
// showMessage("error", res.message || "加载用户列表失败");
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
|
|
||||||
const data = res.data;
|
// const data = res.data;
|
||||||
users.value = Array.isArray(data?.items) ? data.items : [];
|
// users.value = Array.isArray(data?.items) ? data.items : [];
|
||||||
total.value = typeof data?.total === "number" ? data.total : 0;
|
// total.value = typeof data?.total === "number" ? data.total : 0;
|
||||||
if (typeof data?.page === "number") page.value = data.page;
|
users.value=[];
|
||||||
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
|
total.value=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) {
|
async function onPage(event) {
|
||||||
|
|||||||
Reference in New Issue
Block a user