From 05ecb43766dc4d808c0f91e4f91bf525a571b26c Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 15:18:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E6=8A=95=E9=80=92?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=97=B6=E9=97=B4=E7=AD=9B=E9=80=89=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E4=B8=8E=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEVIATIONS.md | 9 ++++ web/src/api/admin-mock.ts | 6 ++- web/src/api/admin.ts | 10 +++- web/src/api/mock.ts | 21 +++++++- web/src/views/MessagesView.spec.ts | 79 ++++++++++++++++++++++++++++++ web/src/views/MessagesView.vue | 57 +++++++++++++++++++-- 6 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 web/src/views/MessagesView.spec.ts diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 0387ea8..1749379 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1021,6 +1021,15 @@ - 备选方案:扩展 LoginLocks 查询任一锁定;留给 P 线。 - 影响:管理员可解除 IP 锁定;停用需二次确认。 +### 复审修复 W-02 + +- 日期:2026-09-30 +- 原条款:PRD F17 按时间筛选并查看原因/完整统计;审查 #52。 +- 实际做法:投递记录加 `n-date-picker datetimerange` 传 `from_ms`/`to_ms`;列表与详情增加原因;统计六项;详情接收端 `limit=200` 并以「加载更多」翻页。 +- 原因:接口已提供字段,页面未接线。 +- 备选方案:详情用页码分页;未采用,加载更多更贴合游标。 +- 影响:大群投递可看完全部接收端。 + ### S1.1 传输层可注入假实现(Go / JS) - 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。 diff --git a/web/src/api/admin-mock.ts b/web/src/api/admin-mock.ts index 378006d..ac6c9d6 100644 --- a/web/src/api/admin-mock.ts +++ b/web/src/api/admin-mock.ts @@ -161,12 +161,14 @@ export function listMessages(q: { endpoint_id?: string; group_id?: string; state?: string; + from_ms?: number; + to_ms?: number; }) { return run(() => mockApi.listMessages(q)); } -export function getMessage(seq: number) { - return run(() => mockApi.getMessage(seq)); +export function getMessage(seq: number, cursor?: string, limit?: number) { + return run(() => mockApi.getMessage(seq, cursor, limit)); } export function getSettings() { diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index 99b1e20..444114e 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -331,6 +331,8 @@ export function listMessages(q: { endpoint_id?: string; group_id?: string; state?: string; + from_ms?: number; + to_ms?: number; }): Promise> { return run(() => requestAdmin>( @@ -341,13 +343,17 @@ export function listMessages(q: { endpoint_id: q.endpoint_id, group_id: q.group_id, state: q.state, + from_ms: q.from_ms, + to_ms: q.to_ms, })}`, ), ); } -export function getMessage(seq: number): Promise { - return run(() => requestAdmin(`/api/admin/messages/${seq}`)); +export function getMessage(seq: number, cursor?: string, limit?: number): Promise { + return run(() => + requestAdmin(`/api/admin/messages/${seq}${buildQuery({ cursor, limit })}`), + ); } export function getSettings(): Promise { diff --git a/web/src/api/mock.ts b/web/src/api/mock.ts index e3658a3..ef092bf 100644 --- a/web/src/api/mock.ts +++ b/web/src/api/mock.ts @@ -151,6 +151,14 @@ const messages: MessageDetail[] = [ pushed_at_ms: now() - 3_599_000, updated_at_ms: now() - 3_598_000, }, + { + endpoint_id: "ops-bot", + state: "rejected", + reason: "disabled", + attempts: 1, + pushed_at_ms: now() - 3_599_000, + updated_at_ms: now() - 3_598_000, + }, ], next_cursor: "", }, @@ -730,6 +738,8 @@ export const mockApi = { endpoint_id?: string; group_id?: string; state?: string; + from_ms?: number; + to_ms?: number; }): Promise> { requireSession(); let list = messages.map(toMessageSummary); @@ -742,16 +752,23 @@ export const mockApi = { }); } if (q.state) list = list.filter((m) => m.state === q.state); + if (q.from_ms != null) list = list.filter((m) => m.created_at_ms >= q.from_ms!); + if (q.to_ms != null) list = list.filter((m) => m.created_at_ms <= q.to_ms!); return paginate(list, q.cursor, q.limit); }, - async getMessage(seq: number): Promise { + async getMessage(seq: number, cursor?: string, limit?: number): Promise { requireSession(); const m = messages.find((x) => x.seq === seq); if (!m) { throw new ApiError("not_found", "消息不存在", 404); } - return clone(m); + const page = paginate(m.deliveries, cursor, limit ?? 200); + return { + ...clone(m), + deliveries: page.items, + next_cursor: page.next_cursor, + }; }, async getSettings(): Promise { diff --git a/web/src/views/MessagesView.spec.ts b/web/src/views/MessagesView.spec.ts new file mode 100644 index 0000000..4d8e1cc --- /dev/null +++ b/web/src/views/MessagesView.spec.ts @@ -0,0 +1,79 @@ +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, nextTick } from "vue"; +import MessagesView from "./MessagesView.vue"; +import { mockApi } from "@/api/mock"; +import * as admin from "@/api/admin"; + +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("MessagesView", () => { + beforeEach(() => { + setActivePinia(createPinia()); + mockApi._setSession(); + vi.restoreAllMocks(); + }); + + it("查询时传递时间范围,统计含拒绝,详情有原因列并可翻页", async () => { + const listSpy = vi.spyOn(admin, "listMessages"); + const getSpy = vi.spyOn(admin, "getMessage").mockImplementation((seq, cursor) => mockApi.getMessage(seq, cursor, 1)); + const w = mount(wrap(MessagesView), { + global: { plugins: [createPinia()] }, + attachTo: document.body, + }); + await flushPromises(); + + expect(w.text()).toMatch(/已拒绝/); + const picker = w.findComponent({ name: "DatePicker" }); + expect(picker.exists()).toBe(true); + picker.vm.$emit("update:value", [1_700_000_000_000, 1_800_000_000_000]); + await nextTick(); + const queryBtn = w.findAll("button").find((b) => b.text().trim() === "查询"); + await queryBtn!.trigger("click"); + await flushPromises(); + + const last = listSpy.mock.calls.at(-1)?.[0] as { from_ms?: number; to_ms?: number }; + expect(last.from_ms).toBe(1_700_000_000_000); + expect(last.to_ms).toBe(1_800_000_000_000); + + const detailBtn = w.findAll("button").find((b) => b.text().trim() === "详情"); + await detailBtn!.trigger("click"); + await flushPromises(); + expect(w.text()).toContain("原因"); + expect(w.find('[data-testid="delivery-more"]').exists()).toBe(true); + + getSpy.mockClear(); + await w.find('[data-testid="delivery-more"]').trigger("click"); + await flushPromises(); + expect(getSpy).toHaveBeenCalled(); + expect(getSpy.mock.calls[0][1]).toBeTruthy(); + w.unmount(); + }); +}); diff --git a/web/src/views/MessagesView.vue b/web/src/views/MessagesView.vue index 91f0a22..d36923d 100644 --- a/web/src/views/MessagesView.vue +++ b/web/src/views/MessagesView.vue @@ -4,6 +4,7 @@ import type { DataTableColumns } from "naive-ui"; import { NButton, NDataTable, + NDatePicker, NDescriptions, NDescriptionsItem, NInput, @@ -30,9 +31,12 @@ const filters = reactive({ group_id: "", state: "" as "" | "scheduled" | "dispatched" | "completed", }); +const timeRange = ref<[number, number] | null>(null); const detailOpen = ref(false); const detail = ref(null); +const detailSeq = ref(0); +const deliveryLoading = ref(false); const stateOptions = [ { label: "全部状态", value: "" }, @@ -53,6 +57,8 @@ async function load() { endpoint_id: filters.endpoint_id.trim() || undefined, group_id: filters.group_id.trim() || undefined, state: filters.state || undefined, + from_ms: timeRange.value?.[0], + to_ms: timeRange.value?.[1], }); rows.value = res.items; total.value = res.total; @@ -78,13 +84,20 @@ const columns = computed>(() => [ render: (r) => `${r.dest_kind}:${r.dest_id}`, }, { title: "状态", key: "state", width: 100 }, + { + title: "原因", + key: "reason", + width: 140, + ellipsis: { tooltip: true }, + render: (r) => r.reason || "—", + }, { title: "投递统计", key: "delivery_counts", - width: 200, + width: 280, render: (r) => { const c = r.delivery_counts; - return `待${c.pending}/收${c.accepted}/撤${c.recalled}/过${c.expired}`; + return `待投递${c.pending}/已收下${c.accepted}/已撤回${c.recalled}/已过期${c.expired}/已丢弃${c.dropped}/已拒绝${c.rejected}`; }, }, { @@ -110,6 +123,11 @@ const columns = computed>(() => [ const deliveryColumns = computed>(() => [ { title: "接收端", key: "endpoint_id" }, { title: "状态", key: "state" }, + { + title: "原因", + key: "reason", + render: (r) => r.reason || "—", + }, { title: "次数", key: "attempts", width: 60 }, { title: "推送时间", @@ -124,9 +142,24 @@ const deliveryColumns = computed>(() => [ ]); async function openDetail(seq: number) { - detail.value = await getMessage(seq); + detailSeq.value = seq; + detail.value = await getMessage(seq, "", 200); detailOpen.value = true; } + +async function loadMoreDeliveries() { + if (!detail.value?.next_cursor) return; + deliveryLoading.value = true; + try { + const more = await getMessage(detailSeq.value, detail.value.next_cursor, 200); + detail.value = { + ...more, + deliveries: [...detail.value.deliveries, ...more.deliveries], + }; + } finally { + deliveryLoading.value = false; + } +}