fix: 群详情成员分页并与后端能力对齐

This commit is contained in:
Nixevol
2026-09-30 16:22:49 +08:00
parent 05ecb43766
commit 965060dc0c
8 changed files with 204 additions and 22 deletions
+9
View File
@@ -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) ### S1.1 传输层可注入假实现(Go / JS)
- 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。 - 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。
+1
View File
@@ -559,6 +559,7 @@ Authorization: Bearer nxm_...
"name": "一组", "name": "一组",
"owner_id": "a", "owner_id": "a",
"created_at_ms": 1750000000000, "created_at_ms": 1750000000000,
"member_total": 1,
"members": [ "members": [
{"id": "a", "name": "", "online": true, "joined_at_ms": 1750000000000} {"id": "a", "name": "", "online": true, "joined_at_ms": 1750000000000}
], ],
+27
View File
@@ -272,6 +272,33 @@ func TestGroupsCRUD(t *testing.T) {
t.Fatalf("created=%+v", created) 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, res = doReq(t, client, http.MethodPatch, base+"/api/admin/groups/"+created.ID,
`{"name":"新名"}`, csrf()) `{"name":"新名"}`, csrf())
env = decodeEnv(t, res) env = decodeEnv(t, res)
+1
View File
@@ -209,6 +209,7 @@ LIMIT ? OFFSET ?`, id, limit, offset)
"name": name, "name": name,
"owner_id": owner, "owner_id": owner,
"created_at_ms": created, "created_at_ms": created,
"member_total": memberTotal,
"members": members, "members": members,
"next_cursor": next, "next_cursor": next,
}) })
+2 -1
View File
@@ -607,7 +607,7 @@ export const mockApi = {
let list = groups.map(toGroupSummary); let list = groups.map(toGroupSummary);
if (query) { if (query) {
const n = query.toLowerCase(); 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); return paginate(list, cursor, limit);
}, },
@@ -664,6 +664,7 @@ export const mockApi = {
name: g.name, name: g.name,
owner_id: g.owner_id, owner_id: g.owner_id,
created_at_ms: g.created_at_ms, created_at_ms: g.created_at_ms,
member_total: all.length,
members: page.items, members: page.items,
next_cursor: page.next_cursor, next_cursor: page.next_cursor,
}; };
+1
View File
@@ -155,6 +155,7 @@ export interface GroupDetail {
name: string; name: string;
owner_id: string; owner_id: string;
created_at_ms: number; created_at_ms: number;
member_total?: number;
members: GroupMember[]; members: GroupMember[];
next_cursor: string; next_cursor: string;
} }
+77
View File
@@ -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();
});
});
+82 -17
View File
@@ -10,10 +10,12 @@ import {
NModal, NModal,
NScrollbar, NScrollbar,
NSpace, NSpace,
NText,
useDialog, useDialog,
} from "naive-ui"; } from "naive-ui";
import PageHeader from "@/components/PageHeader.vue"; import PageHeader from "@/components/PageHeader.vue";
import LoadFailed from "@/components/LoadFailed.vue"; import LoadFailed from "@/components/LoadFailed.vue";
import HelpTip from "@/components/HelpTip.vue";
import { import {
addGroupMembers, addGroupMembers,
createGroup, createGroup,
@@ -40,11 +42,14 @@ const pageSize = ref(50);
const query = ref(""); const query = ref("");
const createOpen = ref(false); 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 createLoading = ref(false);
const detailOpen = ref(false); const detailOpen = ref(false);
const detailId = ref("");
const detail = ref<GroupDetail | null>(null); const detail = ref<GroupDetail | null>(null);
const memberPage = ref(1);
const memberPageSize = ref(50);
const renameName = ref(""); const renameName = ref("");
const addMembers = ref(""); const addMembers = ref("");
@@ -137,13 +142,38 @@ const memberColumns = computed<DataTableColumns<GroupMember>>(() => [
}, },
]); ]);
async function openDetail(id: string) { async function loadDetail(id: string) {
detail.value = await getGroup(id); const cursor = memberPage.value > 1 ? String((memberPage.value - 1) * memberPageSize.value) : "";
detail.value = await getGroup(id, cursor, memberPageSize.value);
renameName.value = detail.value.name; renameName.value = detail.value.name;
}
async function openDetail(id: string) {
detailId.value = id;
memberPage.value = 1;
addMembers.value = ""; addMembers.value = "";
await loadDetail(id);
detailOpen.value = true; 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() { async function submitCreate() {
if (!createForm.name.trim() || !createForm.owner_id.trim()) { if (!createForm.name.trim() || !createForm.owner_id.trim()) {
message.error("名称与群主必填"); message.error("名称与群主必填");
@@ -156,14 +186,13 @@ async function submitCreate() {
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const res = await createGroup({ const res = await createGroup({
id: createForm.id.trim() || undefined,
name: createForm.name.trim(), name: createForm.name.trim(),
owner_id: createForm.owner_id.trim(), owner_id: createForm.owner_id.trim(),
member_ids, member_ids,
}); });
createOpen.value = false; createOpen.value = false;
if (res.failed.length) { if (res.failed.length) {
message.warning(`已创建,部分成员失败:${res.failed.map((f) => f.id).join(", ")}`); message.warning(`已创建,部分成员失败:${formatFailed(res.failed)}`);
} else { } else {
message.success("已创建"); message.success("已创建");
} }
@@ -191,7 +220,7 @@ async function onRename() {
if (!detail.value) return; if (!detail.value) return;
await renameGroup(detail.value.id, renameName.value.trim()); await renameGroup(detail.value.id, renameName.value.trim());
message.success("已改名"); message.success("已改名");
await openDetail(detail.value.id); await loadDetail(detail.value.id);
await load(); await load();
} }
@@ -204,28 +233,44 @@ async function onAddMembers() {
if (!ids.length) return; if (!ids.length) return;
const res = await addGroupMembers(detail.value.id, ids); const res = await addGroupMembers(detail.value.id, ids);
if (res.failed.length) { if (res.failed.length) {
message.warning(`部分失败:${res.failed.map((f) => f.id).join(", ")}`); message.warning(`部分失败:${formatFailed(res.failed)}`);
} else { } else {
message.success("已加人"); message.success("已加人");
} }
await openDetail(detail.value.id); await loadDetail(detail.value.id);
await load(); await load();
} }
async function onRemove(endpointId: string) { async function onRemove(endpointId: string) {
if (!detail.value) return; if (!detail.value) return;
await removeGroupMember(detail.value.id, endpointId); dialog.warning({
title: "移除成员",
content: `确定将 ${endpointId} 移出本群?`,
positiveText: "移除",
negativeText: "取消",
onPositiveClick: async () => {
await removeGroupMember(detail.value!.id, endpointId);
message.success("已移除"); message.success("已移除");
await openDetail(detail.value.id); await loadDetail(detail.value!.id);
await load(); await load();
},
});
} }
async function onTransfer(endpointId: string) { async function onTransfer(endpointId: string) {
if (!detail.value) return; if (!detail.value) return;
await transferGroup(detail.value.id, endpointId); dialog.warning({
title: "转让群主",
content: `确定将群主转让给 ${endpointId}?`,
positiveText: "转让",
negativeText: "取消",
onPositiveClick: async () => {
await transferGroup(detail.value!.id, endpointId);
message.success("已转让群主"); message.success("已转让群主");
await openDetail(detail.value.id); await loadDetail(detail.value!.id);
await load(); await load();
},
});
} }
</script> </script>
@@ -237,7 +282,7 @@ async function onTransfer(endpointId: string) {
type="primary" type="primary"
data-testid="group-create-open" data-testid="group-create-open"
@click=" @click="
Object.assign(createForm, { id: '', name: '', owner_id: '', member_ids: '' }); Object.assign(createForm, { name: '', owner_id: '', member_ids: '' });
createOpen = true; createOpen = true;
" "
> >
@@ -249,7 +294,7 @@ async function onTransfer(endpointId: string) {
<LoadFailed v-if="loadError" :description="loadError" @retry="load" /> <LoadFailed v-if="loadError" :description="loadError" @retry="load" />
<template v-else> <template v-else>
<n-space style="margin-bottom: 12px"> <n-space style="margin-bottom: 12px">
<n-input v-model:value="query" placeholder="名称/编号" style="width: 200px" @keyup.enter="page = 1; load()" /> <n-input v-model:value="query" placeholder="群名称" style="width: 200px" data-testid="group-query" @keyup.enter="page = 1; load()" />
<n-button @click="page = 1; load()">查询</n-button> <n-button @click="page = 1; load()">查询</n-button>
</n-space> </n-space>
<n-data-table <n-data-table
@@ -275,8 +320,12 @@ async function onTransfer(endpointId: string) {
<n-modal v-model:show="createOpen" preset="card" title="新建群" style="width: 480px"> <n-modal v-model:show="createOpen" preset="card" title="新建群" style="width: 480px">
<n-scrollbar style="max-height: 320px"> <n-scrollbar style="max-height: 320px">
<n-form label-placement="left" label-width="90" :show-feedback="false"> <n-form label-placement="left" label-width="90" :show-feedback="false">
<n-form-item label="编号"> <n-form-item>
<n-input v-model:value="createForm.id" placeholder="留空则生成" data-testid="group-id" /> <template #label>
编号
<HelpTip>由服务器生成,后台不能自定。</HelpTip>
</template>
<n-text depth="3">创建后自动分配</n-text>
</n-form-item> </n-form-item>
<n-form-item label="名称"> <n-form-item label="名称">
<n-input v-model:value="createForm.name" data-testid="group-name" /> <n-input v-model:value="createForm.name" data-testid="group-name" />
@@ -323,7 +372,23 @@ async function onTransfer(endpointId: string) {
</n-space> </n-space>
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-data-table :columns="memberColumns" :data="detail.members" :row-key="(r: GroupMember) => r.id" size="small" /> <n-data-table
remote
:columns="memberColumns"
:data="detail.members"
:row-key="(r: GroupMember) => r.id"
size="small"
:max-height="280"
:pagination="{
page: memberPage,
pageSize: memberPageSize,
itemCount: detail.member_total ?? detail.members.length,
onChange: (p: number) => {
memberPage = p;
loadDetail(detailId);
},
}"
/>
</n-scrollbar> </n-scrollbar>
<template #footer> <template #footer>
<n-space justify="end"> <n-space justify="end">