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
+70
View File
@@ -0,0 +1,70 @@
import { config, mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it } from "vitest";
import {
NConfigProvider,
NDialogProvider,
NMessageProvider,
dateZhCN,
zhCN,
} from "naive-ui";
import { defineComponent, h, nextTick } from "vue";
import EndpointsView from "./EndpointsView.vue";
import { mockApi } from "@/api/mock";
config.global.stubs = { teleport: true };
function wrap(Comp: object) {
return defineComponent({
setup() {
return () =>
h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, {
default: () =>
h(NMessageProvider, null, {
default: () =>
h(NDialogProvider, null, {
default: () => h(Comp),
}),
}),
});
},
});
}
describe("EndpointsView 开通端", () => {
beforeEach(() => {
setActivePinia(createPinia());
mockApi._setSession();
});
it("开通后密码只显示一次,关闭后消失", async () => {
const pinia = createPinia();
const w = mount(wrap(EndpointsView), {
global: { plugins: [pinia] },
attachTo: document.body,
});
await flushPromises();
const openBtn = w.findAll("button").find((b) => b.text().trim() === "开通");
expect(openBtn).toBeTruthy();
await openBtn!.trigger("click");
await nextTick();
await flushPromises();
const nameRoot = w.find('[data-testid="create-name"]');
expect(nameRoot.exists()).toBe(true);
await nameRoot.find("input").setValue("测试端");
await w.find('[data-testid="create-submit"]').trigger("click");
await flushPromises();
expect(w.text()).toContain("只显示一次");
const dismiss = w.findAll("button").find((b) => b.text().includes("我已保存"));
expect(dismiss).toBeTruthy();
await dismiss!.trigger("click");
await nextTick();
expect(w.text()).not.toContain("只显示一次");
w.unmount();
});
});
+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>
+332
View File
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from "vue";
import type { DataTableColumns } from "naive-ui";
import {
NButton,
NDataTable,
NForm,
NFormItem,
NInput,
NModal,
NScrollbar,
NSpace,
useDialog,
} from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import {
addGroupMembers,
createGroup,
deleteGroup,
getGroup,
listGroups,
removeGroupMember,
renameGroup,
transferGroup,
type GroupDetail,
type GroupMember,
type GroupSummary,
} from "@/api/admin";
import { formatLocalMs } from "@/utils/time";
import { message } from "@/utils/notify";
const dialog = useDialog();
const loading = ref(false);
const rows = ref<GroupSummary[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(50);
const query = ref("");
const createOpen = ref(false);
const createForm = reactive({ id: "", name: "", owner_id: "", member_ids: "" });
const createLoading = ref(false);
const detailOpen = ref(false);
const detail = ref<GroupDetail | null>(null);
const renameName = ref("");
const addMembers = ref("");
async function load() {
loading.value = true;
try {
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
const res = await listGroups(query.value.trim() || undefined, cursor, pageSize.value);
rows.value = res.items;
total.value = res.total;
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
});
const columns = computed<DataTableColumns<GroupSummary>>(() => [
{ title: "编号", key: "id", width: 140 },
{ title: "名称", key: "name", width: 140 },
{ title: "群主", key: "owner_id", width: 120 },
{ title: "成员数", key: "member_count", width: 80 },
{
title: "创建时间",
key: "created_at_ms",
width: 160,
render: (r) => formatLocalMs(r.created_at_ms),
},
{
title: "操作",
key: "actions",
width: 200,
render: (r) =>
h(NSpace, { size: 4 }, () => [
h(NButton, { size: "tiny", onClick: () => void openDetail(r.id) }, () => "详情"),
h(
NButton,
{
size: "tiny",
type: "error",
secondary: true,
onClick: () => onDelete(r),
},
() => "解散",
),
]),
},
]);
const memberColumns = computed<DataTableColumns<GroupMember>>(() => [
{ title: "编号", key: "id" },
{ title: "名称", key: "name" },
{ title: "在线", key: "online", render: (r) => (r.online ? "是" : "否") },
{
title: "加入时间",
key: "joined_at_ms",
render: (r) => formatLocalMs(r.joined_at_ms),
},
{
title: "操作",
key: "actions",
render: (r) =>
h(NSpace, { size: 4 }, () => [
h(
NButton,
{
size: "tiny",
disabled: detail.value?.owner_id === r.id,
onClick: () => void onTransfer(r.id),
},
() => "设为群主",
),
h(
NButton,
{
size: "tiny",
type: "error",
secondary: true,
disabled: detail.value?.owner_id === r.id,
onClick: () => void onRemove(r.id),
},
() => "移除",
),
]),
},
]);
async function openDetail(id: string) {
detail.value = await getGroup(id);
renameName.value = detail.value.name;
addMembers.value = "";
detailOpen.value = true;
}
async function submitCreate() {
if (!createForm.name.trim() || !createForm.owner_id.trim()) {
message.error("名称与群主必填");
return;
}
createLoading.value = true;
try {
const member_ids = createForm.member_ids
.split(/[,,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
const res = await createGroup({
id: createForm.id.trim() || undefined,
name: createForm.name.trim(),
owner_id: createForm.owner_id.trim(),
member_ids,
});
createOpen.value = false;
if (res.failed.length) {
message.warning(`已创建,部分成员失败:${res.failed.map((f) => f.id).join(", ")}`);
} else {
message.success("已创建");
}
await load();
} finally {
createLoading.value = false;
}
}
function onDelete(r: GroupSummary) {
dialog.warning({
title: "解散群",
content: `确定解散「${r.name}」?`,
positiveText: "解散",
negativeText: "取消",
onPositiveClick: async () => {
await deleteGroup(r.id);
message.success("已解散");
await load();
},
});
}
async function onRename() {
if (!detail.value) return;
await renameGroup(detail.value.id, renameName.value.trim());
message.success("已改名");
await openDetail(detail.value.id);
await load();
}
async function onAddMembers() {
if (!detail.value) return;
const ids = addMembers.value
.split(/[,,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
if (!ids.length) return;
const res = await addGroupMembers(detail.value.id, ids);
if (res.failed.length) {
message.warning(`部分失败:${res.failed.map((f) => f.id).join(", ")}`);
} else {
message.success("已加人");
}
await openDetail(detail.value.id);
await load();
}
async function onRemove(endpointId: string) {
if (!detail.value) return;
await removeGroupMember(detail.value.id, endpointId);
message.success("已移除");
await openDetail(detail.value.id);
await load();
}
async function onTransfer(endpointId: string) {
if (!detail.value) return;
await transferGroup(detail.value.id, endpointId);
message.success("已转让群主");
await openDetail(detail.value.id);
await load();
}
</script>
<template>
<div class="page">
<PageHeader title="群" help="后台建群与加人不必填对话密码。">
<template #actions>
<n-button
type="primary"
@click="
Object.assign(createForm, { id: '', name: '', owner_id: '', member_ids: '' });
createOpen = true;
"
>
新建群
</n-button>
</template>
</PageHeader>
<div class="page-body">
<n-space style="margin-bottom: 12px">
<n-input v-model:value="query" placeholder="名称/编号" style="width: 200px" @keyup.enter="page = 1; load()" />
<n-button @click="page = 1; load()">查询</n-button>
</n-space>
<n-data-table
remote
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(r: GroupSummary) => r.id"
:max-height="480"
:pagination="{
page,
pageSize,
itemCount: total,
onChange: (p: number) => {
page = p;
load();
},
}"
/>
</div>
<n-modal v-model:show="createOpen" preset="card" title="新建群" style="width: 480px">
<n-scrollbar style="max-height: 320px">
<n-form label-placement="left" label-width="90" :show-feedback="false">
<n-form-item label="编号">
<n-input v-model:value="createForm.id" placeholder="留空则生成" />
</n-form-item>
<n-form-item label="名称">
<n-input v-model:value="createForm.name" />
</n-form-item>
<n-form-item label="群主编号">
<n-input v-model:value="createForm.owner_id" />
</n-form-item>
<n-form-item label="成员" label-placement="top">
<n-input v-model:value="createForm.member_ids" type="textarea" :rows="3" placeholder="多个编号用逗号或空格分隔" />
</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" @click="submitCreate">创建</n-button>
</n-space>
</template>
</n-modal>
<n-modal v-model:show="detailOpen" preset="card" title="群详情" style="width: 720px">
<n-scrollbar v-if="detail" style="max-height: 420px">
<n-form label-placement="left" label-width="80" :show-feedback="false" style="margin-bottom: 12px">
<n-form-item label="编号">{{ detail.id }}</n-form-item>
<n-form-item label="群主">{{ detail.owner_id }}</n-form-item>
<n-form-item label="名称">
<n-space>
<n-input v-model:value="renameName" style="width: 200px" />
<n-button size="small" @click="onRename">改名</n-button>
</n-space>
</n-form-item>
<n-form-item label="加人" label-placement="top">
<n-space vertical style="width: 100%">
<n-input v-model:value="addMembers" type="textarea" :rows="2" placeholder="成员编号,逗号分隔" />
<n-button size="small" @click="onAddMembers">加人</n-button>
</n-space>
</n-form-item>
</n-form>
<n-data-table :columns="memberColumns" :data="detail.members" :row-key="(r: GroupMember) => r.id" size="small" />
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="detailOpen = false">关闭</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;
}
</style>
-15
View File
@@ -1,15 +0,0 @@
<script setup lang="ts">
import { NCard, NSpace, NText } from "naive-ui";
import { storeToRefs } from "pinia";
import { useAppStore } from "@/stores/app";
const { title } = storeToRefs(useAppStore());
</script>
<template>
<n-space vertical style="padding: 24px">
<n-card :title="title">
<n-text>管理后台骨架(T0.1)。后续页面由 W 线实现。</n-text>
</n-card>
</n-space>
</template>
+100
View File
@@ -0,0 +1,100 @@
import { config, mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { createMemoryHistory, createRouter } from "vue-router";
import { beforeEach, describe, expect, it } from "vitest";
import {
NConfigProvider,
NDialogProvider,
NMessageProvider,
dateZhCN,
zhCN,
} from "naive-ui";
import { defineComponent, h } from "vue";
import LoginView from "./LoginView.vue";
import { mockApi } from "@/api/mock";
import { useAuthStore } from "@/stores/auth";
config.global.stubs = { teleport: true };
function wrap(child: object) {
return defineComponent({
setup() {
return () =>
h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, {
default: () =>
h(NMessageProvider, null, {
default: () =>
h(NDialogProvider, null, {
default: () => h(child),
}),
}),
});
},
});
}
describe("LoginView", () => {
beforeEach(() => {
mockApi._resetSession();
sessionStorage.clear();
});
it("登录成功后写入会话", async () => {
const pinia = createPinia();
setActivePinia(pinia);
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/login", component: LoginView },
{ path: "/overview", component: { template: "<div>ok</div>" } },
],
});
await router.push("/login");
await router.isReady();
const w = mount(wrap(LoginView), {
global: { plugins: [pinia, router] },
attachTo: document.body,
});
const inputs = w.findAll("input");
expect(inputs.length).toBeGreaterThanOrEqual(2);
await inputs[0].setValue("admin");
await inputs[1].setValue("adminpassword");
await w.find('[data-testid="login-submit"]').trigger("click");
await flushPromises();
expect(useAuthStore().isLoggedIn).toBe(true);
expect(useAuthStore().username).toBe("admin");
w.unmount();
});
it("密码错误时显示错误且未登录", async () => {
const pinia = createPinia();
setActivePinia(pinia);
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: LoginView },
{ path: "/overview", component: { template: "<div />" } },
],
});
await router.push("/");
await router.isReady();
const w = mount(wrap(LoginView), {
global: { plugins: [pinia, router] },
attachTo: document.body,
});
const inputs = w.findAll("input");
await inputs[0].setValue("admin");
await inputs[1].setValue("wrong");
await w.find('[data-testid="login-submit"]').trigger("click");
await flushPromises();
expect(useAuthStore().isLoggedIn).toBe(false);
expect(w.text()).toContain("用户名或密码错误");
w.unmount();
});
});
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { NButton, NCard, NForm, NFormItem, NInput, NSpace, NText } from "naive-ui";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const username = ref("admin");
const password = ref("");
const loading = ref(false);
const formError = ref("");
async function onSubmit() {
formError.value = "";
loading.value = true;
try {
await auth.login(username.value.trim(), password.value);
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/overview";
await router.replace(redirect || "/overview");
} catch (e) {
formError.value = e instanceof Error ? e.message : "登录失败";
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-page">
<n-card title="NixMsg 管理后台" style="width: 380px" size="small">
<n-form label-placement="left" label-width="72" :show-feedback="false" @submit.prevent="onSubmit">
<n-form-item label="用户名">
<n-input v-model:value="username" autocomplete="username" placeholder="admin" />
</n-form-item>
<n-form-item label="密码">
<n-input
v-model:value="password"
type="password"
show-password-on="click"
autocomplete="current-password"
placeholder="请输入密码"
@keyup.enter="onSubmit"
/>
</n-form-item>
<n-text v-if="formError" type="error" style="display: block; margin-bottom: 8px">{{ formError }}</n-text>
<n-space justify="end">
<n-button type="primary" :loading="loading" data-testid="login-submit" @click="onSubmit">
登录
</n-button>
</n-space>
</n-form>
<n-text depth="3" style="display: block; margin-top: 12px; font-size: 12px">
假数据模式:用户名 admin,密码 adminpassword
</n-text>
</n-card>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f7;
}
</style>
+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from "vue";
import type { DataTableColumns } from "naive-ui";
import {
NButton,
NDataTable,
NDescriptions,
NDescriptionsItem,
NInput,
NModal,
NScrollbar,
NSelect,
NSpace,
} from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import HelpTip from "@/components/HelpTip.vue";
import { getMessage, listMessages, type MessageDelivery, type MessageDetail, type MessageSummary } from "@/api/admin";
import { formatLocalMs } from "@/utils/time";
const loading = ref(false);
const rows = ref<MessageSummary[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(50);
const filters = reactive({
sender_id: "",
endpoint_id: "",
group_id: "",
state: "" as "" | "scheduled" | "dispatched" | "completed",
});
const detailOpen = ref(false);
const detail = ref<MessageDetail | null>(null);
const stateOptions = [
{ label: "全部状态", value: "" },
{ label: "定时中", value: "scheduled" },
{ label: "投递中", value: "dispatched" },
{ label: "已完成", value: "completed" },
];
async function load() {
loading.value = true;
try {
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
const res = await listMessages({
cursor,
limit: pageSize.value,
sender_id: filters.sender_id.trim() || undefined,
endpoint_id: filters.endpoint_id.trim() || undefined,
group_id: filters.group_id.trim() || undefined,
state: filters.state || undefined,
});
rows.value = res.items;
total.value = res.total;
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
});
const columns = computed<DataTableColumns<MessageSummary>>(() => [
{ title: "序号", key: "seq", width: 70 },
{ title: "消息号", key: "id", width: 180, ellipsis: { tooltip: true } },
{ title: "发送方", key: "sender_id", width: 100 },
{
title: "目标",
key: "dest",
width: 140,
render: (r) => `${r.dest_kind}:${r.dest_id}`,
},
{ title: "状态", key: "state", width: 100 },
{
title: "投递统计",
key: "delivery_counts",
width: 200,
render: (r) => {
const c = r.delivery_counts;
return `待${c.pending}/收${c.accepted}/撤${c.recalled}/过${c.expired}`;
},
},
{
title: "创建时间",
key: "created_at_ms",
width: 160,
render: (r) => formatLocalMs(r.created_at_ms),
},
{
title: "发送时刻",
key: "send_at_ms",
width: 160,
render: (r) => formatLocalMs(r.send_at_ms),
},
{
title: "操作",
key: "actions",
width: 80,
render: (r) => h(NButton, { size: "tiny", onClick: () => void openDetail(r.seq) }, () => "详情"),
},
]);
const deliveryColumns = computed<DataTableColumns<MessageDelivery>>(() => [
{ title: "接收端", key: "endpoint_id" },
{ title: "状态", key: "state" },
{ title: "次数", key: "attempts", width: 60 },
{
title: "推送时间",
key: "pushed_at_ms",
render: (r) => formatLocalMs(r.pushed_at_ms),
},
{
title: "更新时间",
key: "updated_at_ms",
render: (r) => formatLocalMs(r.updated_at_ms),
},
]);
async function openDetail(seq: number) {
detail.value = await getMessage(seq);
detailOpen.value = true;
}
</script>
<template>
<div class="page">
<PageHeader title="投递记录" help="只显示投递痕迹,不显示正文;接口响应也不含 body 字段。" />
<div class="page-body">
<n-space style="margin-bottom: 12px" align="center">
<n-input v-model:value="filters.sender_id" placeholder="发送方" style="width: 120px" />
<n-input v-model:value="filters.endpoint_id" placeholder="接收端" style="width: 120px" />
<n-input v-model:value="filters.group_id" placeholder="群编号" style="width: 120px" />
<n-select v-model:value="filters.state" :options="stateOptions" style="width: 120px" />
<n-button
@click="
page = 1;
load();
"
>
查询
</n-button>
<span>
无正文
<HelpTip>页面与网络响应均不得出现消息正文。</HelpTip>
</span>
</n-space>
<n-data-table
remote
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(r: MessageSummary) => r.seq"
:max-height="480"
:scroll-x="1100"
:pagination="{
page,
pageSize,
itemCount: total,
onChange: (p: number) => {
page = p;
load();
},
}"
/>
</div>
<n-modal v-model:show="detailOpen" preset="card" title="投递详情" style="width: 720px">
<n-scrollbar v-if="detail" style="max-height: 420px">
<n-descriptions label-placement="left" :column="2" size="small" bordered style="margin-bottom: 12px">
<n-descriptions-item label="序号">{{ detail.seq }}</n-descriptions-item>
<n-descriptions-item label="消息号">{{ detail.id }}</n-descriptions-item>
<n-descriptions-item label="发送方">{{ detail.sender_id }}</n-descriptions-item>
<n-descriptions-item label="目标">{{ detail.dest_kind }}:{{ detail.dest_id }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ detail.state }}</n-descriptions-item>
<n-descriptions-item label="类型">{{ detail.content_type }}</n-descriptions-item>
<n-descriptions-item label="创建">{{ formatLocalMs(detail.created_at_ms) }}</n-descriptions-item>
<n-descriptions-item label="发送">{{ formatLocalMs(detail.send_at_ms) }}</n-descriptions-item>
</n-descriptions>
<n-data-table :columns="deliveryColumns" :data="detail.deliveries" size="small" :row-key="(r: MessageDelivery) => r.endpoint_id" />
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="detailOpen = false">关闭</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;
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { NDescriptions, NDescriptionsItem, NSpin } from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import HelpTip from "@/components/HelpTip.vue";
import { fetchOverview, type Overview } from "@/api/admin";
import { formatLocalMs } from "@/utils/time";
const loading = ref(true);
const data = ref<Overview | null>(null);
onMounted(async () => {
try {
data.value = await fetchOverview();
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="page">
<PageHeader title="概览" help="汇总当前实例的端、群与未完成投递数量,不含正文与编号明细。" />
<div class="page-body">
<n-spin :show="loading">
<n-descriptions v-if="data" label-placement="left" :column="2" bordered size="small">
<n-descriptions-item label="版本">{{ data.version }}</n-descriptions-item>
<n-descriptions-item>
<template #label>
运行时长
<HelpTip>自进程启动起的毫秒数,按本地时区换算展示起点附近时间差。</HelpTip>
</template>
{{ Math.floor(data.uptime_ms / 1000) }} 秒(约 {{ formatLocalMs(Date.now() - data.uptime_ms) }} 起)
</n-descriptions-item>
<n-descriptions-item label="端总数">{{ data.endpoints_total }}</n-descriptions-item>
<n-descriptions-item label="在线端">{{ data.endpoints_online }}</n-descriptions-item>
<n-descriptions-item label="已停用">{{ data.endpoints_disabled }}</n-descriptions-item>
<n-descriptions-item label="群数量">{{ data.groups_total }}</n-descriptions-item>
<n-descriptions-item label="待完成投递">{{ data.messages_pending }}</n-descriptions-item>
<n-descriptions-item label="定时中">{{ data.messages_scheduled }}</n-descriptions-item>
</n-descriptions>
</n-spin>
</div>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.page-body {
flex: 1;
overflow: auto;
padding: 16px;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { NButton, NForm, NFormItem, NInput, NSpace, NSpin, NSwitch, NText } from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import HelpTip from "@/components/HelpTip.vue";
import { getRegistration, updateRegistration, type RegistrationSettings } from "@/api/admin";
import { formatLocalMs } from "@/utils/time";
import { message } from "@/utils/notify";
const loading = ref(true);
const saving = ref(false);
const data = ref<RegistrationSettings | null>(null);
const codeDraft = ref("");
async function load() {
loading.value = true;
try {
data.value = await getRegistration();
codeDraft.value = data.value.code;
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
});
async function onToggle(enabled: boolean) {
if (!data.value) return;
saving.value = true;
try {
data.value = await updateRegistration({ enabled });
message.success(enabled ? "已开启自助注册" : "已关闭自助注册");
} finally {
saving.value = false;
}
}
async function saveCode() {
saving.value = true;
try {
data.value = await updateRegistration({ code: codeDraft.value });
codeDraft.value = data.value.code;
message.success("安全码已更新");
} finally {
saving.value = false;
}
}
async function generateCode() {
saving.value = true;
try {
data.value = await updateRegistration({ generate: true });
codeDraft.value = data.value.code;
message.success("已生成新安全码");
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="page">
<PageHeader title="注册设置" help="关闭时任何自助注册失败。安全码 8–64 字符,可手填或生成,后台可查看明文。" />
<div class="page-body">
<n-spin :show="loading">
<n-form v-if="data" label-placement="left" label-width="100" :show-feedback="false" style="max-width: 560px">
<n-form-item>
<template #label>
开放注册
<HelpTip>开启后需同时设置安全码,客户端注册时携带。</HelpTip>
</template>
<n-switch :value="data.enabled" :loading="saving" @update:value="onToggle" />
</n-form-item>
<n-form-item label="安全码">
<n-input v-model:value="codeDraft" placeholder="8–64 字符" />
</n-form-item>
<n-form-item label="更新时间">
<n-text>{{ formatLocalMs(data.updated_at_ms) }}</n-text>
</n-form-item>
<n-space>
<n-button type="primary" :loading="saving" @click="saveCode">保存安全码</n-button>
<n-button :loading="saving" @click="generateCode">生成安全码</n-button>
</n-space>
</n-form>
</n-spin>
</div>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.page-body {
flex: 1;
overflow: auto;
padding: 16px;
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import {
NButton,
NDescriptions,
NDescriptionsItem,
NForm,
NFormItem,
NInput,
NSpace,
NSpin,
NTabPane,
NTabs,
NText,
} from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import HelpTip from "@/components/HelpTip.vue";
import { changePassword, getSettings, type RuntimeSettings } from "@/api/admin";
import { message } from "@/utils/notify";
const loading = ref(true);
const settings = ref<RuntimeSettings | null>(null);
const pwd = reactive({ old_password: "", new_password: "", confirm: "" });
const pwdLoading = ref(false);
onMounted(async () => {
try {
settings.value = await getSettings();
} finally {
loading.value = false;
}
});
async function submitPassword() {
if (pwd.new_password.length < 12) {
message.error("新密码至少 12 位");
return;
}
if (pwd.new_password !== pwd.confirm) {
message.error("两次输入的新密码不一致");
return;
}
pwdLoading.value = true;
try {
await changePassword(pwd.old_password, pwd.new_password);
message.success("管理员密码已修改");
pwd.old_password = "";
pwd.new_password = "";
pwd.confirm = "";
} finally {
pwdLoading.value = false;
}
}
</script>
<template>
<div class="page">
<PageHeader title="系统" help="管理员密码在此修改;运行参数只读,修改请改配置文件后重启。" />
<div class="page-body">
<n-tabs type="line" animated>
<n-tab-pane name="password" tab="管理员密码">
<n-form label-placement="left" label-width="100" :show-feedback="false" style="max-width: 480px">
<n-form-item label="原密码">
<n-input v-model:value="pwd.old_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item>
<template #label>
新密码
<HelpTip>至少 12 位,且不能以 nst_ 开头(与端登录密码规则一致的安全要求)。</HelpTip>
</template>
<n-input v-model:value="pwd.new_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="确认新密码">
<n-input v-model:value="pwd.confirm" type="password" show-password-on="click" />
</n-form-item>
<n-space>
<n-button type="primary" :loading="pwdLoading" @click="submitPassword">修改密码</n-button>
</n-space>
</n-form>
</n-tab-pane>
<n-tab-pane name="settings" tab="运行参数(只读)">
<n-spin :show="loading">
<template v-if="settings">
<n-descriptions label-placement="left" :column="2" bordered size="small" style="margin-bottom: 16px">
<n-descriptions-item label="listen">{{ settings.listen }}</n-descriptions-item>
<n-descriptions-item label="admin_listen">{{ settings.admin_listen || "(空,与端共用)" }}</n-descriptions-item>
<n-descriptions-item label="会话闲置天数">{{ settings.session_idle_days }}</n-descriptions-item>
<n-descriptions-item label="记录保留天数">{{ settings.record_retention_days }}</n-descriptions-item>
<n-descriptions-item label="防重小时数">{{ settings.idempotency_hours }}</n-descriptions-item>
<n-descriptions-item label="回执保留天数">{{ settings.receipt_retention_days }}</n-descriptions-item>
<n-descriptions-item label="SQLite synchronous">{{ settings.sqlite_synchronous }}</n-descriptions-item>
</n-descriptions>
<n-text strong style="display: block; margin-bottom: 8px">limits</n-text>
<n-descriptions label-placement="left" :column="2" bordered size="small">
<n-descriptions-item v-for="(v, k) in settings.limits" :key="k" :label="String(k)">
{{ v }}
</n-descriptions-item>
</n-descriptions>
</template>
</n-spin>
</n-tab-pane>
</n-tabs>
</div>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.page-body {
flex: 1;
overflow: auto;
padding: 16px;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
import { config, mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it } from "vitest";
import {
NConfigProvider,
NDialogProvider,
NMessageProvider,
dateZhCN,
zhCN,
} from "naive-ui";
import { defineComponent, h, nextTick } from "vue";
import TokensView from "./TokensView.vue";
import { mockApi } from "@/api/mock";
config.global.stubs = { teleport: true };
function wrap(Comp: object) {
return defineComponent({
setup() {
return () =>
h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, {
default: () =>
h(NMessageProvider, null, {
default: () =>
h(NDialogProvider, null, {
default: () => h(Comp),
}),
}),
});
},
});
}
describe("TokensView 令牌只显示一次", () => {
beforeEach(() => {
setActivePinia(createPinia());
mockApi._setSession();
});
it("创建后显示令牌,关闭后列表不再含明文", async () => {
const pinia = createPinia();
const w = mount(wrap(TokensView), {
global: { plugins: [pinia] },
attachTo: document.body,
});
await flushPromises();
await w.find('[data-testid="token-create-open"]').trigger("click");
await nextTick();
await flushPromises();
await w.find('[data-testid="token-name"]').find("input").setValue("ci-test");
await w.find('[data-testid="token-create-submit"]').trigger("click");
await flushPromises();
expect(w.text()).toContain("令牌只显示一次");
expect(w.text()).toMatch(/nxm_/);
const shown = w.text().match(/nxm_[A-Za-z0-9]+/)?.[0];
expect(shown).toBeTruthy();
const dismiss = w.findAll("button").find((b) => b.text().includes("我已保存"));
await dismiss!.trigger("click");
await nextTick();
expect(w.text()).not.toContain("令牌只显示一次");
expect(w.text()).not.toContain(shown!);
expect(w.text()).toContain("ci-test");
w.unmount();
});
});
+186
View File
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { computed, h, onMounted, ref } from "vue";
import type { DataTableColumns } from "naive-ui";
import {
NButton,
NDataTable,
NForm,
NFormItem,
NInput,
NModal,
NSpace,
useDialog,
} from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
import { createToken, deleteToken, listTokens, patchToken, type ApiToken } from "@/api/admin";
import { formatLocalMs } from "@/utils/time";
import { message } from "@/utils/notify";
const dialog = useDialog();
const loading = ref(false);
const rows = ref<ApiToken[]>([]);
const createOpen = ref(false);
const createName = ref("");
const createLoading = ref(false);
const onceToken = ref<string | null>(null);
async function load() {
loading.value = true;
try {
const res = await listTokens();
rows.value = res.items;
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
});
const columns = computed<DataTableColumns<ApiToken>>(() => [
{ title: "ID", key: "id", width: 60 },
{ title: "名称", key: "name", width: 160 },
{
title: "启用",
key: "enabled",
width: 70,
render: (r) => (r.enabled ? "是" : "否"),
},
{
title: "创建时间",
key: "created_at_ms",
width: 160,
render: (r) => formatLocalMs(r.created_at_ms),
},
{
title: "最近使用",
key: "last_used_at_ms",
width: 160,
render: (r) => formatLocalMs(r.last_used_at_ms),
},
{
title: "操作",
key: "actions",
width: 200,
render: (r) =>
h(NSpace, { size: 4 }, () => [
h(
NButton,
{
size: "tiny",
onClick: () => void onToggle(r),
},
() => (r.enabled ? "停用" : "启用"),
),
h(
NButton,
{
size: "tiny",
type: "error",
secondary: true,
onClick: () => onDelete(r),
},
() => "删除",
),
]),
},
]);
async function submitCreate() {
if (!createName.value.trim()) {
message.error("名称不能为空");
return;
}
createLoading.value = true;
try {
const res = await createToken(createName.value.trim());
createOpen.value = false;
createName.value = "";
onceToken.value = res.token;
await load();
} finally {
createLoading.value = false;
}
}
async function onToggle(r: ApiToken) {
await patchToken(r.id, { enabled: !r.enabled });
message.success(r.enabled ? "已停用" : "已启用");
await load();
}
function onDelete(r: ApiToken) {
dialog.warning({
title: "删除令牌",
content: `确定删除「${r.name}」?删除后立即失效。`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
await deleteToken(r.id);
message.success("已删除");
await load();
},
});
}
</script>
<template>
<div class="page">
<PageHeader title="API 令牌" help="令牌明文只在创建时显示一次;列表不含明文。">
<template #actions>
<n-button type="primary" data-testid="token-create-open" @click="createOpen = true; createName = ''">
创建令牌
</n-button>
</template>
</PageHeader>
<div class="page-body">
<SecretOnceAlert
v-if="onceToken"
title="令牌只显示一次"
:secret="onceToken"
filename="api-token.txt"
data-testid="token-once"
@dismiss="onceToken = null"
/>
<n-data-table
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(r: ApiToken) => r.id"
:max-height="480"
/>
</div>
<n-modal v-model:show="createOpen" preset="card" title="创建 API 令牌" style="width: 420px">
<n-form label-placement="left" label-width="60" :show-feedback="false">
<n-form-item label="名称">
<n-input v-model:value="createName" data-testid="token-name" placeholder="例如 ops" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="createOpen = false">取消</n-button>
<n-button type="primary" :loading="createLoading" data-testid="token-create-submit" @click="submitCreate">
创建
</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;
}
</style>