fix: 群详情成员分页并与后端能力对齐
This commit is contained in:
@@ -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,不要起真实服务器」。
|
||||||
|
|||||||
@@ -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}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user