fix: 补齐投递记录时间筛选统计与分页
This commit is contained in:
@@ -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,不要起真实服务器」。
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -331,6 +331,8 @@ export function listMessages(q: {
|
||||
endpoint_id?: string;
|
||||
group_id?: string;
|
||||
state?: string;
|
||||
from_ms?: number;
|
||||
to_ms?: number;
|
||||
}): Promise<PageResult<MessageSummary>> {
|
||||
return run(() =>
|
||||
requestAdmin<PageResult<MessageSummary>>(
|
||||
@@ -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<MessageDetail> {
|
||||
return run(() => requestAdmin<MessageDetail>(`/api/admin/messages/${seq}`));
|
||||
export function getMessage(seq: number, cursor?: string, limit?: number): Promise<MessageDetail> {
|
||||
return run(() =>
|
||||
requestAdmin<MessageDetail>(`/api/admin/messages/${seq}${buildQuery({ cursor, limit })}`),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSettings(): Promise<RuntimeSettings> {
|
||||
|
||||
+19
-2
@@ -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<PageResult<MessageSummary>> {
|
||||
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<MessageDetail> {
|
||||
async getMessage(seq: number, cursor?: string, limit?: number): Promise<MessageDetail> {
|
||||
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<RuntimeSettings> {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<MessageDetail | null>(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<DataTableColumns<MessageSummary>>(() => [
|
||||
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<DataTableColumns<MessageSummary>>(() => [
|
||||
const deliveryColumns = computed<DataTableColumns<MessageDelivery>>(() => [
|
||||
{ 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<DataTableColumns<MessageDelivery>>(() => [
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -140,6 +173,12 @@ async function openDetail(seq: number) {
|
||||
<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-date-picker
|
||||
v-model:value="timeRange"
|
||||
type="datetimerange"
|
||||
clearable
|
||||
data-testid="msg-time-range"
|
||||
/>
|
||||
<n-button
|
||||
@click="
|
||||
page = 1;
|
||||
@@ -182,11 +221,21 @@ async function openDetail(seq: number) {
|
||||
<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.reason || "—" }}</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-data-table :columns="deliveryColumns" :data="detail.deliveries" size="small" :row-key="(r: MessageDelivery) => r.endpoint_id" :max-height="280" />
|
||||
<n-button
|
||||
v-if="detail.next_cursor"
|
||||
:loading="deliveryLoading"
|
||||
data-testid="delivery-more"
|
||||
style="margin-top: 8px"
|
||||
@click="loadMoreDeliveries"
|
||||
>
|
||||
加载更多
|
||||
</n-button>
|
||||
</n-scrollbar>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
|
||||
Reference in New Issue
Block a user