This commit is contained in:
fengchuanhn@gmail.com
2026-05-22 16:48:58 +08:00
parent a2ff5b065e
commit 5a9e024c0c
6 changed files with 232 additions and 913 deletions

View File

@@ -21,13 +21,11 @@ function agentRequest(token, path, options = {}) {
* pageSize?: number, * pageSize?: number,
* status?: string, * status?: string,
* username?: string, * username?: string,
* oemId?: number | null,
* agentId?: number | null,
* }} [params] * }} [params]
*/ */
export function listAgentCardKeysApi( export function listAgentCardKeysApi(
token, token,
{ page = 1, pageSize = 20, status = "all", username, oemId, agentId } = {}, { page = 1, pageSize = 20, status = "all", username } = {},
) { ) {
const query = new URLSearchParams({ const query = new URLSearchParams({
page: String(page), page: String(page),
@@ -36,26 +34,13 @@ export function listAgentCardKeysApi(
}); });
const trimmed = username?.trim(); const trimmed = username?.trim();
if (trimmed) query.set("username", trimmed); 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 agentRequest(token, `/agent/card-keys?${query}`); return agentRequest(token, `/agent/card-keys?${query}`);
} }
/**
* @param {string} token
* @param {object} payload
*/
export function createAgentCardKeysApi(token, payload) {
return agentRequest(token, "/agent/card-keys", {
method: "POST",
body: JSON.stringify(payload),
});
}
/** /**
* @param {string} token * @param {string} token
* @param {number} cardId * @param {number} cardId
* @param {object} payload * @param {{ agent_remark?: string | null }} payload
*/ */
export function updateAgentCardKeyApi(token, cardId, payload) { export function updateAgentCardKeyApi(token, cardId, payload) {
return agentRequest(token, `/agent/card-keys/${cardId}`, { return agentRequest(token, `/agent/card-keys/${cardId}`, {
@@ -63,10 +48,3 @@ export function updateAgentCardKeyApi(token, cardId, payload) {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
/** @param {string} token @param {number} cardId */
export function deleteAgentCardKeyApi(token, cardId) {
return agentRequest(token, `/agent/card-keys/${cardId}`, {
method: "DELETE",
});
}

View File

@@ -20,14 +20,12 @@ function agentRequest(token, path, options = {}) {
* page?: number, * page?: number,
* pageSize?: number, * pageSize?: number,
* username?: string, * username?: string,
* oemId?: number | null,
* agentId?: number | null,
* roleId?: number | null, * roleId?: number | null,
* }} [params] * }} [params]
*/ */
export function listAgentUsersApi( export function listAgentUsersApi(
token, token,
{ page = 1, pageSize = 20, username, oemId, agentId, roleId } = {}, { page = 1, pageSize = 20, username, roleId } = {},
) { ) {
const query = new URLSearchParams({ const query = new URLSearchParams({
page: String(page), page: String(page),
@@ -35,38 +33,6 @@ export function listAgentUsersApi(
}); });
const trimmed = username?.trim(); const trimmed = username?.trim();
if (trimmed) query.set("username", trimmed); if (trimmed) query.set("username", trimmed);
if (oemId != null) query.set("oem_id", String(oemId));
if (agentId != null) query.set("agent_id", String(agentId));
if (roleId != null) query.set("role_id", String(roleId)); if (roleId != null) query.set("role_id", String(roleId));
return agentRequest(token, `/agent/users?${query}`); return agentRequest(token, `/agent/users?${query}`);
} }
/**
* @param {string} token
* @param {object} payload
*/
export function createAgentUserApi(token, payload) {
return agentRequest(token, "/agent/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
/**
* @param {string} token
* @param {number} userId
* @param {object} payload
*/
export function updateAgentUserApi(token, userId, payload) {
return agentRequest(token, `/agent/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
/** @param {string} token @param {number} userId */
export function deleteAgentUserApi(token, userId) {
return agentRequest(token, `/agent/users/${userId}`, {
method: "DELETE",
});
}

View File

@@ -21,13 +21,12 @@ function oemRequest(token, path, options = {}) {
* pageSize?: number, * pageSize?: number,
* status?: string, * status?: string,
* username?: string, * username?: string,
* oemId?: number | null,
* agentId?: number | null, * agentId?: number | null,
* }} [params] * }} [params]
*/ */
export function listAdminCardKeysApi( export function listOemCardKeysApi(
token, token,
{ page = 1, pageSize = 20, status = "all", username, oemId, agentId } = {}, { page = 1, pageSize = 20, status = "all", username, agentId } = {},
) { ) {
const query = new URLSearchParams({ const query = new URLSearchParams({
page: String(page), page: String(page),
@@ -36,37 +35,29 @@ export function listAdminCardKeysApi(
}); });
const trimmed = username?.trim(); const trimmed = username?.trim();
if (trimmed) query.set("username", trimmed); if (trimmed) query.set("username", trimmed);
if (oemId != null) query.set("oem_id", String(oemId));
if (agentId != null) query.set("agent_id", String(agentId)); if (agentId != null) query.set("agent_id", String(agentId));
return oemRequest(token, `/oem/card-keys?${query}`); 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 {string} token
* @param {number} cardId * @param {number} cardId
* @param {object} payload * @param {{ oem_remark?: string | null }} payload
*/ */
export function updateAdminCardKeyApi(token, cardId, payload) { export function updateOemCardKeyApi(token, cardId, payload) {
return oemRequest(token, `/oem/card-keys/${cardId}`, { return oemRequest(token, `/oem/card-keys/${cardId}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
/** @param {string} token @param {number} cardId */ /**
export function deleteAdminCardKeyApi(token, cardId) { * @param {string} token
return oemRequest(token, `/oem/card-keys/${cardId}`, { * @param {{ card_ids: number[], agent_id: number }} payload
method: "DELETE", */
export function assignOemCardKeysToAgentApi(token, payload) {
return oemRequest(token, "/oem/card-keys/assign-agent", {
method: "POST",
body: JSON.stringify(payload),
}); });
} }

View File

@@ -1,13 +1,7 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from "vue"; import { onMounted, ref, watch } from "vue";
import { useAuthStore, ROLE_AGENT, ROLE_OEM } from "../../stores/auth.js"; import { useAuthStore } from "../../stores/auth.js";
import { import { listAgentCardKeysApi, updateAgentCardKeyApi } from "../../api/agentCardKeys.js";
listAgentCardKeysApi,
createAgentCardKeysApi,
updateAgentCardKeyApi,
deleteAgentCardKeyApi,
} from "../../api/agentCardKeys.js";
import { listAgentUsersApi } from "../../api/agentUsers.js";
const auth = useAuthStore(); const auth = useAuthStore();
@@ -17,67 +11,20 @@ const STATUS_OPTIONS = [
{ label: "已激活", value: "used" }, { label: "已激活", value: "used" },
]; ];
const ASSIGN_OPTIONS = [
{ label: "不指定", value: "none" },
{ label: "分配给 OEM", value: "oem" },
{ label: "分配给代理", value: "agent" },
];
const cards = ref([]); const cards = ref([]);
const total = ref(0); const total = ref(0);
const page = ref(1); const page = ref(1);
const pageSize = ref(20); const pageSize = ref(20);
const statusFilter = ref("all"); const statusFilter = ref("all");
const oemOptions = ref([]); const filters = ref({ username: "" });
const agentOptions = ref([]);
const filters = ref({
username: "",
oem_id: null,
agent_id: null,
});
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const feedback = ref({ severity: "", message: "" }); const feedback = ref({ severity: "", message: "" });
const dialogVisible = ref(false); const remarkDialogVisible = ref(false);
const dialogMode = ref("create");
const editingId = ref(null); const editingId = ref(null);
const editingSerial = ref("");
const emptyForm = () => ({ const remarkForm = ref({ agent_remark: "" });
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) { function formatDateTime(value) {
if (!value) return "—"; if (!value) return "—";
@@ -90,19 +37,6 @@ function showMessage(severity, message) {
feedback.value = { severity, message }; feedback.value = { severity, message };
} }
async function loadFilterOptions() {
const [oemRes, agentRes] = await Promise.all([
listAgentUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_OEM }),
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)) {
agentOptions.value = agentRes.data.items;
}
}
async function loadCards() { async function loadCards() {
loading.value = true; loading.value = true;
feedback.value = { severity: "", message: "" }; feedback.value = { severity: "", message: "" };
@@ -111,8 +45,6 @@ async function loadCards() {
pageSize: pageSize.value, pageSize: pageSize.value,
status: statusFilter.value, status: statusFilter.value,
username: filters.value.username, username: filters.value.username,
oemId: filters.value.oem_id,
agentId: filters.value.agent_id,
}); });
loading.value = false; loading.value = false;
@@ -145,32 +77,36 @@ async function applyFilters() {
} }
async function resetFilters() { async function resetFilters() {
filters.value = { username: "", oem_id: null, agent_id: null }; filters.value = { username: "" };
page.value = 1; page.value = 1;
await loadCards(); await loadCards();
} }
function openCreateDialog() { function openRemarkDialog(row) {
dialogMode.value = "create"; editingId.value = row.id;
editingId.value = null; editingSerial.value = row.serial_number;
editingActivatedAt.value = null; remarkForm.value = { agent_remark: row.agent_remark || "" };
form.value = emptyForm(); remarkDialogVisible.value = true;
dialogVisible.value = true;
} }
function openEditDialog(row) { async function saveRemark() {
dialogMode.value = "edit"; saving.value = true;
editingId.value = row.id; feedback.value = { severity: "", message: "" };
editingActivatedAt.value = row.activated_at;
form.value = { const res = await updateAgentCardKeyApi(auth.token, editingId.value, {
serial_number: row.serial_number, agent_remark: remarkForm.value.agent_remark.trim() || null,
duration_days: row.duration_days, });
count: 1,
remark: row.remark || "", saving.value = false;
assign_type: "none",
assign_id: null, if (!res.ok) {
}; showMessage("error", res.message || "保存失败");
dialogVisible.value = true; return;
}
remarkDialogVisible.value = false;
showMessage("success", res.message || "备注已更新");
await loadCards();
} }
async function copySerial(serial) { async function copySerial(serial) {
@@ -182,113 +118,17 @@ async function copySerial(serial) {
} }
} }
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 createAgentCardKeysApi(auth.token, payload);
} else {
res = await updateAgentCardKeyApi(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 deleteAgentCardKeyApi(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(statusFilter, onStatusChange);
watch( onMounted(loadCards);
() => form.value.assign_type,
() => {
form.value.assign_id = null;
},
);
onMounted(async () => {
await loadFilterOptions();
await loadCards();
});
</script> </script>
<template> <template>
<div class="admin-page admin-card-keys"> <div class="admin-page admin-card-keys">
<div class="admin-users__header"> <div class="admin-users__header">
<div>
<h1 class="admin-page__title gradient-text">卡密管理</h1> <h1 class="admin-page__title gradient-text">卡密管理</h1>
</div> </div>
</div>
<Message <Message
v-if="feedback.message" v-if="feedback.message"
:severity="feedback.severity" :severity="feedback.severity"
@@ -306,8 +146,6 @@ onMounted(async () => {
class="admin-users__search" class="admin-users__search"
@keyup.enter="applyFilters" @keyup.enter="applyFilters"
/> />
<label class="text-sm text-text-muted">状态</label> <label class="text-sm text-text-muted">状态</label>
<Select <Select
v-model="statusFilter" v-model="statusFilter"
@@ -352,16 +190,6 @@ onMounted(async () => {
</template> </template>
</Column> </Column>
<Column field="duration_days" header="时长(天)" style="width: 6rem" /> <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="创建时间"> <Column field="created_at" header="创建时间">
<template #body="{ data }"> <template #body="{ data }">
{{ formatDateTime(data.created_at) }} {{ formatDateTime(data.created_at) }}
@@ -382,97 +210,46 @@ onMounted(async () => {
{{ data.username || "—" }} {{ data.username || "—" }}
</template> </template>
</Column> </Column>
<Column field="remark" header="备注"> <Column field="agent_remark" header="代理备注">
<template #body="{ data }"> <template #body="{ data }">
{{ data.remark || "—" }} {{ data.agent_remark || "—" }}
</template>
</Column>
<Column header="操作" style="width: 6rem">
<template #body="{ data }">
<Button
label="备注"
size="small"
severity="secondary"
text
@click="openRemarkDialog(data)"
/>
</template> </template>
</Column> </Column>
<template #empty> <template #empty>
<p class="py-6 text-center text-sm text-text-muted">暂无卡密</p> <p class="py-6 text-center text-sm text-text-muted">暂无卡密</p>
</template> </template>
</DataTable> </DataTable>
<Dialog <Dialog
v-model:visible="dialogVisible" v-model:visible="remarkDialogVisible"
:header="dialogTitle" header="编辑代理备注"
modal modal
:style="{ width: '28rem' }" :style="{ width: '28rem' }"
> >
<div class="flex flex-col gap-4"> <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"> <div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">序列号</label> <label class="text-xs text-text-muted uppercase">序列号</label>
<InputText <InputText :model-value="editingSerial" class="w-full font-mono" disabled />
:model-value="form.serial_number"
class="w-full font-mono"
disabled
/>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">时长</label> <label class="text-xs text-text-muted uppercase">代理备注</label>
<InputNumber <Textarea v-model="remarkForm.agent_remark" rows="3" class="w-full" />
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>
</div> </div>
<template #footer> <template #footer>
<Button label="取消" severity="secondary" text @click="dialogVisible = false" /> <Button label="取消" severity="secondary" text @click="remarkDialogVisible = false" />
<Button label="保存" :loading="saving" @click="saveCard" /> <Button label="保存" :loading="saving" @click="saveRemark" />
</template> </template>
</Dialog> </Dialog>
</div> </div>

View File

@@ -1,22 +1,10 @@
<script setup> <script setup>
import { computed, onMounted, ref } from "vue"; import { 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 { listAgentUsersApi } from "../../api/agentUsers.js";
listAgentUsersApi,
createAgentUserApi,
updateAgentUserApi,
deleteAgentUserApi,
} from "../../api/agentUsers.js";
const auth = useAuthStore(); const auth = useAuthStore();
const ROLE_OPTIONS = [
{ label: "普通用户", value: 0 },
{ label: "管理员", value: 1 },
{ label: "代理", value: 2 },
{ label: "OEM", value: 3 },
];
const ROLE_LABELS = { const ROLE_LABELS = {
0: "普通用户", 0: "普通用户",
[ROLE_ADMIN]: "管理员", [ROLE_ADMIN]: "管理员",
@@ -29,36 +17,9 @@ const total = ref(0);
const page = ref(1); const page = ref(1);
const pageSize = ref(20); const pageSize = ref(20);
const loading = ref(false); const loading = ref(false);
const oemOptions = ref([]); const filters = ref({ username: "" });
const agentOptions = ref([]);
const filters = ref({
username: "",
oem_id: null,
agent_id: null,
});
const saving = ref(false);
const feedback = ref({ severity: "", message: "" }); 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) { function formatDateTime(value) {
if (!value) return "—"; if (!value) return "—";
const d = new Date(value); const d = new Date(value);
@@ -66,14 +27,6 @@ function formatDateTime(value) {
return d.toLocaleString("zh-CN", { hour12: false }); 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) { function roleLabel(roleId) {
return ROLE_LABELS[roleId] ?? `角色 ${roleId}`; return ROLE_LABELS[roleId] ?? `角色 ${roleId}`;
} }
@@ -85,35 +38,10 @@ function roleSeverity(roleId) {
return "secondary"; 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) { function showMessage(severity, message) {
feedback.value = { severity, message }; feedback.value = { severity, message };
} }
async function loadFilterOptions() {
const [oemRes, agentRes] = await Promise.all([
listAgentUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_OEM }),
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)) {
agentOptions.value = agentRes.data.items;
}
}
async function loadUsers() { async function loadUsers() {
loading.value = true; loading.value = true;
feedback.value = { severity: "", message: "" }; feedback.value = { severity: "", message: "" };
@@ -121,8 +49,6 @@ async function loadUsers() {
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,
agentId: filters.value.agent_id,
}); });
loading.value = false; loading.value = false;
@@ -150,158 +76,19 @@ async function applyFilters() {
} }
async function resetFilters() { async function resetFilters() {
filters.value = { username: "", oem_id: null, agent_id: null }; filters.value = { username: "" };
page.value = 1; page.value = 1;
await loadUsers(); await loadUsers();
} }
function openCreateDialog() { onMounted(loadUsers);
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 createAgentUserApi(auth.token, payload);
} else {
res = await updateAgentUserApi(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 deleteAgentUserApi(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> </script>
<template> <template>
<div class="admin-page admin-users"> <div class="admin-page admin-users">
<div class="admin-users__header"> <div class="admin-users__header">
<div>
<h1 class="admin-page__title gradient-text">用户管理</h1> <h1 class="admin-page__title gradient-text">用户管理</h1>
</div> </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 <Message
v-if="feedback.message" v-if="feedback.message"
@@ -313,6 +100,17 @@ onMounted(async () => {
{{ feedback.message }} {{ feedback.message }}
</Message> </Message>
<div class="admin-users__filters">
<InputText
v-model="filters.username"
placeholder="搜索用户名"
class="admin-users__search"
@keyup.enter="applyFilters"
/>
<Button label="搜索" @click="applyFilters" />
<Button label="重置" severity="secondary" text @click="resetFilters" />
</div>
<DataTable <DataTable
:value="users" :value="users"
:loading="loading" :loading="loading"
@@ -342,16 +140,6 @@ onMounted(async () => {
<Tag :value="roleLabel(data.role_id)" :severity="roleSeverity(data.role_id)" /> <Tag :value="roleLabel(data.role_id)" :severity="roleSeverity(data.role_id)" />
</template> </template>
</Column> </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 到期"> <Column field="vip_end_time" header="VIP 到期">
<template #body="{ data }"> <template #body="{ data }">
{{ formatDateTime(data.vip_end_time) }} {{ formatDateTime(data.vip_end_time) }}
@@ -362,94 +150,9 @@ onMounted(async () => {
{{ formatDateTime(data.created_at) }} {{ formatDateTime(data.created_at) }}
</template> </template>
</Column> </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> <template #empty>
<p class="py-6 text-center text-sm text-text-muted">暂无用户</p> <p class="py-6 text-center text-sm text-text-muted">暂无用户</p>
</template> </template>
</DataTable> </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> </div>
</template> </template>

View File

@@ -1,11 +1,10 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useAuthStore, ROLE_AGENT, ROLE_OEM } from "../../stores/auth.js"; import { useAuthStore, ROLE_AGENT } from "../../stores/auth.js";
import { import {
listAdminCardKeysApi, listOemCardKeysApi,
createAdminCardKeysApi, updateOemCardKeyApi,
updateAdminCardKeyApi, assignOemCardKeysToAgentApi,
deleteAdminCardKeyApi,
} from "../../api/oemCardKeys.js"; } from "../../api/oemCardKeys.js";
import { listOemUsersApi } from "../../api/oemUsers.js"; import { listOemUsersApi } from "../../api/oemUsers.js";
@@ -17,62 +16,41 @@ const STATUS_OPTIONS = [
{ label: "已激活", value: "used" }, { label: "已激活", value: "used" },
]; ];
const ASSIGN_OPTIONS = [
{ label: "不指定", value: "none" },
{ label: "分配给 OEM", value: "oem" },
{ label: "分配给代理", value: "agent" },
];
const cards = ref([]); const cards = ref([]);
const total = ref(0); const total = ref(0);
const page = ref(1); const page = ref(1);
const pageSize = ref(20); const pageSize = ref(20);
const statusFilter = ref("all"); const statusFilter = ref("all");
const oemOptions = ref([]);
const agentOptions = ref([]); const agentOptions = ref([]);
const filters = ref({ const filters = ref({
username: "", username: "",
oem_id: null,
agent_id: null, agent_id: null,
}); });
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const assigning = ref(false);
const feedback = ref({ severity: "", message: "" }); const feedback = ref({ severity: "", message: "" });
const dialogVisible = ref(false); const selectedCards = ref([]);
const dialogMode = ref("create"); const assignDialogVisible = ref(false);
const assignAgentId = ref(null);
const remarkDialogVisible = ref(false);
const editingId = ref(null); const editingId = ref(null);
const editingSerial = ref("");
const emptyForm = () => ({ const remarkForm = ref({ oem_remark: "" });
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 ownerNameById = computed(() => {
const map = new Map(); 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); for (const u of agentOptions.value) map.set(u.id, u.username);
return map; return map;
}); });
const assignSelectOptions = computed(() => { const selectedUnusedCards = computed(() =>
if (form.value.assign_type === "oem") return oemOptions.value; selectedCards.value.filter((row) => !row.activated_at),
if (form.value.assign_type === "agent") return agentOptions.value; );
return [];
}); const canBatchAssign = computed(() => selectedUnusedCards.value.length > 0);
function ownerLabel(userId) { function ownerLabel(userId) {
if (userId == null) return "—"; if (userId == null) return "—";
@@ -86,30 +64,33 @@ function formatDateTime(value) {
return d.toLocaleString("zh-CN", { hour12: false }); return d.toLocaleString("zh-CN", { hour12: false });
} }
function isRowSelectable(row) {
return !row.activated_at;
}
function showMessage(severity, message) { function showMessage(severity, message) {
feedback.value = { severity, message }; feedback.value = { severity, message };
} }
async function loadFilterOptions() { async function loadFilterOptions() {
const [ agentRes] = await Promise.all([ const res = await listOemUsersApi(auth.token, {
page: 1,
listOemUsersApi(auth.token, { page: 1, pageSize: 200, roleId: ROLE_AGENT }), pageSize: 200,
]); roleId: ROLE_AGENT,
});
if (agentRes.ok && Array.isArray(agentRes.data?.items)) { if (res.ok && Array.isArray(res.data?.items)) {
agentOptions.value = agentRes.data.items; agentOptions.value = res.data.items;
} }
} }
async function loadCards() { async function loadCards() {
loading.value = true; loading.value = true;
feedback.value = { severity: "", message: "" }; feedback.value = { severity: "", message: "" };
const res = await listAdminCardKeysApi(auth.token, { const res = await listOemCardKeysApi(auth.token, {
page: page.value, page: page.value,
pageSize: pageSize.value, pageSize: pageSize.value,
status: statusFilter.value, status: statusFilter.value,
username: filters.value.username, username: filters.value.username,
oemId: filters.value.oem_id,
agentId: filters.value.agent_id, agentId: filters.value.agent_id,
}); });
loading.value = false; loading.value = false;
@@ -124,6 +105,7 @@ async function loadCards() {
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; if (typeof data?.page === "number") page.value = data.page;
if (typeof data?.page_size === "number") pageSize.value = data.page_size; if (typeof data?.page_size === "number") pageSize.value = data.page_size;
selectedCards.value = [];
} }
async function onPage(event) { async function onPage(event) {
@@ -143,32 +125,71 @@ async function applyFilters() {
} }
async function resetFilters() { async function resetFilters() {
filters.value = { username: "", oem_id: null, agent_id: null }; filters.value = { username: "", agent_id: null };
page.value = 1; page.value = 1;
await loadCards(); await loadCards();
} }
function openCreateDialog() { function openRemarkDialog(row) {
dialogMode.value = "create"; editingId.value = row.id;
editingId.value = null; editingSerial.value = row.serial_number;
editingActivatedAt.value = null; remarkForm.value = { oem_remark: row.oem_remark || "" };
form.value = emptyForm(); remarkDialogVisible.value = true;
dialogVisible.value = true;
} }
function openEditDialog(row) { function openAssignDialog() {
dialogMode.value = "edit"; if (!canBatchAssign.value) {
editingId.value = row.id; showMessage("warn", "请勾选未使用的卡密");
editingActivatedAt.value = row.activated_at; return;
form.value = { }
serial_number: row.serial_number, assignAgentId.value = null;
duration_days: row.duration_days, assignDialogVisible.value = true;
count: 1, }
remark: row.remark || "",
assign_type: "none", async function saveRemark() {
assign_id: null, saving.value = true;
}; feedback.value = { severity: "", message: "" };
dialogVisible.value = true;
const res = await updateOemCardKeyApi(auth.token, editingId.value, {
oem_remark: remarkForm.value.oem_remark.trim() || null,
});
saving.value = false;
if (!res.ok) {
showMessage("error", res.message || "保存失败");
return;
}
remarkDialogVisible.value = false;
showMessage("success", res.message || "备注已更新");
await loadCards();
}
async function confirmAssign() {
if (assignAgentId.value == null) {
showMessage("warn", "请选择代理");
return;
}
assigning.value = true;
feedback.value = { severity: "", message: "" };
const res = await assignOemCardKeysToAgentApi(auth.token, {
card_ids: selectedUnusedCards.value.map((c) => c.id),
agent_id: assignAgentId.value,
});
assigning.value = false;
if (!res.ok) {
showMessage("error", res.message || "分配失败");
return;
}
assignDialogVisible.value = false;
showMessage("success", res.message || "分配成功");
await loadCards();
} }
async function copySerial(serial) { async function copySerial(serial) {
@@ -180,98 +201,8 @@ async function copySerial(serial) {
} }
} }
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(statusFilter, onStatusChange);
watch(
() => form.value.assign_type,
() => {
form.value.assign_id = null;
},
);
onMounted(async () => { onMounted(async () => {
await loadFilterOptions(); await loadFilterOptions();
await loadCards(); await loadCards();
@@ -284,7 +215,11 @@ onMounted(async () => {
<div> <div>
<h1 class="admin-page__title gradient-text">卡密管理</h1> <h1 class="admin-page__title gradient-text">卡密管理</h1>
</div> </div>
<Button label="生成卡密" @click="openCreateDialog" /> <Button
label="分配给代理"
:disabled="!canBatchAssign"
@click="openAssignDialog"
/>
</div> </div>
<Message <Message
@@ -304,7 +239,6 @@ onMounted(async () => {
class="admin-users__search" class="admin-users__search"
@keyup.enter="applyFilters" @keyup.enter="applyFilters"
/> />
<Select <Select
v-model="filters.agent_id" v-model="filters.agent_id"
:options="agentOptions" :options="agentOptions"
@@ -328,6 +262,7 @@ onMounted(async () => {
</div> </div>
<DataTable <DataTable
v-model:selection="selectedCards"
:value="cards" :value="cards"
:loading="loading" :loading="loading"
lazy lazy
@@ -342,8 +277,10 @@ onMounted(async () => {
size="small" size="small"
class="admin-users__table" class="admin-users__table"
data-key="id" data-key="id"
:selectable="isRowSelectable"
@page="onPage" @page="onPage"
> >
<Column selection-mode="multiple" header-style="width: 3rem" />
<Column field="serial_number" header="序列号" style="min-width: 11rem"> <Column field="serial_number" header="序列号" style="min-width: 11rem">
<template #body="{ data }"> <template #body="{ data }">
<div class="admin-card-keys__serial"> <div class="admin-card-keys__serial">
@@ -359,7 +296,6 @@ onMounted(async () => {
</template> </template>
</Column> </Column>
<Column field="duration_days" header="时长(天)" style="width: 6rem" /> <Column field="duration_days" header="时长(天)" style="width: 6rem" />
<Column header="代理" style="width: 7rem"> <Column header="代理" style="width: 7rem">
<template #body="{ data }"> <template #body="{ data }">
{{ ownerLabel(data.agent_id) }} {{ ownerLabel(data.agent_id) }}
@@ -385,30 +321,20 @@ onMounted(async () => {
{{ data.username || "—" }} {{ data.username || "—" }}
</template> </template>
</Column> </Column>
<Column field="remark" header="备注"> <Column field="oem_remark" header="OEM 备注">
<template #body="{ data }"> <template #body="{ data }">
{{ data.oem_remark || "—" }} {{ data.oem_remark || "—" }}
</template> </template>
</Column> </Column>
<Column header="操作" style="width: 9rem"> <Column header="操作" style="width: 6rem">
<template #body="{ data }"> <template #body="{ data }">
<div class="admin-users__actions">
<Button <Button
label="编辑" label="备注"
size="small" size="small"
severity="secondary" severity="secondary"
text text
@click="openEditDialog(data)" @click="openRemarkDialog(data)"
/> />
<Button
label="删除"
size="small"
severity="danger"
text
:disabled="!!data.activated_at"
@click="removeCard(data)"
/>
</div>
</template> </template>
</Column> </Column>
<template #empty> <template #empty>
@@ -417,73 +343,51 @@ onMounted(async () => {
</DataTable> </DataTable>
<Dialog <Dialog
v-model:visible="dialogVisible" v-model:visible="remarkDialogVisible"
:header="dialogTitle" header="编辑 OEM 备注"
modal modal
:style="{ width: '28rem' }" :style="{ width: '28rem' }"
> >
<div class="flex flex-col gap-4"> <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>
<InputText :model-value="editingSerial" class="w-full font-mono" disabled />
</div>
<div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">OEM 备注</label>
<Textarea v-model="remarkForm.oem_remark" rows="3" class="w-full" />
</div>
</div>
<template #footer>
<Button label="取消" severity="secondary" text @click="remarkDialogVisible = false" />
<Button label="保存" :loading="saving" @click="saveRemark" />
</template>
</Dialog>
<Dialog
v-model:visible="assignDialogVisible"
header="分配给代理"
modal
:style="{ width: '24rem' }"
>
<p class="mb-4 text-sm text-text-muted">
已选择 {{ selectedUnusedCards.length }} 张未使用卡密
</p>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">生成数量</label> <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 <Select
v-model="form.assign_type" v-model="assignAgentId"
:options="ASSIGN_OPTIONS" :options="agentOptions"
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-label="username"
option-value="id" option-value="id"
placeholder="请选择" placeholder="请选择代理"
filter filter
class="w-full" class="w-full"
/> />
</div> </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>
</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> <template #footer>
<Button label="取消" severity="secondary" text @click="dialogVisible = false" /> <Button label="取消" severity="secondary" text @click="assignDialogVisible = false" />
<Button label="保存" :loading="saving" @click="saveCard" /> <Button label="确认分配" :loading="assigning" @click="confirmAssign" />
</template> </template>
</Dialog> </Dialog>
</div> </div>