From c3b0f1fffc166c24b828391cbd08f9ac564cc48f Mon Sep 17 00:00:00 2001 From: mycyg Date: Fri, 17 Jul 2026 09:19:28 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(auth):=20#H1=20GET=20/api/auth/invites?= =?UTF-8?q?=20=E2=80=94=20list=20pending=20(unexpired)=20invites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R18 批 H1(成员管理面板 · 未过期邀请清单):新增 admin 门控的读端点 GET /api/auth/invites?status=pending,回本工作区未接受∧未撤销∧未过期的邀请 {invites:[{invite_id,email,expires_at,created_at}]}——绝不回 token(服务端只存 sha256,明文取不回)。门控/错误码同 POST /invites。 - db: InviteRepository.listPending(workspaceId, now),最新在前 - contracts: pendingInviteVmSchema / listPendingInvitesResultVmSchema(additive) - openapi: GET /api/auth/invites additive path + 响应 schema - test: 列出/排除(已接受/撤销/过期)、无 token、403 非管理员、400 非 pending Co-Authored-By: Claude Fable 5 --- apps/api/src/auth.test.ts | 71 +++++++++++++++++++++++++ apps/api/src/openapi.ts | 46 ++++++++++++++++ apps/api/src/routes/auth.ts | 34 ++++++++++++ packages/contracts/src/auth.ts | 22 +++++++- packages/db/src/repositories/invites.ts | 23 +++++++- 5 files changed, 194 insertions(+), 2 deletions(-) diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts index b1e9b968b..2ff1f00de 100644 --- a/apps/api/src/auth.test.ts +++ b/apps/api/src/auth.test.ts @@ -550,6 +550,18 @@ class MemoryInvites implements InviteRepository { (row) => row.email.toLowerCase() === email.toLowerCase() && row.acceptedAt === null && row.deletedAt === null ); } + + async listPending(workspaceId: string, at: Date) { + return this.rows + .filter( + (row) => + row.workspaceId === workspaceId && + row.acceptedAt === null && + row.deletedAt === null && + row.expiresAt > at + ) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } } // 用户停用-工作交接的假仓库:只实现 unassignActiveClaimsForUser(停用路由用的唯一方法)。 @@ -1979,6 +1991,65 @@ test("invite create derives tenant and role from the server-side admin context", assert.equal(invites.rows[0]?.workspaceId, "22220000-0000-4000-8000-0000000000ad"); }); +// R18 批 H1:GET /api/auth/invites?status=pending —— 列未过期邀请(未接受∧未撤销∧未过期),绝不回 token。 +test("invite list returns pending invites without tokens, excluding accepted/revoked/expired", async () => { + const admin = user({ id: "10000000-0000-4000-8000-0000000000ae", nickname: "admin", isAdmin: true }); + const { deps, invites, runtimeSettings } = inviteCtx(admin); + const workspaceId = runtimeSettings.auth.defaultWorkspaceId; + const { token: adminToken } = await mintSession(deps, admin, { authMethod: "password" }); + + const app = withErrors(new Hono()); + app.route("/auth", createAuthRoutes(deps)); + const cookie = await signedCookie(adminToken, runtimeSettings); + + // 一条活跃邀请(POST 创建,workspaceId = actor 默认工作区)。 + const createRes = await app.request("/auth/invites", { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify({ email: "pending@example.com" }) + }); + assert.equal(createRes.status, 201); + // 一条已过期、一条已接受、一条已撤销——都不该出现在清单里。 + await invites.create({ email: "expired@example.com", tokenHash: "hash-expired", workspaceId, expiresAt: new Date(now.getTime() - 1000) }); + const accepted = await invites.create({ email: "accepted@example.com", tokenHash: "hash-accepted", workspaceId, expiresAt: new Date(now.getTime() + 1_000_000) }); + await invites.accept(accepted.id, admin.id, now); + const revoked = await invites.create({ email: "revoked@example.com", tokenHash: "hash-revoked", workspaceId, expiresAt: new Date(now.getTime() + 1_000_000) }); + await invites.revoke(revoked.id, now); + + const listRes = await app.request("/auth/invites?status=pending", { headers: { Cookie: cookie } }); + assert.equal(listRes.status, 200); + const body = (await listRes.json()) as { invites: Array> }; + assert.equal(body.invites.length, 1, "only the single active invite is listed"); + const only = body.invites[0]!; + assert.equal(only["email"], "pending@example.com"); + assert.ok(typeof only["invite_id"] === "string" && (only["invite_id"] as string).length > 0); + assert.ok(typeof only["expires_at"] === "string"); + assert.ok(typeof only["created_at"] === "string"); + assert.equal("token" in only, false, "list never leaks the invite token"); +}); + +test("invite list requires admin (403) and rejects non-pending status (400)", async () => { + const admin = user({ id: "10000000-0000-4000-8000-0000000000af", nickname: "admin", isAdmin: true }); + const member = user({ id: "10000000-0000-4000-8000-0000000000b0", nickname: "member" }); + const { deps, users, runtimeSettings } = inviteCtx(admin); + (users as unknown as { rows: UserAuthRow[] }).rows.push(member); + const { token: adminToken } = await mintSession(deps, admin, { authMethod: "password" }); + const { token: memberToken } = await mintSession(deps, member, { authMethod: "password" }); + + const app = withErrors(new Hono()); + app.route("/auth", createAuthRoutes(deps)); + + const byMember = await app.request("/auth/invites?status=pending", { + headers: { Cookie: await signedCookie(memberToken, runtimeSettings) } + }); + assert.equal(byMember.status, 403); + + const badStatus = await app.request("/auth/invites?status=accepted", { + headers: { Cookie: await signedCookie(adminToken, runtimeSettings) } + }); + assert.equal(badStatus.status, 400); +}); + // ——— 团队就绪 gap[41]:安全/身份事件审计 ——— // auditLogs 写必抛——验「尽力而为」:审计失败绝不破坏认证主流程的状态码/响应体。 diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index e9f78c617..0d6f5fe49 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -638,6 +638,29 @@ const inviteCreateResponseSchema = { }, additionalProperties: false } as const; +// R18 批 H1(成员管理面板 · 未过期邀请清单):GET /api/auth/invites?status=pending 的响应。绝不带 +// token——服务端只存 sha256,明文取不回;只回 invite_id/email/过期时间/创建时间。 +const pendingInvitesListResponseSchema = { + type: "object", + required: ["invites"], + properties: { + invites: { + type: "array", + items: { + type: "object", + required: ["invite_id", "email", "expires_at", "created_at"], + properties: { + invite_id: uuidStringSchema, + email: { type: "string", format: "email", maxLength: 320 }, + expires_at: dateTimeStringSchema, + created_at: dateTimeStringSchema + }, + additionalProperties: false + } + } + }, + additionalProperties: false +} as const; const inviteAcceptRequestBodySchema = { type: "object", required: ["token", "nickname", "password"], @@ -775,6 +798,15 @@ const authInviteAcceptResponses = { "422": authValidationResponse } } as const; +const authInviteListResponses = { + responses: { + "200": rawJsonResponse(pendingInvitesListResponseSchema, "Pending (unexpired, unaccepted) invites for the workspace").responses["200"], + "400": authBadRequestResponse, + "401": authNotIdentifiedResponse, + "403": authForbiddenResponse, + "404": authNotFoundResponse + } +} as const; const authDeactivateResponses = { responses: { "200": rawJsonResponse(authOkResponseSchema, "Deactivated user").responses["200"], @@ -7186,6 +7218,20 @@ export function getOpenApiDocument() { } }, "/api/auth/invites": { + get: { + tags: ["auth"], + summary: "Admin: list pending (unexpired, unaccepted) invites for the workspace (never returns tokens)", + parameters: [ + { + name: "status", + in: "query", + required: false, + schema: { type: "string", enum: ["pending"], default: "pending" }, + description: "Only 'pending' is supported" + } + ], + ...authInviteListResponses + }, post: { tags: ["auth"], summary: "Admin: create an out-of-band invite, returns a one-time token", diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index f4c399ba8..17a31202c 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -588,6 +588,40 @@ export function createAuthRoutes( ); }); + // R18 批 H1(成员管理面板 · 未过期邀请清单):管理员列出本工作区仍可用的邀请(未接受 ∧ 未撤销 ∧ + // 未过期,最新在前)。门控与错误码同 POST /invites(鉴权先行 401 → 非管理员 403 → 非密码模式 404 → + // 运行时不支持 501)。绝不回 token——服务端只存 sha256(token),明文取不回(POST 创建时一次性回过)。 + routes.get("/invites", async (c) => { + const deps = resolveAuthDependencies(source); + const actingUser = await resolveCurrentUser(c, deps); // 鉴权先行 → 未鉴权 401 fail-closed + if (!actingUser.isAdmin) { + throw new HTTPException(403, { message: "需要管理员权限" }); + } + if (!passwordModeEnabled(deps)) { + throw new HTTPException(404, { message: "邀请功能未启用" }); + } + if (!deps.invites) { + throw new HTTPException(501, { message: "当前运行时不支持邀请" }); + } + // ?status=pending 是目前唯一支持的取值(默认 pending)——收窄查询面,其它取值直接 400, + // 避免客户端误以为 status=accepted 等也能查(本端点只服务成员管理面板的未过期邀请追踪)。 + const status = c.req.query("status") ?? "pending"; + if (status !== "pending") { + throw new HTTPException(400, { message: "只支持 status=pending" }); + } + const at = (deps.now ?? (() => new Date()))(); + const actor = await resolveHumanActor(deps, actingUser); + const rows = await deps.invites.listPending(actor.workspaceId, at); + return c.json({ + invites: rows.map((row) => ({ + invite_id: row.id, + email: row.email, + expires_at: row.expiresAt.toISOString(), + created_at: row.createdAt.toISOString() + })) + }); + }); + // 公开入口:收件人凭 out-of-band token 接受邀请 → 建账号 + 凭据 + 默认成员 + 会话。 routes.post("/invites/accept", async (c) => { const deps = resolveAuthDependencies(source); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 2ae41bed5..666d87826 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { idSchema } from "./domain/common.js"; +import { idSchema, isoDateTimeSchema } from "./domain/common.js"; import { clientDeviceSchema, userSchema } from "./domain/identity.js"; import { identityContextSchema } from "./identity.js"; import { workHubLocaleInputSchema, workHubLocaleSchema } from "./locale.js"; @@ -56,6 +56,26 @@ export const inviteAcceptRequestSchema = z.object({ }); export type InviteAcceptRequest = z.infer; +// R18 批 H1(成员管理面板 · 未过期邀请清单):GET /api/auth/invites?status=pending 的一条邀请。 +// 绝不带 token——服务端只存 sha256(token),明文取不回(POST 创建时一次性回过),这里如实只暴露 +// invite_id/email/过期时间/创建时间,供管理员在 web 成员分区追踪尚未被接受、且还没过期的邀请。 +export const pendingInviteVmSchema = z + .object({ + invite_id: idSchema, + email: z.string().email().max(320), + expires_at: isoDateTimeSchema, + created_at: isoDateTimeSchema + }) + .strict(); +export type PendingInviteVM = z.infer; + +export const listPendingInvitesResultVmSchema = z + .object({ + invites: z.array(pendingInviteVmSchema) + }) + .strict(); +export type ListPendingInvitesResultVM = z.infer; + // R17 批 G1(群成员管理 · #15 工作区成员移出/角色变更):工作区成员角色枚举——与 db // memberships.role('member'|'admin'|'owner')对齐。 export const workspaceMemberRoleSchema = z.enum(["member", "admin", "owner"]); diff --git a/packages/db/src/repositories/invites.ts b/packages/db/src/repositories/invites.ts index cb2ea7a9a..90a2ba08e 100644 --- a/packages/db/src/repositories/invites.ts +++ b/packages/db/src/repositories/invites.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -import { and, eq, gt, isNull } from "drizzle-orm"; +import { and, desc, eq, gt, isNull } from "drizzle-orm"; import type { WorkHubDb } from "../client.js"; import { userInvites } from "../schema/index.js"; @@ -31,6 +31,12 @@ export type InviteRepository = { revoke: (id: string, at: Date) => Promise; /** 某邮箱的待接受邀请(未接受 ∧ 未撤销)。 */ listPendingForEmail: (email: string) => Promise; + /** + * R18 批 H1(成员管理面板 · 未过期邀请清单):某工作区仍可用的邀请——未接受 ∧ 未撤销 ∧ 未过期, + * 最新在前。供管理员在 web 成员分区追踪已发出、尚未被接受、且还没过期的邀请(token 只回过一次、 + * 服务端只存 sha256 取不回,所以这里绝不带 token——只带 id/email/过期时间/创建时间)。 + */ + listPending: (workspaceId: string, now: Date) => Promise; }; export function createInviteRepository(db: WorkHubDb): InviteRepository { @@ -94,6 +100,21 @@ export function createInviteRepository(db: WorkHubDb): InviteRepository { .select() .from(userInvites) .where(and(eq(userInvites.email, email), isNull(userInvites.acceptedAt), isNull(userInvites.deletedAt))); + }, + + async listPending(workspaceId, now) { + return db + .select() + .from(userInvites) + .where( + and( + eq(userInvites.workspaceId, workspaceId), + isNull(userInvites.acceptedAt), + isNull(userInvites.deletedAt), + gt(userInvites.expiresAt, now) + ) + ) + .orderBy(desc(userInvites.createdAt)); } }; } From 26799e4b7109b0cda4f5d24a3b607a17cc751f95 Mon Sep 17 00:00:00 2001 From: mycyg Date: Fri, 17 Jul 2026 09:27:22 +0800 Subject: [PATCH 2/4] feat(web): #H1 conversation mirror participant management (manager console) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R18 批 H1(web 会话镜像成员管理):只读会话镜像 /conversations/:id 新增「参与者」 侧区——SSR 出加载态骨架,browser.ts bindConversationParticipantsPanel 拉 GET /participants 后按 scope/is_dm 渲: * main(scope:"workspace")→ 全员会话说明,无动作; * DM(is_dm)→ 双人说明,无动作; * 普通群 → 成员条 + 群管理动作:任何参与者可加人(工作区成员选择器,排除已在群者) 与退出(自删);群主额外可移出他人。调 G1 端点,403/409 按错误码人话化,动作落定重拉。 SSE 取舍(G-web 窄化纪律):web 镜像页刻意不订会话 SSE(liveEventTypes 已排除 conversation.*)——参与者变化靠「动作后重拉 + 手动刷新按钮」感知,不把 conversation.* 事件引回 web。 - contracts: conversationParticipantsVmSchema 增 additive/optional is_dm 判别位 - api: listParticipants/addParticipant 从 dm_key 如实推导 is_dm - ui: 会话镜像 SSR 参与者骨架 + 测试 - web: bindConversationParticipantsPanel(水合 + 群管理动作 + 双语错误矩阵) Co-Authored-By: Claude Fable 5 --- apps/api/src/conversations.test.ts | 29 +++ apps/api/src/services/conversations.ts | 5 + apps/web/src/browser.ts | 243 ++++++++++++++++++ packages/contracts/src/domain/conversation.ts | 7 +- packages/contracts/src/r14-chat.test.ts | 12 + .../ui/src/gold-path/route-components.test.ts | 30 +++ packages/ui/src/gold-path/route-components.ts | 9 + 7 files changed, 334 insertions(+), 1 deletion(-) diff --git a/apps/api/src/conversations.test.ts b/apps/api/src/conversations.test.ts index 242ff2935..95d87e5d2 100644 --- a/apps/api/src/conversations.test.ts +++ b/apps/api/src/conversations.test.ts @@ -2167,6 +2167,7 @@ test("listParticipants returns scope=participants with real rows for a collab (a assert.deepEqual(result, { scope: "participants", + is_dm: false, participants: [ { user_id: userId, nickname: "阿曼", role: "owner" }, { user_id: participantUserId, nickname: "小赵", role: "member" } @@ -2174,6 +2175,33 @@ test("listParticipants returns scope=participants with real rows for a collab (a }); }); +test("listParticipants marks a DM (dm_key set) with is_dm=true", async () => { + const service = createConversationService( + repository({ + async findVisibleAccessRecord() { + return accessRecord({ conversation: conversationRow({ dmKey: "dm:a:b" }), participantRole: "owner" }); + }, + async listParticipantsWithNickname() { + return [ + { userId, nickname: "阿曼", role: "owner" }, + { userId: participantUserId, nickname: "小赵", role: "member" } + ]; + } + }), + { + driveFiles: driveFiles(async () => { + throw new Error("Drive must not be called"); + }), + now: () => now + } + ); + + const result = await service.listParticipants({ actor: actor(), conversationId }); + + assert.equal(result.scope, "participants"); + assert.equal(result.is_dm, true); +}); + test("listParticipants 404s an invisible (or non-participant) conversation before any query", async () => { let listCalls = 0; const service = createConversationService( @@ -2248,6 +2276,7 @@ test("addParticipant adds to a non-dm collab, broadcasts participants.updated, a assert.deepEqual(writeInput, { workspaceId, conversationId, addedUserId: participantUserId, at: now }); assert.equal(result.added, true); assert.equal(result.participants.scope, "participants"); + assert.equal(result.participants.is_dm, false); assert.equal(result.participants.participants.length, 2); assert.equal(capture.published[0]?.type, "conversation.participants.updated"); const event = capture.published[0]?.data as { data: { conversation_id: string; change: string; user_id: string } }; diff --git a/apps/api/src/services/conversations.ts b/apps/api/src/services/conversations.ts index 4ad3cd0dd..87fa025aa 100644 --- a/apps/api/src/services/conversations.ts +++ b/apps/api/src/services/conversations.ts @@ -1055,6 +1055,9 @@ export function createConversationService( conversationParticipantsVmSchema, { scope: "participants", + // R18 批 H1:DM 判别位从 dm_key 非空如实推导(供 web 会话镜像区分「可管理的群」与「不可增删的 + // 2 人 DM」);main 走上面的 scope:"workspace" 分支,不带 is_dm。 + is_dm: Boolean(access.conversation.dmKey), participants: rows.map((row) => ({ user_id: row.userId, nickname: row.nickname, role: row.role })) }, "conversations.participants.list" @@ -1107,6 +1110,8 @@ export function createConversationService( added: result.added, participants: { scope: "participants" as const, + // 加人只在非 DM 群成功(DM 已在上面 409),dm_key 恒空 → is_dm:false,与 listParticipants 同口径。 + is_dm: Boolean(conversation.dmKey), participants: rows.map((row) => ({ user_id: row.userId, nickname: row.nickname, role: row.role })) } }, diff --git a/apps/web/src/browser.ts b/apps/web/src/browser.ts index d85274c06..c7b41ced1 100644 --- a/apps/web/src/browser.ts +++ b/apps/web/src/browser.ts @@ -2144,6 +2144,7 @@ function bindReadyRoute(result: WebRouteReadyResult, client: BrowserApiClient, l bindNotificationMutePanel(root, result, client, locale, signal); bindProjectHomePlansPanel(root, result, client, locale, signal); bindProjectHomeInstructionsPanel(root, result, client, locale, signal); + bindConversationParticipantsPanel(root, result, client, locale, signal); bindSearchRoutePanel(root, result, client, locale, signal); bindSettingsAiProfilePanel(root, result, client, locale, signal); bindSettingsMyProfilePanel(root, result, client, locale, signal); @@ -2521,6 +2522,248 @@ function bindProjectHomeInstructionsPanel( void hydrate(); } +// R18 批 H1(web 会话镜像成员管理):只读会话镜像的「参与者」侧区水合。SSR(route-components +// renderConversationRouteComponent)只出加载态骨架;这里拉 GET /participants 后按 scope/is_dm 渲: +// * main(scope:"workspace")→ 全员会话说明,无动作; +// * DM(scope:"participants" + is_dm)→ 双人说明,无动作; +// * 普通群(scope:"participants" 非 DM)→ 成员条 + 群管理动作:任何参与者可加人(工作区成员选择器, +// 排除已在群者)与退出(自删);群主额外可移出他人。调 G1 端点,403/409 按错误码人话化。 +// web 镜像页刻意不订会话 SSE(G-web 窄化纪律,见 liveEventTypes 排除 conversation.*)——参与者变化 +// 靠「动作后重拉 + 手动刷新按钮」感知,不把 conversation.* 事件引回 web。 +type MirrorParticipantItem = { user_id: string; nickname: string; role: "owner" | "member" }; +type MirrorParticipantsVM = { scope: "workspace" | "participants"; is_dm?: boolean; participants: MirrorParticipantItem[] }; +type MirrorRemoveResult = { removed_user_id: string; self_left: boolean; new_owner_user_id: string | null }; +type MirrorAddResult = { added: boolean; participants: MirrorParticipantsVM }; + +function bindConversationParticipantsPanel( + container: HTMLElement, + result: WebRouteReadyResult, + client: BrowserApiClient, + locale: WorkHubLocale, + signal: AbortSignal +) { + if (result.match.key !== "conversation") { + return; + } + const section = container.querySelector("[data-r18-conversation-participants]"); + const body = section?.querySelector("[data-r18-conversation-participants-body]"); + const conversationId = section?.getAttribute("data-r18-conversation-id") ?? ""; + if (!section || !body || !conversationId) { + return; + } + const zh = locale === "zh-CN"; + const participantsPath = `/api/conversations/${encodeURIComponent(conversationId)}/participants`; + let selfUserId = ""; + let roster: Array<{ id: string; nickname: string }> = []; + + // 端点错误码 → 双语人话(服务端消息只有中文,en 直接回退它会串语言,所以已知码全给双语;未知码回落通用)。 + const ACTION_ERROR_COPY: Record = { + conversation_add_target_required: ["请先选择要加入的成员。", "Pick a member to add first."], + conversation_participant_invalid: ["这个人不在工作区里,无法加入。", "That person isn't in the workspace and can't be added."], + conversation_dm_target_not_found: ["没有找到工作区里的这个人。", "That person wasn't found in the workspace."], + conversation_participant_cap: ["这个群的成员已经满了。", "This group is already full."], + conversation_not_group: ["主区是全员会话,不能单独增删成员。", "The main channel includes everyone — members can't be added or removed."], + conversation_dm_no_add: ["私聊只能是两个人,不能再加人。", "A DM is limited to two people."], + conversation_dm_no_remove: ["私聊不能移出或退出。", "You can't leave or remove members from a DM."], + conversation_remove_forbidden: ["只有群主可以移出其他成员。", "Only the group owner can remove other members."], + conversation_last_participant: ["你是最后一名成员,不能退出。", "You're the last member — you can't leave."], + conversation_participant_not_found: ["这个人已经不在群里了。", "That person is no longer in the group."], + conversation_not_found: ["没有找到这个会话。", "Conversation not found."] + }; + const humanize = (error: unknown): string => { + if (error instanceof WorkHubApiError) { + const pair = ACTION_ERROR_COPY[error.code]; + if (pair) { + return zh ? pair[0] : pair[1]; + } + } + return zh ? "操作失败,请稍后重试。" : "Action failed, please try again."; + }; + + const renderLoading = () => { + body.innerHTML = `

${escapeHtml(zh ? "正在加载参与者…" : "Loading participants…")}

`; + }; + const renderError = () => { + body.innerHTML = `

${escapeHtml( + zh ? "参与者没拉到,稍后重试。" : "Couldn't load participants — retry later." + )}

`; + body.querySelector("[data-r18-conversation-participants-retry]") + ?.addEventListener("click", () => void hydrate(), { signal }); + }; + + const applyVm = (vm: MirrorParticipantsVM) => { + if (vm.scope === "workspace") { + body.innerHTML = `

${escapeHtml( + zh + ? "这是主区会话,工作区全体成员都能看到,没有单独的参与者名单可管理。" + : "This is the main channel — visible to the whole workspace; there is no per-conversation roster to manage." + )}

`; + return; + } + if (vm.is_dm) { + const names = vm.participants.map((p) => p.nickname).join(zh ? "、" : ", "); + body.innerHTML = `

${escapeHtml(zh ? "私聊(双人)" : "Direct message (2 people)")}

+

${escapeHtml(names)}

+

${escapeHtml(zh ? "私聊固定两个人,不能加人或移出。" : "A DM is fixed at two people — members can't be added or removed.")}

`; + return; + } + renderGroup(vm); + }; + + const renderGroup = (vm: MirrorParticipantsVM) => { + const selfRole = vm.participants.find((p) => p.user_id === selfUserId)?.role; + const isOwner = selfRole === "owner"; + const rowsHtml = vm.participants + .map((p) => { + const isSelf = p.user_id === selfUserId; + const roleLabel = p.role === "owner" ? (zh ? "群主" : "Owner") : (zh ? "成员" : "Member"); + let actionHtml = ""; + if (isSelf) { + actionHtml = ``; + } else if (isOwner) { + actionHtml = ``; + } + return `
+
${escapeHtml(p.nickname)} ${escapeHtml(roleLabel)}${isSelf ? ` ${escapeHtml(zh ? "你" : "You")}` : ""}
+ ${actionHtml} +
`; + }) + .join(""); + const currentIds = new Set(vm.participants.map((p) => p.user_id)); + const addable = roster.filter((u) => !currentIds.has(u.id)); + const addHtml = addable.length + ? `
+ + +
` + : `

${escapeHtml( + zh ? "工作区里其余成员都已在群内。" : "Everyone else in the workspace is already in this group." + )}

`; + body.innerHTML = `
${rowsHtml}
+ +
${addHtml}
+ `; + + const status = body.querySelector("[data-r18-participant-status]"); + const setStatus = (text: string, tone: "saving" | "error") => { + if (!status) { + return; + } + status.hidden = false; + status.textContent = text; + status.setAttribute("data-r18-participant-status", tone); + }; + + const doRemove = async (targetId: string, isSelf: boolean) => { + setStatus(zh ? "处理中…" : "Working…", "saving"); + try { + const res = await client.request(`${participantsPath}/${encodeURIComponent(targetId)}`, { + method: "DELETE" + }); + if (signal.aborted) { + return; + } + if (res.self_left) { + // 自己退群后就不再是参与者,重拉会 404——不重拉,直接给「已退出」收尾说明。 + body.innerHTML = `

${escapeHtml(zh ? "你已退出该会话。" : "You've left this conversation.")}

+

${escapeHtml(zh ? "刷新页面后你将无法再看到它。" : "You won't be able to see it after refreshing.")}

`; + return; + } + void reload(); + } catch (error) { + if (signal.aborted) { + return; + } + setStatus(humanize(error), "error"); + } + void isSelf; + }; + + body.querySelector("[data-r18-participant-leave]") + ?.addEventListener("click", () => void doRemove(selfUserId, true), { signal }); + body.querySelectorAll("[data-r18-participant-remove]").forEach((btn) => { + const targetId = btn.getAttribute("data-r18-participant-remove") ?? ""; + btn.addEventListener("click", () => void doRemove(targetId, false), { signal }); + }); + body.querySelector("[data-r18-participant-refresh]") + ?.addEventListener("click", () => void reload(), { signal }); + body.querySelector("[data-r18-participant-add-form]")?.addEventListener( + "submit", + (event) => { + event.preventDefault(); + const select = body.querySelector("[data-r18-participant-add-select]"); + const userId = select?.value ?? ""; + if (!userId) { + setStatus(zh ? "请先选择要加入的成员。" : "Pick a member to add first.", "error"); + return; + } + void (async () => { + setStatus(zh ? "处理中…" : "Working…", "saving"); + try { + const res = await client.request(participantsPath, { + method: "POST", + body: JSON.stringify({ user_id: userId }) + }); + if (signal.aborted) { + return; + } + applyVm(res.participants); // 加人端点回刷新后的完整列表,直接采用(无需再 GET)。 + } catch (error) { + if (signal.aborted) { + return; + } + setStatus(humanize(error), "error"); + } + })(); + }, + { signal } + ); + }; + + const reload = async () => { + try { + const vm = await client.request(participantsPath); + if (signal.aborted) { + return; + } + applyVm(vm); + } catch (error) { + if (signal.aborted) { + return; + } + renderError(); + } + }; + + const hydrate = async () => { + renderLoading(); + try { + const [me, vm, users] = await Promise.all([ + client.me().catch(() => null), + client.request(participantsPath), + client.listUsers().then( + (value) => value.users.map((u) => ({ id: u.id, nickname: u.nickname })), + () => [] as Array<{ id: string; nickname: string }> + ) + ]); + if (signal.aborted) { + return; + } + selfUserId = me?.id ?? ""; + roster = users; + applyVm(vm); + } catch (error) { + if (signal.aborted) { + return; + } + renderError(); + } + }; + void hydrate(); +} + // R14 批 SEARCH(web-search-page,02-search-design.md §7):顶栏搜索页的客户端水合。SSR // (route-components renderSearchRouteComponent)只渲搜索框外壳 + 诚实的空/短词提示,四个结果分组卡 // 先隐藏、无数据。这里在 q ≥ 2 字符时拉 GET /api/search(默认四 scope、limit=10)后按固定 scope 顺序 diff --git a/packages/contracts/src/domain/conversation.ts b/packages/contracts/src/domain/conversation.ts index 2020e6186..a4171af64 100644 --- a/packages/contracts/src/domain/conversation.ts +++ b/packages/contracts/src/domain/conversation.ts @@ -698,7 +698,12 @@ export const CONVERSATION_PARTICIPANTS_LIST_CAP = 100; export const conversationParticipantsVmSchema = z .object({ scope: conversationParticipantsScopeSchema, - participants: z.array(conversationParticipantListItemVmSchema).max(CONVERSATION_PARTICIPANTS_LIST_CAP) + participants: z.array(conversationParticipantListItemVmSchema).max(CONVERSATION_PARTICIPANTS_LIST_CAP), + // R18 批 H1(web 会话镜像成员管理):DM 判别位——scope:"participants" 涵盖普通群与 DM 两种 collab, + // 光看 scope/参与者数分不出(DM 与 2 人小群都是 owner+member)。服务层从 dm_key 非空如实推导: + // 群加/移人可做(web 给管理动作),DM 是 2 人不变量不能增删(web 渲双人无动作),main 是全员语义 + // (scope:"workspace",本位省略)。additive/optional——桌面消费方不读它,仍按 scope + 服务端 409 兜底。 + is_dm: z.boolean().optional() }) .strict(); export type ConversationParticipantsVM = z.infer; diff --git a/packages/contracts/src/r14-chat.test.ts b/packages/contracts/src/r14-chat.test.ts index ed9590026..cc4f3a8ed 100644 --- a/packages/contracts/src/r14-chat.test.ts +++ b/packages/contracts/src/r14-chat.test.ts @@ -308,6 +308,18 @@ test("R15 conversation participants VM: main is scope=workspace + empty, collab/ }).success, true ); + // R18 批 H1:additive/optional is_dm 判别位——带上时接受(web 会话镜像据此区分群 vs DM)。 + assert.equal( + schema.safeParse({ + scope: "participants", + is_dm: true, + participants: [ + { user_id: userId, nickname: "阿曼", role: "owner" }, + { user_id: otherUserId, nickname: "小赵", role: "member" } + ] + }).success, + true + ); assert.equal(schema.safeParse({ scope: "team", participants: [] }).success, false); assert.equal( schema.safeParse({ scope: "participants", participants: [{ user_id: userId, nickname: "", role: "owner" }] }) diff --git a/packages/ui/src/gold-path/route-components.test.ts b/packages/ui/src/gold-path/route-components.test.ts index 6e80dc430..afebb9a0a 100644 --- a/packages/ui/src/gold-path/route-components.test.ts +++ b/packages/ui/src/gold-path/route-components.test.ts @@ -4113,6 +4113,36 @@ test("R15 web-mirror conversation component shows an honest empty state and pagi assert.equal(paged.html.includes('data-r15-conversation-target-seq="12"'), true); }); +test("R18 web-mirror conversation component renders a participants side-region skeleton for hydration", () => { + const zh = renderWebRouteComponent({ + key: "conversation", + conversation: { + conversationId: "c-9", + messages: [], + members: [], + isLatest: true, + refreshHref: "/conversations/c-9" + } + }, { locale: "zh-CN" }); + // SSR 只出加载态骨架 + hydration 锚点(真参与者与群管理动作由 browser.ts 拉 GET /participants 后注入)。 + assert.equal(zh.html.includes('data-r18-conversation-participants="true"'), true); + assert.equal(zh.html.includes('data-r18-conversation-id="c-9"'), true); + assert.equal(zh.html.includes('data-r18-conversation-participants-body="true"'), true); + assert.equal(zh.html.includes("正在加载参与者"), true); + + const en = renderWebRouteComponent({ + key: "conversation", + conversation: { + conversationId: "c-9", + messages: [], + members: [], + isLatest: true, + refreshHref: "/conversations/c-9" + } + }, { locale: "en-US" }); + assert.equal(en.html.includes("Loading participants"), true); +}); + test("R15 web-mirror conversation component renders system_event risk digest and tool_note plainly", () => { const messages: ConversationMessageVM[] = [ { diff --git a/packages/ui/src/gold-path/route-components.ts b/packages/ui/src/gold-path/route-components.ts index fb5c5a883..9a6cb8742 100644 --- a/packages/ui/src/gold-path/route-components.ts +++ b/packages/ui/src/gold-path/route-components.ts @@ -5224,9 +5224,17 @@ function renderMirrorMessage(message: ConversationMessageVM, ctx: MirrorRenderCt } function renderConversationRouteComponent(input: ConversationMirrorInput, locale: WorkHubLocale): WebRouteComponent { + const zh = locale === "zh-CN"; const memberNames = new Map(input.members.map((member) => [member.id, member.nickname] as const)); const ctx: MirrorRenderCtx = { locale, memberNames, targetSeq: input.targetSeq }; const base = `/conversations/${encodeURIComponent(input.conversationId)}`; + // R18 批 H1(web 会话镜像成员管理):SSR 出「参与者」侧区骨架——先渲加载态,由 apps/web/src/browser.ts + // 的 bindConversationParticipantsPanel 拉 GET /participants 后按 scope/is_dm 渲成员条 + 群管理动作 + // (加人/移出,DM/main 只渲说明无动作),照 settings AI 面板/项目指令面板的「SSR 骨架 + 客户端水合」纪律。 + const participantsSection = `
+

${escapeHtml(zh ? "参与者" : "Participants")}

+

${escapeHtml(zh ? "正在加载参与者…" : "Loading participants…")}

+
`; const stream = input.messages.length === 0 ? `

${escapeHtml(routeT(locale, "conversation.empty"))}

` : input.messages.map((message) => renderMirrorMessage(message, ctx)).join(""); @@ -5258,6 +5266,7 @@ function renderConversationRouteComponent(input: ConversationMirrorInput, locale ${escapeHtml(routeT(locale, "conversation.readonlyBanner"))}

${escapeHtml(routeT(locale, "conversation.readonlyHint"))}

+ ${participantsSection}
${olderLink}${refreshLink}
${stream}
${newerLink}${latestLink}
From 25d3daa0abce34ab0f6dc58396562092a582f4a1 Mon Sep 17 00:00:00 2001 From: mycyg Date: Fri, 17 Jul 2026 09:38:23 +0800 Subject: [PATCH 3/4] feat(web): #H1 workspace member management in /settings (admin console) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R18 批 H1(web 工作区成员管理):/settings 新增管理员可见的「成员」分区——SSR 出 加载态骨架(isAdmin 由外壳登录态门控,同 memory 团队技能 tab),browser.ts bindSettingsMembersPanel 水合: * 成员 roster(昵称/角色/加入时间)+ 移出/改角色(DELETE/PATCH /api/workspace/members/:userId,last-admin/self 保护按错误码人话化); * 邀请成员(POST /api/auth/invites,令牌一次性展示 + 复制)+ 未过期邀请清单 (GET /api/auth/invites?status=pending,昵称模式 404 → 诚实说明不渲表单)。 新增窄读端点 GET /api/workspace/members(管理员门控,同增删/改角色): - contracts: workspaceMemberSummaryVmSchema / listWorkspaceMembersResultVmSchema - db: memberships.listActiveWithNicknameByWorkspace(join users,取昵称+加入时间) - api: WorkspaceMemberService.listMembers + assertManager 抽取(读写共用门) - openapi: GET /api/workspace/members additive path - ui: renderSettingsRouteComponent 接 isAdmin,成员/邀请分区骨架 + 测试 - web: bindSettingsMembersPanel(roster/角色/移出 + 邀请 + 未过期清单 + 错误矩阵) Co-Authored-By: Claude Fable 5 --- apps/api/src/auth.test.ts | 11 + apps/api/src/openapi.ts | 43 +++ apps/api/src/routes/workspace-members.test.ts | 62 ++++ apps/api/src/routes/workspace-members.ts | 7 + .../src/services/workspace-members.test.ts | 43 +++ apps/api/src/services/workspace-members.ts | 55 ++- apps/web/src/browser.ts | 331 ++++++++++++++++++ apps/web/src/routes.ts | 2 +- packages/contracts/src/auth.ts | 21 ++ packages/db/src/repositories/memberships.ts | 42 ++- .../ui/src/gold-path/route-components.test.ts | 22 ++ packages/ui/src/gold-path/route-components.ts | 30 +- 12 files changed, 653 insertions(+), 16 deletions(-) diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts index 2ff1f00de..7f6675e1c 100644 --- a/apps/api/src/auth.test.ts +++ b/apps/api/src/auth.test.ts @@ -479,6 +479,17 @@ class MemoryMemberships implements WorkspaceMembershipRepository { return this.rows.filter((row) => row.workspaceId === workspaceId && row.deletedAt === null); } + async listActiveWithNicknameByWorkspace(workspaceId: string) { + return this.rows + .filter((row) => row.workspaceId === workspaceId && row.deletedAt === null) + .map((row) => ({ + userId: row.userId, + nickname: `user-${row.userId.slice(0, 8)}`, + role: row.role as MembershipRole, + joinedAt: row.createdAt + })); + } + async updateRole(id: string, role: MembershipRole, at: Date) { const row = this.rows.find((candidate) => candidate.id === id && candidate.deletedAt === null); if (!row) { diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 0d6f5fe49..6830cc5e2 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -5852,6 +5852,42 @@ const removeWorkspaceMemberResponses = { "500": conversationInternalResponse } } as const; +// R18 批 H1(成员清单):GET /api/workspace/members —— 管理员读 roster(昵称/角色/加入时间/是否本人)。 +const listWorkspaceMembersResponses = { + responses: { + "200": jsonDataResponse( + { + type: "object", + required: ["members"], + properties: { + members: { + type: "array", + items: { + type: "object", + required: ["user_id", "nickname", "role", "joined_at", "is_self"], + properties: { + user_id: uuidStringSchema, + nickname: { type: "string", minLength: 1, maxLength: 96 }, + role: workspaceMemberRoleSchema, + joined_at: dateTimeStringSchema, + is_self: { type: "boolean" } + }, + additionalProperties: false + } + } + }, + additionalProperties: false + }, + "Workspace member roster" + ).responses["200"], + "401": conversationAuthRequiredResponse, + "403": jsonErrorStatusResponse("403", "Only workspace admins/owners may list members", [ + "member_manage_forbidden", + "human_required" + ]).responses["403"], + "500": conversationInternalResponse + } +} as const; const updateWorkspaceMemberRoleResponses = { responses: { "200": jsonDataResponse( @@ -8253,6 +8289,13 @@ export function getOpenApiDocument() { ...removeConversationParticipantResponses } }, + "/api/workspace/members": { + get: { + tags: ["conversations"], + summary: "List the workspace member roster (admin/owner only): nickname, role, joined-at, is-self", + ...listWorkspaceMembersResponses + } + }, "/api/workspace/members/{userId}": { delete: { tags: ["conversations"], diff --git a/apps/api/src/routes/workspace-members.test.ts b/apps/api/src/routes/workspace-members.test.ts index e9665fba2..f04a385db 100644 --- a/apps/api/src/routes/workspace-members.test.ts +++ b/apps/api/src/routes/workspace-members.test.ts @@ -107,6 +107,7 @@ function memberService(overrides: Partial = {}): Workspa throw new Error(`${name} not expected`); }; return { + listMembers: reject("listMembers"), removeMember: reject("removeMember"), updateMemberRole: reject("updateMemberRole"), ...overrides @@ -135,6 +136,67 @@ function routeApp(runtimeSettings: Settings, members: WorkspaceMemberService) { return app; } +test("GET members requires authentication before reaching the service", async () => { + const runtimeSettings = settings(); + const app = routeApp( + runtimeSettings, + memberService({ + async listMembers() { + throw new Error("anonymous request must not reach the service"); + } + }) + ); + + const response = await app.request("/api/workspace/members"); + + assert.equal(response.status, 401); +}); + +test("GET members returns the roster from the service", async () => { + const runtimeSettings = settings(); + let seen: unknown; + const roster = { + members: [ + { user_id: adminUserId, nickname: "r17-admin", role: "owner" as const, joined_at: now.toISOString(), is_self: true }, + { user_id: targetUserId, nickname: "小赵", role: "member" as const, joined_at: now.toISOString(), is_self: false } + ] + }; + const app = routeApp( + runtimeSettings, + memberService({ + async listMembers(input) { + seen = input; + return roster; + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request("/api/workspace/members", { headers }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true, data: roster }); + assert.ok((seen as { actor: unknown }).actor, "the actor is forwarded to the service"); +}); + +test("GET members preserves the service's typed 403 for non-managers", async () => { + const runtimeSettings = settings(); + const app = routeApp( + runtimeSettings, + memberService({ + async listMembers() { + throw new WorkspaceMemberServiceError(403, "member_manage_forbidden", "只有工作区管理员可以管理成员。"); + } + }) + ); + const headers = { Cookie: await cookie(runtimeSettings) }; + + const response = await app.request("/api/workspace/members", { headers }); + + assert.equal(response.status, 403); + assert.equal(((await response.json()) as { error: { code: string } }).error.code, "member_manage_forbidden"); +}); + test("DELETE member requires authentication before reaching the service", async () => { const runtimeSettings = settings(); const app = routeApp( diff --git a/apps/api/src/routes/workspace-members.ts b/apps/api/src/routes/workspace-members.ts index 58e0eaaf0..82ca15d39 100644 --- a/apps/api/src/routes/workspace-members.ts +++ b/apps/api/src/routes/workspace-members.ts @@ -39,6 +39,13 @@ export function createWorkspaceMemberRoutes(deps: WorkspaceMemberRoutesDependenc const members = deps.members ?? getDefaultWorkspaceMemberService(); const requireCurrentUser = createCurrentUserMiddleware(authSource); + // R18 批 H1(成员清单):管理员读本工作区 roster(昵称/角色/加入时间/是否本人)。门控在服务层 + // assertManager 里做(非管理员 403),路由层只成形响应。供 web /settings 成员分区渲染。 + routes.get("/workspace/members", requireCurrentUser, async (c) => { + const data = await members.listMembers({ actor: c.var.actor }); + return c.json({ ok: true, data }); + }); + routes.delete("/workspace/members/:userId", requireCurrentUser, async (c) => { const targetUserId = requireTargetUserId(c.req.param("userId")); const data = await members.removeMember({ actor: c.var.actor, targetUserId }); diff --git a/apps/api/src/services/workspace-members.test.ts b/apps/api/src/services/workspace-members.test.ts index 1ee4e7497..0280048ca 100644 --- a/apps/api/src/services/workspace-members.test.ts +++ b/apps/api/src/services/workspace-members.test.ts @@ -45,6 +45,16 @@ function memberships(rows: WorkspaceMembershipRow[]): WorkspaceMembershipReposit async listActiveByWorkspace(ws) { return store.filter((row) => row.workspaceId === ws && row.deletedAt === null); }, + async listActiveWithNicknameByWorkspace(ws) { + return store + .filter((row) => row.workspaceId === ws && row.deletedAt === null) + .map((row) => ({ + userId: row.userId, + nickname: `user-${row.userId.slice(-4)}`, + role: row.role, + joinedAt: row.createdAt + })); + }, async softDelete(id, at) { const row = store.find((candidate) => candidate.id === id && candidate.deletedAt === null); if (!row) { @@ -228,3 +238,36 @@ test("updateMemberRole refuses changing your own role with 409", async () => { error instanceof WorkspaceMemberServiceError && error.status === 409 && error.code === "member_manage_self" ); }); + +test("listMembers returns the roster with roles, join times, and is_self, gated to managers", async () => { + const repo = memberships([ + membershipRow({ userId: adminUserId, role: "admin" }), + membershipRow({ userId: memberUserId, role: "member" }) + ]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + const result = await service.listMembers({ actor: actor() }); + + assert.equal(result.members.length, 2); + const self = result.members.find((m) => m.user_id === adminUserId); + const other = result.members.find((m) => m.user_id === memberUserId); + assert.equal(self?.is_self, true); + assert.equal(self?.role, "admin"); + assert.equal(self?.joined_at, now.toISOString()); + assert.equal(other?.is_self, false); + assert.equal(other?.role, "member"); +}); + +test("listMembers rejects a non-manager with 403", async () => { + const repo = memberships([ + membershipRow({ userId: adminUserId, role: "owner" }), + membershipRow({ userId: memberUserId, role: "member" }) + ]); + const service = createWorkspaceMemberService({ memberships: repo, now: () => now }); + + await assert.rejects( + () => service.listMembers({ actor: actor({ id: memberUserId, userId: memberUserId, isAdmin: false }) }), + (error: unknown) => + error instanceof WorkspaceMemberServiceError && error.status === 403 && error.code === "member_manage_forbidden" + ); +}); diff --git a/apps/api/src/services/workspace-members.ts b/apps/api/src/services/workspace-members.ts index 7f97ab0ef..50fae9673 100644 --- a/apps/api/src/services/workspace-members.ts +++ b/apps/api/src/services/workspace-members.ts @@ -31,7 +31,17 @@ export class WorkspaceMemberServiceError extends Error { const PRIVILEGED_ROLES: ReadonlySet = new Set(["admin", "owner"]); +export type WorkspaceMemberSummary = { + user_id: string; + nickname: string; + role: MembershipRole; + joined_at: string; + is_self: boolean; +}; + export type WorkspaceMemberService = { + // R18 批 H1(成员清单):某工作区 roster(昵称/角色/加入时间/是否本人)——管理员门控(同增删/改角色)。 + listMembers(input: { actor: AuthActor }): Promise<{ members: WorkspaceMemberSummary[] }>; removeMember(input: { actor: AuthActor; targetUserId: string }): Promise<{ removed_user_id: string }>; updateMemberRole(input: { actor: AuthActor; @@ -43,7 +53,11 @@ export type WorkspaceMemberService = { export type WorkspaceMemberServiceDependencies = { memberships: Pick< WorkspaceMembershipRepository, - "findActiveForUserWorkspace" | "listActiveByWorkspace" | "softDelete" | "updateRole" + | "findActiveForUserWorkspace" + | "listActiveByWorkspace" + | "listActiveWithNicknameByWorkspace" + | "softDelete" + | "updateRole" >; auditLogs?: Pick | undefined; now?: () => Date; @@ -65,15 +79,9 @@ export function createWorkspaceMemberService(deps: WorkspaceMemberServiceDepende const memberships = deps.memberships; // 权限闸:解析发起人在这个工作区的活跃成员行,判定其为 admin/owner(或 user 级 isAdmin)。非成员 → 403。 - async function assertManagerAndResolveTarget(input: { - actor: AuthActor; - targetUserId: string; - }): Promise<{ resolved: ResolvedActor; targetUserId: string; targetRole: MembershipRole; targetMembershipId: string }> { - const resolved = requireHumanActor(input.actor); - const targetUserId = input.targetUserId.trim().toLowerCase(); - if (!targetUserId) { - throw new WorkspaceMemberServiceError(400, "member_target_required", "请选择要管理的成员。"); - } + // 读(列成员)与写(移出/改角色)共用这一道门——成员分区连同 roster 都只对管理员开放。 + async function assertManager(actor: AuthActor): Promise { + const resolved = requireHumanActor(actor); const actingMembership = await memberships.findActiveForUserWorkspace(resolved.actorUserId, resolved.workspaceId); if (!actingMembership) { throw new WorkspaceMemberServiceError(403, "member_manage_forbidden", "只有工作区管理员可以管理成员。"); @@ -82,6 +90,18 @@ export function createWorkspaceMemberService(deps: WorkspaceMemberServiceDepende if (!isManager) { throw new WorkspaceMemberServiceError(403, "member_manage_forbidden", "只有工作区管理员可以管理成员。"); } + return resolved; + } + + async function assertManagerAndResolveTarget(input: { + actor: AuthActor; + targetUserId: string; + }): Promise<{ resolved: ResolvedActor; targetUserId: string; targetRole: MembershipRole; targetMembershipId: string }> { + const resolved = await assertManager(input.actor); + const targetUserId = input.targetUserId.trim().toLowerCase(); + if (!targetUserId) { + throw new WorkspaceMemberServiceError(400, "member_target_required", "请选择要管理的成员。"); + } // 不能对自己动手(移出=自锁、降级=自我夺权)——取窄,两条路径共用同一条守卫。 if (targetUserId === resolved.actorUserId.toLowerCase()) { throw new WorkspaceMemberServiceError(409, "member_manage_self", "不能对自己执行这个操作。"); @@ -105,6 +125,21 @@ export function createWorkspaceMemberService(deps: WorkspaceMemberServiceDepende } return { + async listMembers(input) { + const resolved = await assertManager(input.actor); + const rows = await memberships.listActiveWithNicknameByWorkspace(resolved.workspaceId); + const selfId = resolved.actorUserId.toLowerCase(); + return { + members: rows.map((row) => ({ + user_id: row.userId, + nickname: row.nickname, + role: row.role, + joined_at: row.joinedAt.toISOString(), + is_self: row.userId.toLowerCase() === selfId + })) + }; + }, + async removeMember(input) { const { resolved, targetUserId, targetRole, targetMembershipId } = await assertManagerAndResolveTarget(input); // 移出的目标若是特权成员,且它是最后一名特权成员 → 拒(否则工作区没人能再管理)。 diff --git a/apps/web/src/browser.ts b/apps/web/src/browser.ts index c7b41ced1..32a787d9c 100644 --- a/apps/web/src/browser.ts +++ b/apps/web/src/browser.ts @@ -2147,6 +2147,7 @@ function bindReadyRoute(result: WebRouteReadyResult, client: BrowserApiClient, l bindConversationParticipantsPanel(root, result, client, locale, signal); bindSearchRoutePanel(root, result, client, locale, signal); bindSettingsAiProfilePanel(root, result, client, locale, signal); + bindSettingsMembersPanel(root, result, client, locale, signal); bindSettingsMyProfilePanel(root, result, client, locale, signal); bindSettingsAvatarPanel(root, result, client, locale, signal); bindAvatarTiles(root, signal); @@ -2764,6 +2765,336 @@ function bindConversationParticipantsPanel( void hydrate(); } +// R18 批 H1(web 工作区成员管理):/settings 「成员」分区的客户端水合(仅管理员——SSR 已门控,非管理员 +// 无此骨架,这里 querySelector 落空即静默返回)。拉 GET /api/workspace/members 渲 roster(昵称/角色/加入 +// 时间)+ 移出/改角色(DELETE/PATCH /api/workspace/members/:userId),并拉 GET/POST /api/auth/invites 做 +// 邀请(令牌一次性展示 + 复制)与未过期邀请清单。调 G1 端点,403/409/last-admin 按错误码人话化。 +type SettingsMemberSummary = { + user_id: string; + nickname: string; + role: "member" | "admin" | "owner"; + joined_at: string; + is_self: boolean; +}; +type SettingsMembersVM = { members: SettingsMemberSummary[] }; +type SettingsPendingInvite = { invite_id: string; email: string; expires_at: string; created_at: string }; +type SettingsPendingInvitesVM = { invites: SettingsPendingInvite[] }; +type SettingsInviteCreateResult = { invite_id: string; token: string; email: string; expires_at: string }; + +function formatDayStamp(iso: string): string { + return /^\d{4}-\d{2}-\d{2}/u.test(iso) ? iso.slice(0, 10) : iso; +} + +function bindSettingsMembersPanel( + container: HTMLElement, + result: WebRouteReadyResult, + client: BrowserApiClient, + locale: WorkHubLocale, + signal: AbortSignal +) { + if (result.match.key !== "settings") { + return; + } + const zh = locale === "zh-CN"; + const ROLE_LABELS: Record = { + member: ["成员", "Member"], + admin: ["管理员", "Admin"], + owner: ["拥有者", "Owner"] + }; + const MEMBER_ERROR_COPY: Record = { + member_manage_self: ["不能对自己执行这个操作。", "You can't do that to yourself."], + member_manage_forbidden: ["只有工作区管理员可以管理成员。", "Only workspace admins can manage members."], + member_last_admin: ["不能移出或降级最后一名管理员。", "You can't remove or demote the last admin."], + member_not_found: ["没有找到这个成员。", "That member wasn't found."], + member_target_required: ["请选择要管理的成员。", "Pick a member to manage."] + }; + const humanizeMember = (error: unknown): string => { + if (error instanceof WorkHubApiError) { + const pair = MEMBER_ERROR_COPY[error.code]; + if (pair) { + return zh ? pair[0] : pair[1]; + } + } + return zh ? "操作失败,请稍后重试。" : "Action failed, please try again."; + }; + + bindSettingsMembersRoster(container, client, zh, signal, ROLE_LABELS, humanizeMember); + bindSettingsInvites(container, client, zh, signal); +} + +function bindSettingsMembersRoster( + container: HTMLElement, + client: BrowserApiClient, + zh: boolean, + signal: AbortSignal, + roleLabels: Record, + humanize: (error: unknown) => string +) { + const section = container.querySelector("[data-r18-settings-members]"); + const body = section?.querySelector("[data-r18-settings-members-body]"); + if (!section || !body) { + return; + } + const roles: Array = ["member", "admin", "owner"]; + + const renderError = () => { + body.innerHTML = `

${escapeHtml( + zh ? "成员没拉到,稍后重试。" : "Couldn't load members — retry later." + )}

`; + body.querySelector("[data-r18-settings-members-retry]") + ?.addEventListener("click", () => void hydrate(), { signal }); + }; + + const render = (vm: SettingsMembersVM) => { + const rowsHtml = vm.members + .map((member) => { + const roleLabel = roleLabels[member.role][zh ? 0 : 1]; + const joined = formatDayStamp(member.joined_at); + if (member.is_self) { + return `
+
${escapeHtml(member.nickname)} ${escapeHtml(zh ? "你" : "You")}
+
${escapeHtml(roleLabel)}${escapeHtml(zh ? `加入于 ${joined}` : `Joined ${joined}`)}
+
`; + } + const options = roles + .map((role) => ``) + .join(""); + return `
+
${escapeHtml(member.nickname)}
${escapeHtml(zh ? `加入于 ${joined}` : `Joined ${joined}`)}
+
+ + +
+
`; + }) + .join(""); + body.innerHTML = `
${rowsHtml}
+ `; + + const status = body.querySelector("[data-r18-settings-members-status]"); + const setStatus = (text: string, tone: "saving" | "error") => { + if (!status) { + return; + } + status.hidden = false; + status.textContent = text; + status.setAttribute("data-r18-settings-members-status", tone); + }; + + body.querySelectorAll("[data-r18-settings-member-remove]").forEach((btn) => { + const targetId = btn.getAttribute("data-r18-settings-member-remove") ?? ""; + btn.addEventListener( + "click", + () => { + void (async () => { + setStatus(zh ? "处理中…" : "Working…", "saving"); + try { + await client.request(`/api/workspace/members/${encodeURIComponent(targetId)}`, { method: "DELETE" }); + if (signal.aborted) { + return; + } + void hydrate(); + } catch (error) { + if (signal.aborted) { + return; + } + setStatus(humanize(error), "error"); + } + })(); + }, + { signal } + ); + }); + + body.querySelectorAll("[data-r18-settings-member-role-select]").forEach((select) => { + const targetId = select.getAttribute("data-r18-settings-member-role-select") ?? ""; + const previous = select.value; + select.addEventListener( + "change", + () => { + const nextRole = select.value; + void (async () => { + setStatus(zh ? "处理中…" : "Working…", "saving"); + try { + await client.request(`/api/workspace/members/${encodeURIComponent(targetId)}`, { + method: "PATCH", + body: JSON.stringify({ role: nextRole }) + }); + if (signal.aborted) { + return; + } + void hydrate(); + } catch (error) { + if (signal.aborted) { + return; + } + select.value = previous; // 改角色失败:回滚下拉,不留「看起来改成功了」的假象。 + setStatus(humanize(error), "error"); + } + })(); + }, + { signal } + ); + }); + }; + + const hydrate = async () => { + try { + const vm = await client.request("/api/workspace/members"); + if (signal.aborted) { + return; + } + render(vm); + } catch (error) { + if (signal.aborted) { + return; + } + renderError(); + } + }; + void hydrate(); +} + +function bindSettingsInvites(container: HTMLElement, client: BrowserApiClient, zh: boolean, signal: AbortSignal) { + const section = container.querySelector("[data-r18-settings-invites]"); + const body = section?.querySelector("[data-r18-settings-invites-body]"); + if (!section || !body) { + return; + } + + const renderDisabled = () => { + body.innerHTML = `

${escapeHtml( + zh + ? "邀请功能需要密码登录模式(当前工作区为昵称模式,无需邀请即可加入)。" + : "Invites require password login mode (this workspace uses nickname mode, where anyone can join without an invite)." + )}

`; + }; + const renderError = () => { + body.innerHTML = `

${escapeHtml( + zh ? "邀请没拉到,稍后重试。" : "Couldn't load invites — retry later." + )}

`; + body.querySelector("[data-r18-settings-invites-retry]") + ?.addEventListener("click", () => void hydrate(), { signal }); + }; + + const render = (invites: SettingsPendingInvite[]) => { + const listHtml = invites.length + ? `
${invites + .map( + (invite) => `
+
${escapeHtml(invite.email)}
+ ${escapeHtml(zh ? `${formatDayStamp(invite.expires_at)} 过期` : `expires ${formatDayStamp(invite.expires_at)}`)} +
` + ) + .join("")}
` + : `

${escapeHtml(zh ? "还没有未过期的邀请。" : "No pending invites yet.")}

`; + body.innerHTML = `
+ + +
+ + +

${escapeHtml(zh ? "未过期邀请" : "Pending invites")}

+ ${listHtml}`; + + const status = body.querySelector("[data-r18-settings-invite-status]"); + const tokenBox = body.querySelector("[data-r18-settings-invite-token]"); + const setStatus = (text: string, tone: "saving" | "error") => { + if (!status) { + return; + } + status.hidden = false; + status.textContent = text; + status.setAttribute("data-r18-settings-invite-status", tone); + }; + const showToken = (invite: SettingsInviteCreateResult) => { + if (!tokenBox) { + return; + } + tokenBox.hidden = false; + tokenBox.innerHTML = `

${escapeHtml(zh ? "邀请令牌(只显示这一次,请立即复制转交):" : "Invite token (shown once — copy and hand it off now):")}

+ ${escapeHtml(invite.token)} +
+

${escapeHtml(zh ? `发给 ${invite.email},${formatDayStamp(invite.expires_at)} 前有效。` : `Send to ${invite.email}; valid until ${formatDayStamp(invite.expires_at)}.`)}

`; + tokenBox.querySelector("[data-r18-settings-invite-copy]")?.addEventListener( + "click", + () => { + void navigator.clipboard?.writeText(invite.token).then( + () => setStatus(zh ? "已复制令牌。" : "Token copied.", "saving"), + () => setStatus(zh ? "复制失败,请手动选中复制。" : "Copy failed — select and copy manually.", "error") + ); + }, + { signal } + ); + }; + + body.querySelector("[data-r18-settings-invite-form]")?.addEventListener( + "submit", + (event) => { + event.preventDefault(); + const input = body.querySelector("[data-r18-settings-invite-email]"); + const email = input?.value.trim() ?? ""; + if (!email) { + setStatus(zh ? "请填写对方邮箱。" : "Enter an email first.", "error"); + return; + } + void (async () => { + setStatus(zh ? "生成中…" : "Creating…", "saving"); + try { + const created = await client.request("/api/auth/invites", { + method: "POST", + body: JSON.stringify({ email }) + }); + if (signal.aborted) { + return; + } + showToken(created); + if (input) { + input.value = ""; + } + void hydrate(); // 重拉未过期清单,纳入刚建的这条。 + } catch (error) { + if (signal.aborted) { + return; + } + if (error instanceof WorkHubApiError && error.status === 422) { + setStatus(zh ? "邮箱格式不对,检查后重试。" : "That email looks invalid — check and retry.", "error"); + return; + } + if (error instanceof WorkHubApiError && error.status === 404) { + setStatus(zh ? "邀请功能未启用(需密码登录模式)。" : "Invites aren't enabled (password mode required).", "error"); + return; + } + setStatus(zh ? "邀请没生成,请稍后重试。" : "Couldn't create the invite — try again later.", "error"); + } + })(); + }, + { signal } + ); + }; + + const hydrate = async () => { + try { + const vm = await client.request("/api/auth/invites?status=pending"); + if (signal.aborted) { + return; + } + render(vm.invites); + } catch (error) { + if (signal.aborted) { + return; + } + // 404 = 昵称模式下邀请功能未启用(诚实说明,不渲邀请表单);其它 = 取数错,给重试。 + if (error instanceof WorkHubApiError && error.status === 404) { + renderDisabled(); + return; + } + renderError(); + } + }; + void hydrate(); +} + // R14 批 SEARCH(web-search-page,02-search-design.md §7):顶栏搜索页的客户端水合。SSR // (route-components renderSearchRouteComponent)只渲搜索框外壳 + 诚实的空/短词提示,四个结果分组卡 // 先隐藏、无数据。这里在 q ≥ 2 字符时拉 GET /api/search(默认四 scope、limit=10)后按固定 scope 顺序 diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index a43e4c93e..90a547112 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -1108,7 +1108,7 @@ function routeComponentForSurface(surface: WebRouteSurface, locale: WorkHubLocal memory: { userMemories: surface.userMemories, teamSkills: surface.teamSkills, tab: surface.tab, isAdmin } }, { locale }); } - return renderWebRouteComponent({ key: "settings", settings: surface.settings }, { locale }); + return renderWebRouteComponent({ key: "settings", settings: surface.settings, isAdmin }, { locale }); } function routeComponentsForSurface(surface: WebRouteSurface, locale: WorkHubLocale, isAdmin: boolean): WebRouteComponentMap { diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 666d87826..b7b34b9ea 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -107,6 +107,27 @@ export const updateWorkspaceMemberRoleResultVmSchema = z .strict(); export type UpdateWorkspaceMemberRoleResultVM = z.infer; +// R18 批 H1(web 成员管理面板 · 成员清单):GET /api/workspace/members 的一行成员——昵称/角色/加入时间。 +// is_self 供客户端把自己那行去掉管理动作(服务端也会以 member_manage_self 兜底,不依赖客户端自觉)。 +// 只读窄端点,管理员门控(同 DELETE/PATCH),供 web /settings 成员分区渲染 roster。 +export const workspaceMemberSummaryVmSchema = z + .object({ + user_id: idSchema, + nickname: z.string().min(1).max(96), + role: workspaceMemberRoleSchema, + joined_at: isoDateTimeSchema, + is_self: z.boolean() + }) + .strict(); +export type WorkspaceMemberSummaryVM = z.infer; + +export const listWorkspaceMembersResultVmSchema = z + .object({ + members: z.array(workspaceMemberSummaryVmSchema) + }) + .strict(); +export type ListWorkspaceMembersResultVM = z.infer; + export const identifyResponseSchema = userSchema.pick({ id: true, nickname: true, diff --git a/packages/db/src/repositories/memberships.ts b/packages/db/src/repositories/memberships.ts index b1f5f0a0b..4cb3ef9cb 100644 --- a/packages/db/src/repositories/memberships.ts +++ b/packages/db/src/repositories/memberships.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, asc, eq, isNull, sql } from "drizzle-orm"; import type { WorkHubDb } from "../client.js"; -import { workspaceMemberships, workspaces } from "../schema/index.js"; +import { users, workspaceMemberships, workspaces } from "../schema/index.js"; // R2 多租户 epic Phase 1:工作区成员仓库。纯数据访问层,Phase 1 不接线(actor 仍走常量); // Phase 2 的 TenantResolver 用 resolveDefaultWorkspace / listForUser 从成员关系派生 actor 租户。 @@ -11,6 +11,15 @@ import { workspaceMemberships, workspaces } from "../schema/index.js"; export type WorkspaceMembershipRow = typeof workspaceMemberships.$inferSelect; export type MembershipRole = "member" | "admin" | "owner"; +// R18 批 H1(web 成员管理面板 · 成员清单):一行含昵称与加入时间的成员——供 GET /api/workspace/members +// 渲染 roster(昵称在 users 表、角色/加入时间在 memberships 表,一趟 join 取全)。 +export type WorkspaceMemberWithNickname = { + userId: string; + nickname: string; + role: MembershipRole; + joinedAt: Date; +}; + // actor 租户 = 默认成员行的工作区 + 其 org(workspaces.org_id 派生,本 epic 无独立 org_memberships)。 export type ResolvedTenant = { workspaceId: string; @@ -41,6 +50,9 @@ export type WorkspaceMembershipRepository = { /** R17 批 G1(#15 成员移出):某工作区全部 active 成员行——供移出/角色变更前统计特权成员数(防移出/ * 降级最后一名 admin/owner),成员规模对内部团队远低于任何实际上限,无需分页。 */ listActiveByWorkspace: (workspaceId: string) => Promise; + /** R18 批 H1(成员清单):某工作区全部 active 成员 + 昵称(join users),按昵称字典序——供 web 成员分区 + * roster。成员规模对内部团队远低于任何上限,无需分页。 */ + listActiveWithNicknameByWorkspace: (workspaceId: string) => Promise; /** R17 批 G1(#15 角色变更):更新一条 active 成员行的角色(幂等:改到同值也照常前进 updated_at)。 */ updateRole: (id: string, role: MembershipRole, at: Date) => Promise; }; @@ -137,6 +149,32 @@ export function createWorkspaceMembershipRepository(db: WorkHubDb): WorkspaceMem ); }, + async listActiveWithNicknameByWorkspace(workspaceId) { + const rows = await db + .select({ + userId: workspaceMemberships.userId, + nickname: users.nickname, + role: workspaceMemberships.role, + joinedAt: workspaceMemberships.createdAt + }) + .from(workspaceMemberships) + .innerJoin(users, eq(users.id, workspaceMemberships.userId)) + .where( + and( + eq(workspaceMemberships.workspaceId, workspaceId), + isNull(workspaceMemberships.deletedAt), + isNull(users.deletedAt) + ) + ) + .orderBy(asc(sql`lower(${users.nickname})`), asc(users.id)); + return rows.map((row) => ({ + userId: row.userId, + nickname: row.nickname, + role: row.role as MembershipRole, + joinedAt: row.joinedAt + })); + }, + async updateRole(id, role, at) { const rows = await db .update(workspaceMemberships) diff --git a/packages/ui/src/gold-path/route-components.test.ts b/packages/ui/src/gold-path/route-components.test.ts index afebb9a0a..eb901a01b 100644 --- a/packages/ui/src/gold-path/route-components.test.ts +++ b/packages/ui/src/gold-path/route-components.test.ts @@ -2717,6 +2717,28 @@ test("R4.11 Settings route component uses a typed Settings Page VM without leaki assertNoMainWindowBoundaryLeak(settings.html); }); +test("R18-H1 settings members section is admin-gated: SSR skeleton only when isAdmin", () => { + const vm = surfaceVm(); + const settingsVm = vm.page_vms.settings; + assert.ok(settingsVm); + + const asMember = renderWebRouteComponent({ key: "settings", settings: settingsVm, isAdmin: false }, { locale: "zh-CN" }); + assert.equal(asMember.html.includes('data-r18-settings-members="true"'), false); + assert.equal(asMember.html.includes('data-r18-settings-invites="true"'), false); + + const asAdmin = renderWebRouteComponent({ key: "settings", settings: settingsVm, isAdmin: true }, { locale: "zh-CN" }); + // SSR 只出加载态骨架 + hydration 锚点(真 roster / 邀请由 browser.ts 拉端点后注入)。 + assert.equal(asAdmin.html.includes('data-r18-settings-members="true"'), true); + assert.equal(asAdmin.html.includes('data-r18-settings-members-body="true"'), true); + assert.equal(asAdmin.html.includes('data-r18-settings-invites="true"'), true); + assert.equal(asAdmin.html.includes('data-r18-settings-invites-body="true"'), true); + assert.equal(asAdmin.html.includes("正在加载成员"), true); + + const asAdminEn = renderWebRouteComponent({ key: "settings", settings: settingsVm, isAdmin: true }, { locale: "en-US" }); + assert.equal(asAdminEn.html.includes("Loading members"), true); + assert.equal(asAdminEn.html.includes("Invite members"), true); +}); + // R13 批 P3(功能审查 B4):web /settings 的「AI 助手」区块——default_mode 与 dispatch_policy 两个 // 真表单(当前值由 apps/web/src/browser.ts 水合后解禁,SSR 必须是 disabled——R10-P1-7 的竞态收口纪律), // 其余 AI 项走既有 data-requires-desktop 提示模式,不再静默留白。 diff --git a/packages/ui/src/gold-path/route-components.ts b/packages/ui/src/gold-path/route-components.ts index 9a6cb8742..71d34914d 100644 --- a/packages/ui/src/gold-path/route-components.ts +++ b/packages/ui/src/gold-path/route-components.ts @@ -5418,7 +5418,30 @@ function renderSettingsMyProfileCard(locale: WorkHubLocale): string { `; } -function renderSettingsRouteComponent(vm: SettingsPageVM, locale: WorkHubLocale): WebRouteComponent { +// R18 批 H1(web 工作区成员管理):/settings 的「成员」分区——仅管理员可见(isAdmin 由外壳登录态给, +// 同 memory 路由的团队技能 tab 编辑权来源)。SSR 只出加载态骨架,apps/web/src/browser.ts 的 +// bindSettingsMembersPanel 拉 GET /api/workspace/members 渲 roster(昵称/角色/加入时间)+ 移出/改角色 +// (PATCH/DELETE /api/workspace/members/:userId),并拉 GET/POST /api/auth/invites 做邀请与未过期邀请 +// 清单。非管理员不渲此分区(服务端各端点也再门控一遍,不靠前端自觉)。 +function renderSettingsMembersSection(locale: WorkHubLocale): string { + const zh = locale === "zh-CN"; + return `

${escapeHtml(zh ? "团队成员(管理员)" : "Team members (admins)")}

+
+
+

${escapeHtml(zh ? "成员" : "Members")}

+

${escapeHtml(zh ? "管理工作区成员的角色与去留。不能对自己动手,也不能移出或降级最后一名管理员。" : "Manage members' roles and removal. You can't act on yourself or remove/demote the last admin.")}

+

${escapeHtml(zh ? "正在加载成员…" : "Loading members…")}

+
+
+

${escapeHtml(zh ? "邀请成员" : "Invite members")}

+

${escapeHtml(zh ? "生成一次性邀请链接令牌,自行转交给对方。令牌只显示一次,服务端不再可取回。" : "Generate a one-time invite token to hand off out-of-band. The token is shown once and can't be retrieved again.")}

+

${escapeHtml(zh ? "正在加载邀请…" : "Loading invites…")}

+
+
`; +} + +function renderSettingsRouteComponent(vm: SettingsPageVM, locale: WorkHubLocale, isAdmin = false): WebRouteComponent { + const membersSection = isAdmin ? renderSettingsMembersSection(locale) : ""; const reactComponent = createSettingsReactRouteComponent(vm, locale); const reactAttrs = dataAttrs(reactRouteComponentMarkerAttrs(reactComponent)); const props = reactComponent.props; @@ -5475,6 +5498,7 @@ function renderSettingsRouteComponent(vm: SettingsPageVM, locale: WorkHubLocale) `).join("") : `

${escapeHtml(locale === "zh-CN" ? "还没有常驻规则。" : "No standing rules yet.")}

`} ` : ""} + ${membersSection}

${escapeHtml(locale === "zh-CN" ? "系统诊断(管理员关注;普通成员只读参考)" : "System diagnostics (for admins; read-only reference for members)")}

@@ -5538,7 +5562,7 @@ export type WebRouteComponentInput = | { key: "knowledge"; evidence: EvidenceBubble; sourceRef?: string | undefined; scopeLanding?: boolean | undefined; projects?: ProjectListVM | undefined } | { key: "search"; q?: string | undefined } | { key: "skills"; skills: TeamSkillsPageVM } - | { key: "settings"; settings: SettingsPageVM } + | { key: "settings"; settings: SettingsPageVM; isAdmin?: boolean | undefined } | { key: "memory"; memory: { userMemories: UserMemoryManagementPageVM; teamSkills: TeamSkillManagementPageVM; tab: "profile" | "skills"; isAdmin: boolean } }; export function renderWebRouteComponent( @@ -5591,7 +5615,7 @@ export function renderWebRouteComponent( case "skills": return renderTeamSkillsRouteComponent(input.skills, locale); case "settings": - return renderSettingsRouteComponent(input.settings, locale); + return renderSettingsRouteComponent(input.settings, locale, input.isAdmin ?? false); case "memory": return renderMemoryRouteComponent(input.memory, locale); } From dd2831cdaeaae661d781d675b8cb3ee335f1a0f6 Mon Sep 17 00:00:00 2001 From: mycyg Date: Fri, 17 Jul 2026 09:41:14 +0800 Subject: [PATCH 4/4] feat(web): #H1 project-home member summary (manager mirror) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R18 批 H1(项目设置成员分区镜像):项目主页(管理者视角)新增「成员」摘要小块—— SSR 出加载态骨架,browser.ts bindProjectHomeMembersPanel 拉 /api/users(主区全员 计数)与 /api/projects/:id/conversations(协同会话数=collab 非 DM + 主区会话 id)后 填数并链到主区会话镜像 /conversations/:id。轻量镜像,不复制桌面工作台的成员全功能; 取数失败静默降级 + 重试。 - ui: renderProjectHomeRouteComponent 增成员摘要骨架 + 测试 - web: bindProjectHomeMembersPanel(复用既有只读端点,无新增端点) Co-Authored-By: Claude Fable 5 --- apps/web/src/browser.ts | 79 +++++++++++++++++++ .../ui/src/gold-path/route-components.test.ts | 5 ++ packages/ui/src/gold-path/route-components.ts | 8 ++ 3 files changed, 92 insertions(+) diff --git a/apps/web/src/browser.ts b/apps/web/src/browser.ts index 32a787d9c..1992a7e48 100644 --- a/apps/web/src/browser.ts +++ b/apps/web/src/browser.ts @@ -2144,6 +2144,7 @@ function bindReadyRoute(result: WebRouteReadyResult, client: BrowserApiClient, l bindNotificationMutePanel(root, result, client, locale, signal); bindProjectHomePlansPanel(root, result, client, locale, signal); bindProjectHomeInstructionsPanel(root, result, client, locale, signal); + bindProjectHomeMembersPanel(root, result, client, locale, signal); bindConversationParticipantsPanel(root, result, client, locale, signal); bindSearchRoutePanel(root, result, client, locale, signal); bindSettingsAiProfilePanel(root, result, client, locale, signal); @@ -2523,6 +2524,84 @@ function bindProjectHomeInstructionsPanel( void hydrate(); } +// R18 批 H1(项目设置成员分区镜像):项目主页「成员」摘要小块的客户端水合。SSR 只出加载态骨架;这里拉 +// /api/users(主区全员计数,任何成员可读)与 /api/projects/:id/conversations(协同会话数 + 主区会话 id) +// 后填数并链到主区会话镜像 /conversations/:id。轻量镜像——不复制桌面工作台的成员全功能。取数失败静默降级。 +type ProjectHomeConversationLite = { id: string; kind: "main" | "collab"; is_dm?: boolean }; +type ProjectHomeConversationsVM = { conversations: ProjectHomeConversationLite[]; capped: boolean }; + +function bindProjectHomeMembersPanel( + container: HTMLElement, + result: WebRouteReadyResult, + client: BrowserApiClient, + locale: WorkHubLocale, + signal: AbortSignal +) { + if (result.match.key !== "project-home") { + return; + } + const section = container.querySelector("[data-r18-project-home-members]"); + const body = section?.querySelector("[data-r18-project-home-members-body]"); + const projectId = section?.getAttribute("data-r18-project-home-members-project") ?? ""; + if (!section || !body || !projectId) { + return; + } + const zh = locale === "zh-CN"; + + const hydrate = async () => { + try { + const [memberCount, convos] = await Promise.all([ + client.listUsers().then( + (value) => value.users.length, + (): number | null => null + ), + client.request(`/api/projects/${encodeURIComponent(projectId)}/conversations`).then( + (value) => value, + (): ProjectHomeConversationsVM | null => null + ) + ]); + if (signal.aborted) { + return; + } + // 协同会话数 = collab 且非 DM(DM 是 is_dm=true 的 collab,不算团队协同会话)。 + const collabCount = convos ? convos.conversations.filter((c) => c.kind === "collab" && c.is_dm !== true).length : null; + const capped = convos?.capped ?? false; + const mainId = convos?.conversations.find((c) => c.kind === "main")?.id ?? ""; + + const pills: string[] = []; + if (memberCount !== null) { + pills.push(`${escapeHtml(zh ? `主区 ${memberCount} 名成员` : `${memberCount} in main channel`)}`); + } + if (collabCount !== null) { + const label = zh ? `${collabCount}${capped ? "+" : ""} 个协同会话` : `${collabCount}${capped ? "+" : ""} collab conversation${collabCount === 1 && !capped ? "" : "s"}`; + pills.push(`${escapeHtml(label)}`); + } + if (!pills.length) { + body.innerHTML = `

${escapeHtml( + zh ? "成员摘要没拉到,稍后重试。" : "Couldn't load the member summary — retry later." + )}

`; + body.querySelector("[data-r18-project-home-members-retry]") + ?.addEventListener("click", () => void hydrate(), { signal }); + return; + } + const link = mainId + ? `${escapeHtml(zh ? "查看主区会话镜像 →" : "Open the main-channel conversation mirror →")}` + : ""; + body.innerHTML = `
${pills.join("")}
+

${escapeHtml(zh ? "主区会话对工作区全员可见;协同会话与成员的完整管理在桌面工作台。" : "The main channel is visible to the whole workspace; full member and collab management lives in the desktop workbench.")}

+ ${link}`; + } catch { + if (signal.aborted) { + return; + } + body.innerHTML = `

${escapeHtml( + zh ? "成员摘要没拉到,稍后重试。" : "Couldn't load the member summary — retry later." + )}

`; + } + }; + void hydrate(); +} + // R18 批 H1(web 会话镜像成员管理):只读会话镜像的「参与者」侧区水合。SSR(route-components // renderConversationRouteComponent)只出加载态骨架;这里拉 GET /participants 后按 scope/is_dm 渲: // * main(scope:"workspace")→ 全员会话说明,无动作; diff --git a/packages/ui/src/gold-path/route-components.test.ts b/packages/ui/src/gold-path/route-components.test.ts index eb901a01b..2809ef774 100644 --- a/packages/ui/src/gold-path/route-components.test.ts +++ b/packages/ui/src/gold-path/route-components.test.ts @@ -2157,6 +2157,11 @@ test("B-R9.6 project home rows show the army progress pill only for armied work // NAMING pass:pill 文案带「子任务」限定词,新人不再猜 2/4 是什么。 assert.equal(projectHome.html.includes("军团子任务 2/4"), true); assert.equal(projectHome.html.includes('data-r9-project-army-pill="94000000-0000-4000-8000-000000000002"'), false); + // R18-H1:项目主页「成员」摘要小块——SSR 骨架 + hydration 锚点(真计数/主区会话链接由 browser.ts 注入)。 + assert.equal(projectHome.html.includes('data-r18-project-home-members="true"'), true); + assert.equal(projectHome.html.includes('data-r18-project-home-members-project="93000000-0000-4000-8000-000000000001"'), true); + assert.equal(projectHome.html.includes('data-r18-project-home-members-body="true"'), true); + assert.equal(projectHome.html.includes("正在加载成员摘要"), true); assertNoMainWindowBoundaryLeak(projectHome.html); }); diff --git a/packages/ui/src/gold-path/route-components.ts b/packages/ui/src/gold-path/route-components.ts index 71d34914d..7847dd7fc 100644 --- a/packages/ui/src/gold-path/route-components.ts +++ b/packages/ui/src/gold-path/route-components.ts @@ -4442,6 +4442,13 @@ function renderProjectHomeRouteComponent(vm: ProjectHomePageVM, locale: WorkHubL
`; // G4 #24(项目自定义指令 web 入口):SSR 出骨架,browser.ts 拉 GET /api/projects/:id/instructions // 后按权限渲可编辑 textarea(失焦 PATCH 保存)或只读说明(403)。与桌面 W4b1 同一错误矩阵。 + // R18 批 H1(项目设置成员分区镜像):项目主页(管理者视角)「成员」摘要小块——SSR 出骨架,browser.ts + // bindProjectHomeMembersPanel 拉 /api/users(主区全员计数)与 /api/projects/:id/conversations(协同 + // 会话数 + 主区会话 id)后填数并链到主区会话镜像。轻量镜像,不复制桌面工作台的成员全功能。 + const membersSection = `
+

${escapeHtml(zh ? "成员" : "Members")}

+

${escapeHtml(zh ? "正在加载成员摘要…" : "Loading member summary…")}

+
`; const instructionsSection = `

${escapeHtml(zh ? "自定义指令" : "Custom instructions")}

${escapeHtml( @@ -4502,6 +4509,7 @@ function renderProjectHomeRouteComponent(vm: ProjectHomePageVM, locale: WorkHubL

${githubSection} ${plansSection} + ${membersSection} ${instructionsSection} ${escapeHtml(routeT(locale, "projectHome.back"))} `