diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index ea76df3..0387ea8 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1012,6 +1012,15 @@ - 备选方案:密码列也加公式前缀;未采用,避免改写密码。 - 影响:导入下载文件可被 Excel 正确打开。 +### 复审修复 W-05 + +- 日期:2026-09-30 +- 原条款:PRD F01/F17 解锁与批量清理;审查 #55;H-03/H-04/H-05 界面部分。 +- 实际做法:解锁始终可点,锁定列 HelpTip 说明只反映编号锁。筛选/翻页清空选择;批量确认列出前 20 个编号,删除说明级联,结果列出失败项。编辑只提交变更字段;启用改停用弹确认。导入中禁用按钮并显示预估秒数,校验错误用可滚动表格。默认延迟 `:max` 取自 `limits.max_schedule_seconds`。 +- 原因:仅 IP 锁定时无法解锁;跨页多选会误删;PATCH 总带 enabled 可能误停用。 +- 备选方案:扩展 LoginLocks 查询任一锁定;留给 P 线。 +- 影响:管理员可解除 IP 锁定;停用需二次确认。 + ### S1.1 传输层可注入假实现(Go / JS) - 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。 diff --git a/web/src/views/EndpointsView.spec.ts b/web/src/views/EndpointsView.spec.ts index 6cd6530..9952aec 100644 --- a/web/src/views/EndpointsView.spec.ts +++ b/web/src/views/EndpointsView.spec.ts @@ -11,6 +11,7 @@ import { import { defineComponent, h, nextTick } from "vue"; import EndpointsView from "./EndpointsView.vue"; import { mockApi } from "@/api/mock"; +import * as admin from "@/api/admin"; vi.mock("@/api/admin", async () => import("@/api/admin-mock")); @@ -94,3 +95,82 @@ describe("EndpointsView 开通端", () => { w.unmount(); }); }); + +describe("EndpointsView 列表与编辑", () => { + beforeEach(() => { + setActivePinia(createPinia()); + mockApi._setSession(); + vi.restoreAllMocks(); + }); + + it("login_locked=false 时解锁按钮可点", async () => { + const w = mount(wrap(EndpointsView), { + global: { plugins: [createPinia()] }, + attachTo: document.body, + }); + await flushPromises(); + const unlocks = w.findAll('[data-testid="endpoint-unlock"]'); + expect(unlocks.length).toBeGreaterThan(0); + for (const b of unlocks) { + expect(b.attributes("disabled")).toBeUndefined(); + } + w.unmount(); + }); + + it("只改名称时请求体没有 enabled", async () => { + const spy = vi.spyOn(admin, "patchEndpoint"); + const w = mount(wrap(EndpointsView), { + global: { plugins: [createPinia()] }, + attachTo: document.body, + }); + await flushPromises(); + + const editBtn = w.findAll("button").find((b) => b.text().trim() === "编辑"); + await editBtn!.trigger("click"); + await nextTick(); + await flushPromises(); + + await w.find('[data-testid="edit-name"]').find("input").setValue("改名后"); + await w.find('[data-testid="edit-submit"]').trigger("click"); + await flushPromises(); + + expect(spy).toHaveBeenCalled(); + const body = spy.mock.calls[0][1]; + expect(body).toEqual({ name: "改名后" }); + expect(body).not.toHaveProperty("enabled"); + w.unmount(); + }); + + it("默认延迟超上限时前端阻止提交", async () => { + vi.spyOn(admin, "getSettings").mockResolvedValue({ + listen: ":7443", + admin_listen: "", + session_idle_days: 30, + record_retention_days: 7, + idempotency_hours: 24, + receipt_retention_days: 7, + sqlite_synchronous: "FULL", + limits: { max_schedule_seconds: 10 }, + }); + const createSpy = vi.spyOn(admin, "createEndpoint"); + const w = mount(wrap(EndpointsView), { + global: { plugins: [createPinia()] }, + attachTo: document.body, + }); + await flushPromises(); + + const openBtn = w.findAll("button").find((b) => b.text().trim() === "开通"); + await openBtn!.trigger("click"); + await nextTick(); + await flushPromises(); + + expect(w.find('[data-testid="create-delay-max"]').text()).toContain("上限 10 秒"); + const delay = w.find('[data-testid="create-delay"]').find("input"); + await delay.setValue("11"); + await w.find('[data-testid="create-submit"]').trigger("click"); + await flushPromises(); + + expect(createSpy.mock.calls.every((c) => ((c[0] as { default_delay_seconds?: number }).default_delay_seconds ?? 0) <= 10)).toBe(true); + w.unmount(); + }); +}); diff --git a/web/src/views/EndpointsView.vue b/web/src/views/EndpointsView.vue index 1454532..84cc2c7 100644 --- a/web/src/views/EndpointsView.vue +++ b/web/src/views/EndpointsView.vue @@ -26,6 +26,7 @@ import LoadFailed from "@/components/LoadFailed.vue"; import { batchEndpoints, createEndpoint, + getSettings, importEndpoints, kickEndpoint, listEndpoints, @@ -34,6 +35,7 @@ import { setTalkPassword, unlockEndpoint, type Endpoint, + type EndpointPatchRequest, type ImportItem, } from "@/api/admin"; import { ApiError } from "@/api/http"; @@ -70,6 +72,10 @@ const onlineOptions = [ const onceSecret = ref<{ title: string; secret: string; filename: string; mime?: string } | null>(null); const importResult = ref(null); +const importing = ref(false); +const importEta = ref(1); +const importErrors = ref<{ line: number; reason: string }[] | null>(null); +const maxDelaySeconds = ref(31536000); const createOpen = ref(false); const createForm = reactive({ @@ -92,6 +98,12 @@ const editForm = reactive({ enabled: true, }); const editLoading = ref(false); +const editOrig = reactive({ + name: "", + remark: "", + default_delay_seconds: 0, + enabled: true, +}); const talkOpen = ref(false); const talkId = ref(""); @@ -120,8 +132,21 @@ async function load() { onMounted(() => { void load(); + void loadLimits(); }); +async function loadLimits() { + try { + const s = await getSettings(); + const n = s.limits?.max_schedule_seconds; + if (typeof n === "number" && n > 0) { + maxDelaySeconds.value = n; + } + } catch { + /* 沿用默认 365 天 */ + } +} + function sourceLabel(s: string) { return s === "self" ? "自助注册" : "后台开通"; } @@ -149,9 +174,16 @@ const columns = computed>(() => [ render: (r) => (r.enabled ? "是" : "否"), }, { - title: "锁定", + title() { + return h("span", [ + "锁定", + h(HelpTip, null, { + default: () => "列表只显示按编号的总数锁定,解锁会同时清除按 IP 的锁定", + }), + ]); + }, key: "login_locked", - width: 70, + width: 90, render: (r) => (r.login_locked ? "是" : "否"), }, { @@ -178,7 +210,11 @@ const columns = computed>(() => [ h(NButton, { size: "tiny", onClick: () => openTalk(r) }, () => "对话密码"), h( NButton, - { size: "tiny", disabled: !r.login_locked, onClick: () => void onUnlock(r) }, + { + size: "tiny", + "data-testid": "endpoint-unlock", + onClick: () => void onUnlock(r), + }, () => "解锁", ), h(NButton, { size: "tiny", onClick: () => void onKick(r) }, () => "踢下线"), @@ -201,6 +237,10 @@ function openCreate() { async function submitCreate() { createError.value = ""; + if ((createForm.default_delay_seconds ?? 0) > maxDelaySeconds.value) { + createError.value = `默认延迟不能超过 ${maxDelaySeconds.value} 秒`; + return; + } createLoading.value = true; try { const res = await createEndpoint({ @@ -237,24 +277,51 @@ function openEdit(r: Endpoint) { default_delay_seconds: Math.round(r.default_delay_ms / 1000), enabled: r.enabled, }); + Object.assign(editOrig, editForm); 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 buildEditPatch(): EndpointPatchRequest { + const body: EndpointPatchRequest = {}; + if (editForm.name !== editOrig.name) body.name = editForm.name; + if (editForm.remark !== editOrig.remark) body.remark = editForm.remark; + if (editForm.default_delay_seconds !== editOrig.default_delay_seconds) { + body.default_delay_seconds = editForm.default_delay_seconds; } + if (editForm.enabled !== editOrig.enabled) body.enabled = editForm.enabled; + return body; +} + +async function submitEdit() { + if ((editForm.default_delay_seconds ?? 0) > maxDelaySeconds.value) { + message.error(`默认延迟不能超过 ${maxDelaySeconds.value} 秒`); + return; + } + const body = buildEditPatch(); + const doSave = async () => { + editLoading.value = true; + try { + if (Object.keys(body).length) { + await patchEndpoint(editId.value, body); + } + editOpen.value = false; + message.success("已保存"); + await load(); + } finally { + editLoading.value = false; + } + }; + if (editOrig.enabled && !editForm.enabled) { + dialog.warning({ + title: "停用端", + content: "停用会立即断开并作废未送达消息,不可恢复", + positiveText: "停用", + negativeText: "取消", + onPositiveClick: () => doSave(), + }); + return; + } + await doSave(); } function openTalk(r: Endpoint) { @@ -318,14 +385,28 @@ async function onBatch(action: "disable" | "delete") { message.warning("请先多选"); return; } + const preview = ids.slice(0, 20).join("、"); + const countPart = ids.length > 20 ? `…共 ${ids.length} 个` : `(${ids.length} 个)`; + let content = `编号:${preview}${countPart}`; + if (action === "delete") { + content += "。删除将退群、必要时转让或解散群主,并清理相关记录,不可恢复。"; + } else { + content += "。停用会立即断开并作废未送达消息,不可恢复。"; + } dialog.warning({ title: action === "disable" ? "批量停用" : "批量删除", - content: `对 ${ids.length} 个端执行${action === "disable" ? "停用" : "删除"}?`, + content, positiveText: "确定", negativeText: "取消", onPositiveClick: async () => { const res = await batchEndpoints(ids, action); - message.success(`成功 ${res.ok_ids.length},失败 ${res.failed.length}`); + if (res.failed.length) { + message.warning( + `成功 ${res.ok_ids.length}。失败:${res.failed.map((f) => `${f.id}(${f.code})`).join("、")}`, + ); + } else { + message.success(`成功 ${res.ok_ids.length}`); + } checkedKeys.value = []; await load(); }, @@ -338,8 +419,13 @@ async function onImport(options: UploadCustomRequestOptions) { options.onError(); return; } + importing.value = true; + importErrors.value = null; try { const text = await raw.text(); + const lineCount = Math.max(0, text.replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim()).length - 1); + const workers = Math.max(1, (navigator.hardwareConcurrency || 4) - 1); + importEta.value = Math.max(1, Math.ceil((lineCount * 0.3) / workers)); const res = await importEndpoints(text); importResult.value = res.items; const pwdRows = res.items.filter((i): i is ImportItem & { login_password: string } => Boolean(i.login_password)); @@ -356,11 +442,12 @@ async function onImport(options: UploadCustomRequestOptions) { } 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); + importErrors.value = (e.data as { errors: { line: number; reason: string }[] }).errors; } else if (e instanceof Error) { message.error(e.message); } + } finally { + importing.value = false; } } @@ -374,12 +461,20 @@ function downloadImportCsv() { downloadText("import-passwords.csv", buildPasswordCsv(pwdRows), "text/csv;charset=utf-8"); } +function reloadFirst() { + checkedKeys.value = []; + page.value = 1; + void load(); +} + function onPageChange(p: number) { + checkedKeys.value = []; page.value = p; void load(); } function onPageSizeChange(s: number) { + checkedKeys.value = []; pageSize.value = s; page.value = 1; void load(); @@ -391,8 +486,8 @@ function onPageSizeChange(s: number) { + + + +