feat: 实现管理后台 W1-W3 假数据界面

This commit is contained in:
Nixevol
2026-09-30 07:02:33 +08:00
parent 8dde564517
commit 1cff321f2a
33 changed files with 4773 additions and 31 deletions
+505
View File
@@ -0,0 +1,505 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from "vue";
import type { DataTableColumns, DataTableRowKey } from "naive-ui";
import {
NButton,
NDataTable,
NForm,
NFormItem,
NInput,
NInputNumber,
NModal,
NScrollbar,
NSelect,
NSpace,
NSwitch,
NText,
NUpload,
useDialog,
} from "naive-ui";
import type { UploadCustomRequestOptions } from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import HelpTip from "@/components/HelpTip.vue";
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
import {
batchEndpoints,
createEndpoint,
importEndpoints,
kickEndpoint,
listEndpoints,
patchEndpoint,
resetLoginPassword,
setTalkPassword,
unlockEndpoint,
type Endpoint,
type ImportItem,
} from "@/api/admin";
import { ApiError } from "@/api/http";
import { formatLocalMs } from "@/utils/time";
import { message } from "@/utils/notify";
const dialog = useDialog();
const loading = ref(false);
const rows = ref<Endpoint[]>([]);
const total = ref(0);
const checkedKeys = ref<DataTableRowKey[]>([]);
const page = ref(1);
const pageSize = ref(50);
const filters = reactive({
source: "" as "" | "admin" | "self",
online: "" as "" | "true" | "false",
query: "",
});
const sourceOptions = [
{ label: "全部来源", value: "" },
{ label: "后台开通", value: "admin" },
{ label: "自助注册", value: "self" },
];
const onlineOptions = [
{ label: "全部状态", value: "" },
{ label: "在线", value: "true" },
{ label: "离线", value: "false" },
];
const onceSecret = ref<{ title: string; secret: string; filename: string } | null>(null);
const importResult = ref<ImportItem[] | null>(null);
const createOpen = ref(false);
const createForm = reactive({
id: "",
name: "",
remark: "",
login_password: "",
talk_password: "",
default_delay_seconds: 0,
});
const createLoading = ref(false);
const editOpen = ref(false);
const editId = ref("");
const editForm = reactive({
name: "",
remark: "",
default_delay_seconds: 0,
enabled: true,
});
const editLoading = ref(false);
const talkOpen = ref(false);
const talkId = ref("");
const talkPassword = ref("");
async function load() {
loading.value = true;
try {
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
const res = await listEndpoints({
cursor,
limit: pageSize.value,
source: filters.source || undefined,
online: filters.online === "" ? undefined : filters.online === "true",
query: filters.query.trim() || undefined,
});
rows.value = res.items;
total.value = res.total;
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
});
function sourceLabel(s: string) {
return s === "self" ? "自助注册" : "后台开通";
}
const columns = computed<DataTableColumns<Endpoint>>(() => [
{ type: "selection" },
{ title: "编号", key: "id", width: 120, ellipsis: { tooltip: true } },
{ title: "名称", key: "name", width: 120, ellipsis: { tooltip: true } },
{
title: "来源",
key: "source",
width: 90,
render: (r) => sourceLabel(r.source),
},
{
title: "在线",
key: "online",
width: 70,
render: (r) => (r.online ? "是" : "否"),
},
{
title: "启用",
key: "enabled",
width: 70,
render: (r) => (r.enabled ? "是" : "否"),
},
{
title: "锁定",
key: "login_locked",
width: 70,
render: (r) => (r.login_locked ? "是" : "否"),
},
{
title: "最近在线",
key: "online_since_ms",
width: 160,
render: (r) => formatLocalMs(r.online ? r.online_since_ms : r.offline_since_ms),
},
{
title: "创建时间",
key: "created_at_ms",
width: 160,
render: (r) => formatLocalMs(r.created_at_ms),
},
{
title: "操作",
key: "actions",
width: 360,
fixed: "right",
render: (r) =>
h(NSpace, { size: 4, wrap: false }, () => [
h(NButton, { size: "tiny", onClick: () => openEdit(r) }, () => "编辑"),
h(NButton, { size: "tiny", onClick: () => void onResetPwd(r) }, () => "重置密码"),
h(NButton, { size: "tiny", onClick: () => openTalk(r) }, () => "对话密码"),
h(
NButton,
{ size: "tiny", disabled: !r.login_locked, onClick: () => void onUnlock(r) },
() => "解锁",
),
h(NButton, { size: "tiny", onClick: () => void onKick(r) }, () => "踢下线"),
]),
},
]);
function openCreate() {
Object.assign(createForm, {
id: "",
name: "",
remark: "",
login_password: "",
talk_password: "",
default_delay_seconds: 0,
});
createOpen.value = true;
}
async function submitCreate() {
if (!createForm.name.trim()) {
message.error("名称不能为空");
return;
}
createLoading.value = true;
try {
const res = await createEndpoint({
id: createForm.id.trim() || undefined,
name: createForm.name.trim(),
remark: createForm.remark,
login_password: createForm.login_password || undefined,
talk_password: createForm.talk_password || undefined,
default_delay_seconds: createForm.default_delay_seconds,
});
createOpen.value = false;
onceSecret.value = {
title: "开通成功,登录密码只显示一次",
secret: `${res.id}\t${res.login_password}`,
filename: `${res.id}-password.txt`,
};
await load();
} finally {
createLoading.value = false;
}
}
function openEdit(r: Endpoint) {
editId.value = r.id;
Object.assign(editForm, {
name: r.name,
remark: r.remark,
default_delay_seconds: Math.round(r.default_delay_ms / 1000),
enabled: r.enabled,
});
editOpen.value = true;
}
async function submitEdit() {
editLoading.value = true;
try {
await patchEndpoint(editId.value, {
name: editForm.name,
remark: editForm.remark,
default_delay_seconds: editForm.default_delay_seconds,
enabled: editForm.enabled,
});
editOpen.value = false;
message.success("已保存");
await load();
} finally {
editLoading.value = false;
}
}
function openTalk(r: Endpoint) {
talkId.value = r.id;
talkPassword.value = "";
talkOpen.value = true;
}
async function submitTalk() {
await setTalkPassword(talkId.value, talkPassword.value);
talkOpen.value = false;
message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码");
await load();
}
async function onResetPwd(r: Endpoint) {
dialog.warning({
title: "重置登录密码",
content: `确定重置「${r.id}」的登录密码?当前会话将失效。`,
positiveText: "重置",
negativeText: "取消",
onPositiveClick: async () => {
const res = await resetLoginPassword(r.id);
onceSecret.value = {
title: "新登录密码只显示一次",
secret: res.login_password,
filename: `${r.id}-new-password.txt`,
};
},
});
}
async function onUnlock(r: Endpoint) {
await unlockEndpoint(r.id);
message.success("已解除锁定");
await load();
}
async function onKick(r: Endpoint) {
const res = await kickEndpoint(r.id);
message.success(res.kicked ? "已踢下线" : "当时不在线");
await load();
}
async function onBatch(action: "disable" | "delete") {
const ids = checkedKeys.value.map(String);
if (!ids.length) {
message.warning("请先多选");
return;
}
dialog.warning({
title: action === "disable" ? "批量停用" : "批量删除",
content: `对 ${ids.length} 个端执行${action === "disable" ? "停用" : "删除"}?`,
positiveText: "确定",
negativeText: "取消",
onPositiveClick: async () => {
const res = await batchEndpoints(ids, action);
message.success(`成功 ${res.ok_ids.length},失败 ${res.failed.length}`);
checkedKeys.value = [];
await load();
},
});
}
async function onImport(options: UploadCustomRequestOptions) {
const raw = options.file.file;
if (!raw) {
options.onError();
return;
}
try {
const text = await raw.text();
const res = await importEndpoints(text);
importResult.value = res.items;
onceSecret.value = {
title: "CSV 导入成功,密码只显示一次",
secret: res.items.map((i) => `${i.id},${i.name},${i.login_password}`).join("\n"),
filename: "import-passwords.csv",
};
options.onFinish();
await load();
} catch (e) {
options.onError();
if (e instanceof ApiError && e.data && typeof e.data === "object" && "errors" in (e.data as object)) {
const errors = (e.data as { errors: { line: number; reason: string }[] }).errors;
message.error(errors.map((x) => `第 ${x.line} 行:${x.reason}`).join(";") || e.message);
} else if (e instanceof Error) {
message.error(e.message);
}
}
}
function onPageChange(p: number) {
page.value = p;
void load();
}
function onPageSizeChange(s: number) {
pageSize.value = s;
page.value = 1;
void load();
}
</script>
<template>
<div class="page">
<PageHeader title="端" help="可按来源与在线筛选;开通/重置/导入生成的密码只显示一次。">
<template #actions>
<n-button type="primary" @click="openCreate">开通</n-button>
<n-upload :show-file-list="false" accept=".csv,text/csv" :custom-request="onImport">
<n-button>CSV 导入</n-button>
</n-upload>
<n-button @click="onBatch('disable')">批量停用</n-button>
<n-button type="error" secondary @click="onBatch('delete')">批量删除</n-button>
</template>
</PageHeader>
<div class="page-body">
<SecretOnceAlert
v-if="onceSecret"
:title="onceSecret.title"
:secret="onceSecret.secret"
:filename="onceSecret.filename"
@dismiss="onceSecret = null"
/>
<n-space style="margin-bottom: 12px" align="center">
<n-select v-model:value="filters.source" :options="sourceOptions" style="width: 140px" @update:value="page = 1; load()" />
<n-select v-model:value="filters.online" :options="onlineOptions" style="width: 120px" @update:value="page = 1; load()" />
<n-input
v-model:value="filters.query"
clearable
placeholder="编号/名称"
style="width: 180px"
@keyup.enter="page = 1; load()"
/>
<n-button @click="page = 1; load()">查询</n-button>
<span>
来源筛选
<HelpTip>admin=后台开通,self=自助注册。</HelpTip>
</span>
</n-space>
<n-data-table
remote
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(r: Endpoint) => r.id"
:checked-row-keys="checkedKeys"
:max-height="480"
:scroll-x="1200"
:pagination="{
page,
pageSize,
itemCount: total,
showSizePicker: true,
pageSizes: [20, 50, 100],
onChange: onPageChange,
onUpdatePageSize: onPageSizeChange,
}"
@update:checked-row-keys="(k) => (checkedKeys = k)"
/>
</div>
<n-modal v-model:show="createOpen" preset="card" title="开通端" style="width: 520px" :mask-closable="false">
<n-scrollbar style="max-height: 360px">
<n-form label-placement="left" label-width="110" :show-feedback="false">
<n-form-item label="编号">
<n-input v-model:value="createForm.id" placeholder="留空则生成" data-testid="create-id" />
</n-form-item>
<n-form-item label="名称">
<n-input v-model:value="createForm.name" data-testid="create-name" />
</n-form-item>
<n-form-item label="登录密码">
<n-input v-model:value="createForm.login_password" placeholder="留空则生成" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="对话密码">
<n-input v-model:value="createForm.talk_password" placeholder="可选" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="默认延迟(秒)">
<n-input-number v-model:value="createForm.default_delay_seconds" :min="0" style="width: 100%" />
</n-form-item>
<n-form-item label="备注" label-placement="top">
<n-input v-model:value="createForm.remark" type="textarea" :rows="3" />
</n-form-item>
</n-form>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="createOpen = false">取消</n-button>
<n-button type="primary" :loading="createLoading" data-testid="create-submit" @click="submitCreate">开通</n-button>
</n-space>
</template>
</n-modal>
<n-modal v-model:show="editOpen" preset="card" title="编辑端" style="width: 480px" :mask-closable="false">
<n-scrollbar style="max-height: 320px">
<n-form label-placement="left" label-width="110" :show-feedback="false">
<n-form-item label="名称">
<n-input v-model:value="editForm.name" />
</n-form-item>
<n-form-item label="默认延迟(秒)">
<n-input-number v-model:value="editForm.default_delay_seconds" :min="0" style="width: 100%" />
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="editForm.enabled" />
</n-form-item>
<n-form-item label="备注" label-placement="top">
<n-input v-model:value="editForm.remark" type="textarea" :rows="3" />
</n-form-item>
</n-form>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="editOpen = false">取消</n-button>
<n-button type="primary" :loading="editLoading" @click="submitEdit">保存</n-button>
</n-space>
</template>
</n-modal>
<n-modal v-model:show="talkOpen" preset="card" title="设置对话密码" style="width: 420px">
<n-form label-placement="left" label-width="90" :show-feedback="false">
<n-form-item label="对话密码">
<n-input v-model:value="talkPassword" type="password" show-password-on="click" placeholder="留空表示清除" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="talkOpen = false">取消</n-button>
<n-button type="primary" @click="submitTalk">确定</n-button>
</n-space>
</template>
</n-modal>
<n-modal v-if="importResult" :show="true" preset="card" title="导入结果" style="width: 560px" @update:show="(v) => !v && (importResult = null)">
<n-scrollbar style="max-height: 280px">
<n-text depth="3">以下密码仅本次可见,请下载保存。</n-text>
<pre style="font-size: 12px; white-space: pre-wrap">{{ importResult.map((i) => `${i.id}\t${i.name}\t${i.login_password}`).join("\n") }}</pre>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="importResult = null">关闭</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.page-body {
flex: 1;
overflow: auto;
padding: 16px;
min-height: 0;
}
</style>