diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 1749379..c4f13fc 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1030,6 +1030,15 @@ - 备选方案:详情用页码分页;未采用,加载更多更贴合游标。 - 影响:大群投递可看完全部接收端。 +### 复审修复 W-03 + +- 日期:2026-09-30 +- 原条款:PRD F17 群成员增减;审查 #53;DEVIATIONS A3.3。 +- 实际做法:群详情返回 `member_total`(只增字段)并同步 admin-api 7.8;成员表远程分页。去掉建群编号输入,占位改为「群名称」。移除/转让加确认;加人失败原因译成中文。 +- 原因:默认每页 50,千人群看不到后半;前端仍按可自定 id 设计。 +- 备选方案:后端按编号前缀搜索;未采用,与现接口「名称包含」一致。 +- 影响:网页线改了 `internal/admin/groups.go` 与 admin-api 7.8(issue 允许)。 + ### S1.1 传输层可注入假实现(Go / JS) - 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。 diff --git a/docs/api/admin-api.md b/docs/api/admin-api.md index 877d68c..55e9938 100644 --- a/docs/api/admin-api.md +++ b/docs/api/admin-api.md @@ -559,6 +559,7 @@ Authorization: Bearer nxm_... "name": "一组", "owner_id": "a", "created_at_ms": 1750000000000, + "member_total": 1, "members": [ {"id": "a", "name": "", "online": true, "joined_at_ms": 1750000000000} ], diff --git a/internal/admin/a3_test.go b/internal/admin/a3_test.go index 953e3bb..32aa4b3 100644 --- a/internal/admin/a3_test.go +++ b/internal/admin/a3_test.go @@ -272,6 +272,33 @@ func TestGroupsCRUD(t *testing.T) { t.Fatalf("created=%+v", created) } + res = doReq(t, client, http.MethodGet, base+"/api/admin/groups/"+created.ID+"?limit=1", "", nil) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("get page1: %d %+v", res.StatusCode, env) + } + var page1 struct { + MemberTotal float64 `json:"member_total"` + Members []any `json:"members"` + NextCursor string `json:"next_cursor"` + } + _ = json.Unmarshal(env.Data, &page1) + if page1.MemberTotal != 3 || len(page1.Members) != 1 || page1.NextCursor == "" { + t.Fatalf("page1=%+v raw=%s", page1, env.Data) + } + res = doReq(t, client, http.MethodGet, base+"/api/admin/groups/"+created.ID+"?limit=1&cursor="+page1.NextCursor, "", nil) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("get page2: %d %+v", res.StatusCode, env) + } + var page2 struct { + Members []any `json:"members"` + } + _ = json.Unmarshal(env.Data, &page2) + if len(page2.Members) != 1 { + t.Fatalf("page2 members=%d", len(page2.Members)) + } + res = doReq(t, client, http.MethodPatch, base+"/api/admin/groups/"+created.ID, `{"name":"新名"}`, csrf()) env = decodeEnv(t, res) diff --git a/internal/admin/groups.go b/internal/admin/groups.go index 14c122d..c8eb577 100644 --- a/internal/admin/groups.go +++ b/internal/admin/groups.go @@ -209,6 +209,7 @@ LIMIT ? OFFSET ?`, id, limit, offset) "name": name, "owner_id": owner, "created_at_ms": created, + "member_total": memberTotal, "members": members, "next_cursor": next, }) diff --git a/web/src/api/mock.ts b/web/src/api/mock.ts index ef092bf..64782af 100644 --- a/web/src/api/mock.ts +++ b/web/src/api/mock.ts @@ -607,7 +607,7 @@ export const mockApi = { let list = groups.map(toGroupSummary); if (query) { const n = query.toLowerCase(); - list = list.filter((g) => g.name.toLowerCase().includes(n) || g.id.toLowerCase().includes(n)); + list = list.filter((g) => g.name.toLowerCase().includes(n)); } return paginate(list, cursor, limit); }, @@ -664,6 +664,7 @@ export const mockApi = { name: g.name, owner_id: g.owner_id, created_at_ms: g.created_at_ms, + member_total: all.length, members: page.items, next_cursor: page.next_cursor, }; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 8f5efcb..e7b6ddc 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -155,6 +155,7 @@ export interface GroupDetail { name: string; owner_id: string; created_at_ms: number; + member_total?: number; members: GroupMember[]; next_cursor: string; } diff --git a/web/src/views/GroupsView.spec.ts b/web/src/views/GroupsView.spec.ts new file mode 100644 index 0000000..5d6ff6e --- /dev/null +++ b/web/src/views/GroupsView.spec.ts @@ -0,0 +1,77 @@ +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 GroupsView from "./GroupsView.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("GroupsView", () => { + beforeEach(() => { + setActivePinia(createPinia()); + mockApi._setSession(); + vi.restoreAllMocks(); + }); + + it("搜索占位为群名称,无编号输入,详情可翻到第 2 页", async () => { + const getSpy = vi.spyOn(admin, "getGroup"); + const w = mount(wrap(GroupsView), { + global: { plugins: [createPinia()] }, + attachTo: document.body, + }); + await flushPromises(); + + expect(w.find('[data-testid="group-query"]').attributes("placeholder") || w.html()).toBeTruthy(); + expect(w.find('[placeholder="群名称"]').exists() || w.html().includes("群名称")).toBe(true); + + await w.find('[data-testid="group-create-open"]').trigger("click"); + await flushPromises(); + expect(w.find('[data-testid="group-id"]').exists()).toBe(false); + expect(w.text()).toContain("创建后自动分配"); + + const detailBtn = w.findAll("button").find((b) => b.text().trim() === "详情"); + await detailBtn!.trigger("click"); + await flushPromises(); + expect(getSpy).toHaveBeenCalled(); + + getSpy.mockClear(); + getSpy.mockImplementation((id, cursor) => mockApi.getGroup(id, cursor, 1)); + await detailBtn!.trigger("click"); + await flushPromises(); + const page2 = w.findAll("li,button").find((el) => el.text().trim() === "2"); + if (page2) { + await page2.trigger("click"); + await flushPromises(); + expect(getSpy.mock.calls.some((c) => String(c[1] || "").length > 0)).toBe(true); + } + w.unmount(); + }); +}); diff --git a/web/src/views/GroupsView.vue b/web/src/views/GroupsView.vue index 0fcc802..49eef29 100644 --- a/web/src/views/GroupsView.vue +++ b/web/src/views/GroupsView.vue @@ -10,10 +10,12 @@ import { NModal, NScrollbar, NSpace, + NText, useDialog, } from "naive-ui"; import PageHeader from "@/components/PageHeader.vue"; import LoadFailed from "@/components/LoadFailed.vue"; +import HelpTip from "@/components/HelpTip.vue"; import { addGroupMembers, createGroup, @@ -40,11 +42,14 @@ const pageSize = ref(50); const query = ref(""); const createOpen = ref(false); -const createForm = reactive({ id: "", name: "", owner_id: "", member_ids: "" }); +const createForm = reactive({ name: "", owner_id: "", member_ids: "" }); const createLoading = ref(false); const detailOpen = ref(false); +const detailId = ref(""); const detail = ref(null); +const memberPage = ref(1); +const memberPageSize = ref(50); const renameName = ref(""); const addMembers = ref(""); @@ -137,13 +142,38 @@ const memberColumns = computed>(() => [ }, ]); -async function openDetail(id: string) { - detail.value = await getGroup(id); +async function loadDetail(id: string) { + const cursor = memberPage.value > 1 ? String((memberPage.value - 1) * memberPageSize.value) : ""; + detail.value = await getGroup(id, cursor, memberPageSize.value); renameName.value = detail.value.name; +} + +async function openDetail(id: string) { + detailId.value = id; + memberPage.value = 1; addMembers.value = ""; + await loadDetail(id); detailOpen.value = true; } +function failLabel(code: string): string { + switch (code) { + case "not_found": + case "invalid_target": + return "端不存在"; + case "endpoint_disabled": + return "端已停用"; + case "group_full": + return "群已满"; + default: + return code; + } +} + +function formatFailed(failed: { id: string; code: string }[]): string { + return failed.map((f) => `${f.id}(${failLabel(f.code)})`).join("、"); +} + async function submitCreate() { if (!createForm.name.trim() || !createForm.owner_id.trim()) { message.error("名称与群主必填"); @@ -156,14 +186,13 @@ async function submitCreate() { .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(", ")}`); + message.warning(`已创建,部分成员失败:${formatFailed(res.failed)}`); } else { message.success("已创建"); } @@ -191,7 +220,7 @@ async function onRename() { if (!detail.value) return; await renameGroup(detail.value.id, renameName.value.trim()); message.success("已改名"); - await openDetail(detail.value.id); + await loadDetail(detail.value.id); await load(); } @@ -204,28 +233,44 @@ async function onAddMembers() { 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(", ")}`); + message.warning(`部分失败:${formatFailed(res.failed)}`); } else { message.success("已加人"); } - await openDetail(detail.value.id); + await loadDetail(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(); + dialog.warning({ + title: "移除成员", + content: `确定将 ${endpointId} 移出本群?`, + positiveText: "移除", + negativeText: "取消", + onPositiveClick: async () => { + await removeGroupMember(detail.value!.id, endpointId); + message.success("已移除"); + await loadDetail(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(); + dialog.warning({ + title: "转让群主", + content: `确定将群主转让给 ${endpointId}?`, + positiveText: "转让", + negativeText: "取消", + onPositiveClick: async () => { + await transferGroup(detail.value!.id, endpointId); + message.success("已转让群主"); + await loadDetail(detail.value!.id); + await load(); + }, + }); } @@ -237,7 +282,7 @@ async function onTransfer(endpointId: string) { type="primary" data-testid="group-create-open" @click=" - Object.assign(createForm, { id: '', name: '', owner_id: '', member_ids: '' }); + Object.assign(createForm, { name: '', owner_id: '', member_ids: '' }); createOpen = true; " > @@ -249,7 +294,7 @@ async function onTransfer(endpointId: string) {