fix: 统一管理后台 401 与加载失败处理

This commit is contained in:
Nixevol
2026-09-30 16:22:48 +08:00
parent 1cb562fb71
commit ed788fbb74
17 changed files with 473 additions and 47 deletions
+35 -11
View File
@@ -2,6 +2,7 @@
import { computed, h, onMounted, reactive, ref } from "vue";
import type { DataTableColumns, DataTableRowKey } from "naive-ui";
import {
NAlert,
NButton,
NDataTable,
NForm,
@@ -21,6 +22,7 @@ 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 LoadFailed from "@/components/LoadFailed.vue";
import {
batchEndpoints,
createEndpoint,
@@ -41,6 +43,7 @@ import { message } from "@/utils/notify";
const dialog = useDialog();
const loading = ref(false);
const loadError = ref("");
const rows = ref<Endpoint[]>([]);
const total = ref(0);
const checkedKeys = ref<DataTableRowKey[]>([]);
@@ -77,6 +80,7 @@ const createForm = reactive({
default_delay_seconds: 0,
});
const createLoading = ref(false);
const createError = ref("");
const editOpen = ref(false);
const editId = ref("");
@@ -94,6 +98,7 @@ const talkPassword = ref("");
async function load() {
loading.value = true;
loadError.value = "";
try {
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
const res = await listEndpoints({
@@ -105,6 +110,8 @@ async function load() {
});
rows.value = res.items;
total.value = res.total;
} catch (e) {
loadError.value = e instanceof Error ? e.message : "加载失败";
} finally {
loading.value = false;
}
@@ -187,14 +194,12 @@ function openCreate() {
talk_password: "",
default_delay_seconds: 0,
});
createError.value = "";
createOpen.value = true;
}
async function submitCreate() {
if (!createForm.name.trim()) {
message.error("名称不能为空");
return;
}
createError.value = "";
createLoading.value = true;
try {
const res = await createEndpoint({
@@ -212,6 +217,8 @@ async function submitCreate() {
filename: `${res.id}-password.txt`,
};
await load();
} catch (e) {
createError.value = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "开通失败";
} finally {
createLoading.value = false;
}
@@ -252,10 +259,23 @@ function openTalk(r: Endpoint) {
}
async function submitTalk() {
await setTalkPassword(talkId.value, talkPassword.value);
talkOpen.value = false;
message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码");
await load();
const save = async () => {
await setTalkPassword(talkId.value, talkPassword.value);
talkOpen.value = false;
message.success(talkPassword.value ? "已设置对话密码" : "已清除对话密码");
await load();
};
if (!talkPassword.value) {
dialog.warning({
title: "清除对话密码",
content: "留空保存将清除该端的对话密码,确定继续?",
positiveText: "清除",
negativeText: "取消",
onPositiveClick: () => save(),
});
return;
}
await save();
}
async function onResetPwd(r: Endpoint) {
@@ -360,6 +380,8 @@ function onPageSizeChange(s: number) {
</template>
</PageHeader>
<div class="page-body">
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
<template v-else>
<SecretOnceAlert
v-if="onceSecret"
:title="onceSecret.title"
@@ -403,16 +425,18 @@ function onPageSizeChange(s: number) {
}"
@update:checked-row-keys="(k) => (checkedKeys = k)"
/>
</template>
</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-alert v-if="createError" type="error" :title="createError" style="margin-bottom: 8px" />
<n-form label-placement="left" label-width="110">
<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-input v-model:value="createForm.name" data-testid="create-name" placeholder="可选" />
</n-form-item>
<n-form-item label="登录密码">
<n-input v-model:value="createForm.login_password" placeholder="留空则生成" type="password" show-password-on="click" />
@@ -464,7 +488,7 @@ function onPageSizeChange(s: number) {
<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-input v-model:value="talkPassword" type="password" show-password-on="click" placeholder="4–64 字符,留空表示清除" />
</n-form-item>
</n-form>
<template #footer>
+8
View File
@@ -13,6 +13,7 @@ import {
useDialog,
} from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import LoadFailed from "@/components/LoadFailed.vue";
import {
addGroupMembers,
createGroup,
@@ -31,6 +32,7 @@ import { message } from "@/utils/notify";
const dialog = useDialog();
const loading = ref(false);
const loadError = ref("");
const rows = ref<GroupSummary[]>([]);
const total = ref(0);
const page = ref(1);
@@ -48,11 +50,14 @@ const addMembers = ref("");
async function load() {
loading.value = true;
loadError.value = "";
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;
} catch (e) {
loadError.value = e instanceof Error ? e.message : "加载失败";
} finally {
loading.value = false;
}
@@ -241,6 +246,8 @@ async function onTransfer(endpointId: string) {
</template>
</PageHeader>
<div class="page-body">
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
<template v-else>
<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>
@@ -262,6 +269,7 @@ async function onTransfer(endpointId: string) {
},
}"
/>
</template>
</div>
<n-modal v-model:show="createOpen" preset="card" title="新建群" style="width: 480px">
+8
View File
@@ -14,10 +14,12 @@ import {
} from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import HelpTip from "@/components/HelpTip.vue";
import LoadFailed from "@/components/LoadFailed.vue";
import { getMessage, listMessages, type MessageDelivery, type MessageDetail, type MessageSummary } from "@/api/admin";
import { formatLocalMs } from "@/utils/time";
const loading = ref(false);
const loadError = ref("");
const rows = ref<MessageSummary[]>([]);
const total = ref(0);
const page = ref(1);
@@ -41,6 +43,7 @@ const stateOptions = [
async function load() {
loading.value = true;
loadError.value = "";
try {
const cursor = page.value > 1 ? String((page.value - 1) * pageSize.value) : "";
const res = await listMessages({
@@ -53,6 +56,8 @@ async function load() {
});
rows.value = res.items;
total.value = res.total;
} catch (e) {
loadError.value = e instanceof Error ? e.message : "加载失败";
} finally {
loading.value = false;
}
@@ -128,6 +133,8 @@ async function openDetail(seq: number) {
<div class="page">
<PageHeader title="投递记录" help="只显示投递痕迹,不显示正文;接口响应也不含 body 字段。" />
<div class="page-body">
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
<template v-else>
<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" />
@@ -164,6 +171,7 @@ async function openDetail(seq: number) {
},
}"
/>
</template>
</div>
<n-modal v-model:show="detailOpen" preset="card" title="投递详情" style="width: 720px">
+74
View File
@@ -0,0 +1,74 @@
import { config, mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
NConfigProvider,
NDialogProvider,
NMessageProvider,
dateZhCN,
zhCN,
} from "naive-ui";
import { defineComponent, h } from "vue";
import OverviewView from "./OverviewView.vue";
import * as admin from "@/api/admin";
import { mockApi } from "@/api/mock";
vi.mock("@/api/admin", async () => import("@/api/admin-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("OverviewView 加载失败", () => {
beforeEach(() => {
setActivePinia(createPinia());
mockApi._setSession();
vi.restoreAllMocks();
});
it("失败时显示重试,点后重新加载", async () => {
const spy = vi.spyOn(admin, "fetchOverview");
spy.mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce({
version: "0.1.0",
endpoints_total: 3,
endpoints_online: 1,
endpoints_disabled: 1,
endpoints_self: 1,
groups_total: 1,
messages_pending: 0,
messages_scheduled: 0,
uptime_ms: 1000,
});
const w = mount(wrap(OverviewView), {
global: { plugins: [createPinia()] },
attachTo: document.body,
});
await flushPromises();
expect(w.text()).toContain("加载失败");
expect(w.find('[data-testid="load-retry"]').exists()).toBe(true);
await w.find('[data-testid="load-retry"]').trigger("click");
await flushPromises();
expect(w.text()).toContain("0.1.0");
expect(w.text()).not.toContain("加载失败");
w.unmount();
});
});
+13 -2
View File
@@ -3,18 +3,28 @@ 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 LoadFailed from "@/components/LoadFailed.vue";
import { fetchOverview, type Overview } from "@/api/admin";
import { formatLocalMs } from "@/utils/time";
const loading = ref(true);
const loadError = ref("");
const data = ref<Overview | null>(null);
onMounted(async () => {
async function load() {
loading.value = true;
loadError.value = "";
try {
data.value = await fetchOverview();
} catch (e) {
loadError.value = e instanceof Error ? e.message : "加载失败";
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
});
</script>
@@ -23,7 +33,8 @@ onMounted(async () => {
<PageHeader title="概览" help="汇总当前实例的端、群与未完成投递数量,不含正文与编号明细。" />
<div class="page-body">
<n-spin :show="loading">
<n-descriptions v-if="data" label-placement="left" :column="2" bordered size="small">
<LoadFailed v-if="loadError && !data" :description="loadError" @retry="load" />
<n-descriptions v-else-if="data" label-placement="left" :column="2" bordered size="small">
<n-descriptions-item label="版本">{{ data.version }}</n-descriptions-item>
<n-descriptions-item>
<template #label>
+60
View File
@@ -0,0 +1,60 @@
import { config, mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
NConfigProvider,
NDialogProvider,
NMessageProvider,
dateZhCN,
zhCN,
} from "naive-ui";
import { defineComponent, h } from "vue";
import SettingsView from "./SettingsView.vue";
import { mockApi } from "@/api/mock";
vi.mock("@/api/admin", async () => import("@/api/admin-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("SettingsView 改密表单", () => {
beforeEach(() => {
setActivePinia(createPinia());
mockApi._setSession();
});
it("新密码不足 12 个字符时就地显示错误", async () => {
const w = mount(wrap(SettingsView), {
global: { plugins: [createPinia()] },
attachTo: document.body,
});
await flushPromises();
const inputs = w.findAll("input");
expect(inputs.length).toBeGreaterThanOrEqual(3);
await inputs[0].setValue("old-password");
await inputs[1].setValue("short");
await inputs[2].setValue("short");
await w.findAll("button").find((b) => b.text().includes("修改密码"))!.trigger("click");
await flushPromises();
expect(w.text()).toContain("新密码至少 12 个字符");
w.unmount();
});
});
+62 -13
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import type { FormInst, FormRules } from "naive-ui";
import {
NAlert,
NButton,
NDescriptions,
NDescriptionsItem,
@@ -15,29 +17,65 @@ import {
} from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import HelpTip from "@/components/HelpTip.vue";
import LoadFailed from "@/components/LoadFailed.vue";
import { changePassword, getSettings, type RuntimeSettings } from "@/api/admin";
import { ApiError } from "@/api/http";
import { message } from "@/utils/notify";
const loading = ref(true);
const loadError = ref("");
const settings = ref<RuntimeSettings | null>(null);
const pwd = reactive({ old_password: "", new_password: "", confirm: "" });
const pwdLoading = ref(false);
const pwdError = ref("");
const formRef = ref<FormInst | null>(null);
onMounted(async () => {
const rules: FormRules = {
new_password: [
{
validator(_, value: string) {
if (Array.from(value || "").length < 12) {
return new Error("新密码至少 12 个字符");
}
return true;
},
trigger: ["blur", "input"],
},
],
confirm: [
{
validator() {
if (pwd.new_password !== pwd.confirm) {
return new Error("两次输入的新密码不一致");
}
return true;
},
trigger: ["blur", "input"],
},
],
};
async function load() {
loading.value = true;
loadError.value = "";
try {
settings.value = await getSettings();
} catch (e) {
loadError.value = e instanceof Error ? e.message : "加载失败";
} finally {
loading.value = false;
}
}
onMounted(() => {
void load();
});
async function submitPassword() {
if (pwd.new_password.length < 12) {
message.error("新密码至少 12 位");
return;
}
if (pwd.new_password !== pwd.confirm) {
message.error("两次输入的新密码不一致");
pwdError.value = "";
try {
await formRef.value?.validate();
} catch {
return;
}
pwdLoading.value = true;
@@ -47,6 +85,8 @@ async function submitPassword() {
pwd.old_password = "";
pwd.new_password = "";
pwd.confirm = "";
} catch (e) {
pwdError.value = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "修改失败";
} finally {
pwdLoading.value = false;
}
@@ -59,18 +99,26 @@ async function submitPassword() {
<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-alert v-if="pwdError" type="error" style="margin-bottom: 12px; max-width: 480px" :title="pwdError" />
<n-form
ref="formRef"
:model="pwd"
:rules="rules"
label-placement="left"
label-width="100"
style="max-width: 480px"
>
<n-form-item label="原密码" path="old_password">
<n-input v-model:value="pwd.old_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item>
<n-form-item path="new_password">
<template #label>
新密码
<HelpTip>至少 12 位,且不能以 nst_ 开头(与端登录密码规则一致的安全要求)。</HelpTip>
<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-form-item label="确认新密码" path="confirm">
<n-input v-model:value="pwd.confirm" type="password" show-password-on="click" />
</n-form-item>
<n-space>
@@ -80,7 +128,8 @@ async function submitPassword() {
</n-tab-pane>
<n-tab-pane name="settings" tab="运行参数(只读)">
<n-spin :show="loading">
<template v-if="settings">
<LoadFailed v-if="loadError && !settings" :description="loadError" @retry="load" />
<template v-else-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>
+8
View File
@@ -12,6 +12,7 @@ import {
useDialog,
} from "naive-ui";
import PageHeader from "@/components/PageHeader.vue";
import LoadFailed from "@/components/LoadFailed.vue";
import SecretOnceAlert from "@/components/SecretOnceAlert.vue";
import { createToken, deleteToken, listTokens, patchToken, type ApiToken } from "@/api/admin";
import { formatLocalMs } from "@/utils/time";
@@ -19,6 +20,7 @@ import { message } from "@/utils/notify";
const dialog = useDialog();
const loading = ref(false);
const loadError = ref("");
const rows = ref<ApiToken[]>([]);
const createOpen = ref(false);
const createName = ref("");
@@ -27,9 +29,12 @@ const onceToken = ref<string | null>(null);
async function load() {
loading.value = true;
loadError.value = "";
try {
const res = await listTokens();
rows.value = res.items;
} catch (e) {
loadError.value = e instanceof Error ? e.message : "加载失败";
} finally {
loading.value = false;
}
@@ -136,6 +141,8 @@ function onDelete(r: ApiToken) {
</template>
</PageHeader>
<div class="page-body">
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
<template v-else>
<SecretOnceAlert
v-if="onceToken"
title="令牌只显示一次"
@@ -151,6 +158,7 @@ function onDelete(r: ApiToken) {
:row-key="(r: ApiToken) => r.id"
:max-height="480"
/>
</template>
</div>
<n-modal v-model:show="createOpen" preset="card" title="创建 API 令牌" style="width: 420px">