forked from bai/curriculum-project-hub
feat(database): 后台成员组(MemberGroup)管理与嵌套解析
This commit is contained in:
@@ -74,8 +74,10 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
|------|------|
|
||||
| `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` |
|
||||
| `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 |
|
||||
| `routes/filelibRoutes.ts` | 文件库 树/授权/搜索 API |
|
||||
| `routes/filelibRoutes.ts` | 文件库 树/授权 API |
|
||||
| `routes/fileRoutes.ts` | 文件库 文件内容/导出 API |
|
||||
| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) |
|
||||
| `routes/adminPanels.ts` | dashboard「用户管理」(org 成员)/「Group 管理」(MemberGroup 嵌套树)面板 |
|
||||
| `routes/libraryPage.ts` | `/database/library` 文件库浏览页 |
|
||||
| `filelib/` | 文件库领域层(见下) |
|
||||
|
||||
@@ -98,7 +100,9 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
|
||||
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
|
||||
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) |
|
||||
| `filelib/groupResolver.ts` | 契约 C2 port + Team 过渡实现 |
|
||||
| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) |
|
||||
| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) |
|
||||
| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) |
|
||||
| `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) |
|
||||
| `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 |
|
||||
| `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) |
|
||||
@@ -107,7 +111,8 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
环境变量:
|
||||
|
||||
- `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`)
|
||||
- `HUB_GROUP_SERVICE_URL` — Group 团队服务地址(C2);未配置时读 hub Team(扁平)
|
||||
- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub
|
||||
MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team)
|
||||
|
||||
> ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**,
|
||||
> 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的
|
||||
|
||||
@@ -32,9 +32,16 @@ export const FILE_LIB_AUDIT_ACTIONS = {
|
||||
fileConflictDetected: "file.conflict_detected",
|
||||
exportRun: "export.run",
|
||||
adminForceAdjust: "admin.force_adjust",
|
||||
// ADR-0028:成员组内置进 hub,组动作在本地审计(契约 C3 §6.3 原委托外部 Group 服务)。
|
||||
groupCreate: "group.create",
|
||||
groupUpdate: "group.update",
|
||||
groupDelete: "group.delete",
|
||||
groupRestore: "group.restore",
|
||||
groupMemberAdd: "group.member_add",
|
||||
groupMemberRemove: "group.member_remove",
|
||||
} as const;
|
||||
|
||||
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job";
|
||||
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job" | "group";
|
||||
|
||||
export interface FileLibAuditEntry {
|
||||
readonly action: string;
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* GroupResolver port(契约 C2)。
|
||||
*
|
||||
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
|
||||
* Group 系统(需求系统二:全局、无限嵌套)由别的团队交付;调用方只依赖此
|
||||
* port,真身到位后替换实现,不换调用点。
|
||||
* ADR-0028 起,默认实现是 in-hub 的 MemberGroup 闭包读取器
|
||||
* (`createMemberGroupResolver`,见 memberGroupResolver.ts);
|
||||
* `HUB_GROUP_SERVICE_URL` 配置后切外部 HTTP 实现(groupResolverHttp.ts)。
|
||||
* 调用方只依赖此 port,不换调用点。
|
||||
*/
|
||||
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
@@ -13,9 +15,11 @@ export interface GroupResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
|
||||
* 需求 3.2 的祖先递归语义在嵌套 Group 落地前无从谈起;此实现保证权限引擎
|
||||
* 的 Group 通路今天就是真的,而不是 mock。
|
||||
* @deprecated ADR-0028:成员组已内置为 in-hub MemberGroup,默认 resolver 改为
|
||||
* `createMemberGroupResolver`。此扁平 Team 过渡实现不再接线,保留仅为历史参照
|
||||
* (以及潜在的迁移对照),新代码不要使用。
|
||||
*
|
||||
* 旧过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
|
||||
*/
|
||||
export function createTeamGroupResolver(
|
||||
prisma: PrismaClient,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 默认 GroupResolver 实现:读 in-hub MemberGroup 闭包(ADR-0028)。
|
||||
*
|
||||
* resolveMemberGroupIds(user) = 用户**活跃直接组 ∪ 这些组的活跃祖先**,去重
|
||||
* (闭包 depth0 自身行令每个直接组也是自己的祖先)。等价于:授权放在组 G 上,
|
||||
* G 及其全部子孙的成员都命中(需求 3.2 权限沿树向下 → 解析沿树向上收集)。
|
||||
*
|
||||
* 实时、不缓存(契约 D4/G4):成员变更在下一次受保护请求即可见。
|
||||
* MemberGroup 全局(无 organizationId),解析不做 org scope。
|
||||
* 两条 Prisma 查询,不用裸 SQL(与 treeService 风格一致)。
|
||||
*/
|
||||
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { GroupResolver } from "./groupResolver.js";
|
||||
|
||||
export function createMemberGroupResolver(prisma: PrismaClient): GroupResolver {
|
||||
return {
|
||||
async resolveMemberGroupIds(userId) {
|
||||
// 1) 活跃直接组:成员未撤销 + 组未归档。
|
||||
const direct = await prisma.memberGroupMembership.findMany({
|
||||
where: { userId, revokedAt: null, group: { archivedAt: null } },
|
||||
select: { groupId: true },
|
||||
});
|
||||
if (direct.length === 0) return [];
|
||||
const directIds = direct.map((m) => m.groupId);
|
||||
|
||||
// 2) 经闭包取活跃祖先(含 depth0 自身);祖先组须未归档。
|
||||
const ancestors = await prisma.memberGroupClosure.findMany({
|
||||
where: { descendantId: { in: directIds }, ancestor: { archivedAt: null } },
|
||||
select: { ancestorId: true },
|
||||
});
|
||||
return [...new Set(ancestors.map((a) => a.ancestorId))];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* 成员组(MemberGroup)管理服务(ADR-0028)。
|
||||
*
|
||||
* 语义锚定:
|
||||
* - 全局主体:MemberGroup 无 organizationId,不做租户 scope;审计行挂 silo org
|
||||
* (deps.organizationId)—— MemberGroup 无 orgId,审计沿用文件库 sink(决策4)。
|
||||
* - 权限门禁:创建/删除/成员增删仅网站管理员(silo org OWNER/ADMIN);
|
||||
* 搜索(授权选择器)不限管理员 —— 选组授权是 Manage 持有者的能力(决策2)。
|
||||
* - 软删除:archivedAt 打标;删组级联软删整棵子树(闭包 ancestorId=G);
|
||||
* 闭包/成员行保留,list/解析按 archivedAt 过滤(决策4)。
|
||||
* - 闭包维护:仅 create —— 插 (G,G,0),再对 parent P 插
|
||||
* (a.ancestorId, G, a.depth+1) for a in closure where descendantId=P。
|
||||
* v1 不支持 reparent(决策5)。
|
||||
*
|
||||
* 与 hub Team 不同:成员是全局用户,不要求 org membership;按 userId 或
|
||||
* User.feishuOpenId(全局 @unique)解析。
|
||||
*/
|
||||
|
||||
import type { PrismaClient, Prisma } from "@prisma/client";
|
||||
import { FileLibError } from "./model.js";
|
||||
import type { FileLibActor } from "./treeService.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
|
||||
export interface MemberGroupServiceDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
/** silo org id —— 仅用于审计归属(MemberGroup 全局无 orgId,决策4)。 */
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
export interface MemberGroupDto {
|
||||
readonly id: string;
|
||||
readonly parentId: string | null;
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
/** 到根的边数(根 = 0);由闭包行数推导。 */
|
||||
readonly depth: number;
|
||||
readonly memberCount: number;
|
||||
/** 软删标记(决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。 */
|
||||
readonly archivedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface MemberGroupMemberDto {
|
||||
readonly userId: string;
|
||||
readonly displayName: string;
|
||||
readonly feishuOpenId: string;
|
||||
readonly avatarUrl: string | null;
|
||||
/** 加入本组时间(membership.createdAt),用于成员表排序/展示。 */
|
||||
readonly joinedAt: Date;
|
||||
}
|
||||
|
||||
/** 成员选择器候选(加成员弹窗搜索用)。 */
|
||||
export interface UserSearchResult {
|
||||
readonly userId: string;
|
||||
readonly displayName: string;
|
||||
readonly feishuOpenId: string;
|
||||
readonly avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface MemberGroupSearchResult {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
/** 祖先链(根在前,自身在末),用 " / " 连接;无祖先时即自身名。 */
|
||||
readonly breadcrumb: string;
|
||||
}
|
||||
|
||||
export interface CreateMemberGroupInput {
|
||||
readonly name: string;
|
||||
readonly description?: string | undefined;
|
||||
readonly parentId?: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface AddMemberInput {
|
||||
readonly userId?: string | undefined;
|
||||
readonly feishuOpenId?: string | undefined;
|
||||
}
|
||||
|
||||
/** 改名/改描述(决策6)。字段缺省 = 不动;description 传空串 = 清空。 */
|
||||
export interface UpdateMemberGroupInput {
|
||||
readonly name?: string | undefined;
|
||||
readonly description?: string | undefined;
|
||||
}
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/* ---------------------------------------------------------------- 内部工具 */
|
||||
|
||||
/** 管理门禁:非网站管理员一律 403(决策2)。 */
|
||||
function requireAdmin(actor: FileLibActor): void {
|
||||
if (!actor.isWebsiteAdmin) {
|
||||
throw new FileLibError(403, "forbidden", "group management requires website administrator");
|
||||
}
|
||||
}
|
||||
|
||||
/** 组名校验(Group 域与节点域分开:轻量 trim/非空/长度,不套用节点命名规则)。 */
|
||||
function normalizeGroupName(raw: string): string {
|
||||
const name = raw.trim();
|
||||
if (name === "") throw new FileLibError(400, "invalid_request", "group name must not be empty");
|
||||
if (name.length > 100) throw new FileLibError(400, "invalid_request", "group name too long (max 100)");
|
||||
return name;
|
||||
}
|
||||
|
||||
async function requireActiveGroup(
|
||||
client: PrismaClient | Tx,
|
||||
groupId: string,
|
||||
): Promise<{ readonly id: string; readonly name: string }> {
|
||||
const group = await client.memberGroup.findFirst({
|
||||
where: { id: groupId, archivedAt: null },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (group === null) throw new FileLibError(404, "group_not_found", "group not found");
|
||||
return group;
|
||||
}
|
||||
|
||||
/** 全局用户解析:按 userId,或 User.feishuOpenId(全局 @unique)。不要求 org 成员。 */
|
||||
async function resolveUser(
|
||||
tx: Tx,
|
||||
input: AddMemberInput,
|
||||
): Promise<{
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly feishuOpenId: string;
|
||||
readonly avatarUrl: string | null;
|
||||
}> {
|
||||
const select = { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } as const;
|
||||
if (input.userId !== undefined && input.userId !== "") {
|
||||
const user = await tx.user.findUnique({ where: { id: input.userId }, select });
|
||||
if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.userId}`);
|
||||
return user;
|
||||
}
|
||||
if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") {
|
||||
const user = await tx.user.findUnique({
|
||||
where: { feishuOpenId: input.feishuOpenId },
|
||||
select,
|
||||
});
|
||||
if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.feishuOpenId}`);
|
||||
return user;
|
||||
}
|
||||
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 公共操作 */
|
||||
|
||||
/**
|
||||
* 创建成员组(建根 / 建子)。仅网站管理员。事务内维护闭包。
|
||||
* parentId 给定时校验其活跃存在;闭包:插自身 depth0 + 继承 parent 的祖先。
|
||||
*/
|
||||
export async function createMemberGroup(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
input: CreateMemberGroupInput,
|
||||
): Promise<MemberGroupDto> {
|
||||
requireAdmin(actor);
|
||||
const name = normalizeGroupName(input.name);
|
||||
const description = input.description?.trim() || null;
|
||||
const parentId = input.parentId ?? null;
|
||||
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
let parentClosure: { ancestorId: string; depth: number }[] = [];
|
||||
if (parentId !== null) {
|
||||
const parent = await tx.memberGroup.findFirst({
|
||||
where: { id: parentId, archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (parent === null) throw new FileLibError(404, "group_not_found", "parent group not found");
|
||||
parentClosure = await tx.memberGroupClosure.findMany({
|
||||
where: { descendantId: parentId },
|
||||
select: { ancestorId: true, depth: true },
|
||||
});
|
||||
}
|
||||
|
||||
const group = await tx.memberGroup.create({
|
||||
data: { name, parentId, ...(description !== null ? { description } : {}) },
|
||||
select: { id: true, parentId: true, name: true, description: true },
|
||||
});
|
||||
|
||||
// 闭包维护:自身 depth0,再继承 parent 的每个祖先(depth+1)。
|
||||
await tx.memberGroupClosure.create({
|
||||
data: { ancestorId: group.id, descendantId: group.id, depth: 0 },
|
||||
});
|
||||
if (parentClosure.length > 0) {
|
||||
await tx.memberGroupClosure.createMany({
|
||||
data: parentClosure.map((a) => ({
|
||||
ancestorId: a.ancestorId,
|
||||
descendantId: group.id,
|
||||
depth: a.depth + 1,
|
||||
})),
|
||||
});
|
||||
}
|
||||
// parent 的闭包行数 = parent.depth + 1 = 新组 depth(闭包不变量)。
|
||||
const depth = parentClosure.length;
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupCreate,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: { name, parentId },
|
||||
});
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
parentId: group.parentId,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
depth,
|
||||
memberCount: 0,
|
||||
archivedAt: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 改名 / 改描述(决策6)。仅网站管理员。**不动 parentId** —— reparent 仍属 v1
|
||||
* 范围外(决策5),闭包无需维护。字段缺省即不动;description 传 "" 清空。
|
||||
*/
|
||||
export async function updateMemberGroup(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
groupId: string,
|
||||
input: UpdateMemberGroupInput,
|
||||
): Promise<MemberGroupDto> {
|
||||
requireAdmin(actor);
|
||||
if (input.name === undefined && input.description === undefined) {
|
||||
throw new FileLibError(400, "invalid_request", "name or description is required");
|
||||
}
|
||||
const name = input.name === undefined ? undefined : normalizeGroupName(input.name);
|
||||
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
await requireActiveGroup(tx, groupId);
|
||||
const group = await tx.memberGroup.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(input.description !== undefined
|
||||
? { description: input.description.trim() || null }
|
||||
: {}),
|
||||
},
|
||||
select: { id: true, parentId: true, name: true, description: true },
|
||||
});
|
||||
|
||||
// depth 由闭包行数推导(与 listMemberGroups 同一不变量);update 不改闭包。
|
||||
const closureCount = await tx.memberGroupClosure.count({ where: { descendantId: groupId } });
|
||||
const memberCount = await tx.memberGroupMembership.count({
|
||||
where: { groupId, revokedAt: null },
|
||||
});
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupUpdate,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(input.description !== undefined ? { description: group.description } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
parentId: group.parentId,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
depth: closureCount - 1,
|
||||
memberCount,
|
||||
archivedAt: null, // requireActiveGroup 已保证是活跃组
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除成员组:级联软删整棵子树(闭包 ancestorId=G 的全部活跃 descendant)。
|
||||
* 闭包/成员行保留;list/解析按 archivedAt 过滤,整支立即停止贡献权限。
|
||||
*/
|
||||
export async function deleteMemberGroup(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
groupId: string,
|
||||
): Promise<{ readonly archivedCount: number }> {
|
||||
requireAdmin(actor);
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const group = await requireActiveGroup(tx, groupId);
|
||||
const subtree = await tx.memberGroupClosure.findMany({
|
||||
where: { ancestorId: groupId },
|
||||
select: { descendantId: true },
|
||||
});
|
||||
const ids = subtree.map((r) => r.descendantId);
|
||||
const now = new Date();
|
||||
const result = await tx.memberGroup.updateMany({
|
||||
where: { id: { in: ids }, archivedAt: null },
|
||||
data: { archivedAt: now },
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupDelete,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: { name: group.name, archivedCount: result.count },
|
||||
});
|
||||
return { archivedCount: result.count };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复(取消归档)。仅网站管理员。**与删除不对称**(决策7):
|
||||
* - 删除级联整棵子树;恢复只恢复「该组 + 其全部已归档祖先」,**不动子树**。
|
||||
* - 恢复祖先链是必须的:活跃组的祖先必须活跃,否则该组在树上无路径、
|
||||
* depth 推导(闭包行数)与"祖先必活跃"的前提脱节。
|
||||
* - 子树保持归档、仍可见(带标记),由管理员逐个决定是否恢复 —— 避免一次
|
||||
* 恢复意外把整支历史组全部重新授权。
|
||||
* 恢复即刻恢复该组贡献的权限(实时解析,不缓存)。
|
||||
*/
|
||||
export async function restoreMemberGroup(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
groupId: string,
|
||||
): Promise<{ readonly restoredCount: number }> {
|
||||
requireAdmin(actor);
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const group = await tx.memberGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
select: { id: true, name: true, archivedAt: true },
|
||||
});
|
||||
if (group === null) throw new FileLibError(404, "group_not_found", "group not found");
|
||||
if (group.archivedAt === null) {
|
||||
throw new FileLibError(409, "not_archived", "group is not archived");
|
||||
}
|
||||
|
||||
// 自身 + 祖先(闭包 descendantId=G 含 depth0 自身),只挑已归档的解标。
|
||||
const chain = await tx.memberGroupClosure.findMany({
|
||||
where: { descendantId: groupId },
|
||||
select: { ancestorId: true },
|
||||
});
|
||||
const ids = chain.map((r) => r.ancestorId);
|
||||
const result = await tx.memberGroup.updateMany({
|
||||
where: { id: { in: ids }, archivedAt: { not: null } },
|
||||
data: { archivedAt: null },
|
||||
});
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupRestore,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: { name: group.name, restoredCount: result.count },
|
||||
});
|
||||
return { restoredCount: result.count };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 组扁平列表(前端自行按 parentId/depth 拼树);仅网站管理员。
|
||||
* includeArchived=true 时连已归档组一并返回(带 archivedAt 标记),供后台展示/恢复;
|
||||
* 默认只返回活跃组 —— 权限相关的调用方一律走默认。
|
||||
*/
|
||||
export async function listMemberGroups(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
includeArchived = false,
|
||||
): Promise<readonly MemberGroupDto[]> {
|
||||
requireAdmin(actor);
|
||||
const groups = await deps.prisma.memberGroup.findMany({
|
||||
where: includeArchived ? {} : { archivedAt: null },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, parentId: true, name: true, description: true, archivedAt: true },
|
||||
});
|
||||
if (groups.length === 0) return [];
|
||||
const ids = groups.map((g) => g.id);
|
||||
|
||||
// depth:每个组的闭包行数(自身 + 祖先)- 1。级联软删保证活跃组的祖先必活跃。
|
||||
const closure = await deps.prisma.memberGroupClosure.findMany({
|
||||
where: { descendantId: { in: ids } },
|
||||
select: { descendantId: true },
|
||||
});
|
||||
const closureCount = new Map<string, number>();
|
||||
for (const row of closure) {
|
||||
closureCount.set(row.descendantId, (closureCount.get(row.descendantId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const counts = await deps.prisma.memberGroupMembership.groupBy({
|
||||
by: ["groupId"],
|
||||
where: { groupId: { in: ids }, revokedAt: null },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const countByGroup = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
||||
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
parentId: g.parentId,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
depth: (closureCount.get(g.id) ?? 1) - 1,
|
||||
memberCount: countByGroup.get(g.id) ?? 0,
|
||||
archivedAt: g.archivedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 组成员列表(仅网站管理员)。**已归档组也可读**(决策7):软删是打标,成员行仍在,
|
||||
* 后台需要看得见「这个组曾经有谁」。写操作(add/remove)仍要求活跃组 —— 可读不可改。
|
||||
*/
|
||||
export async function listMembers(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
groupId: string,
|
||||
): Promise<readonly MemberGroupMemberDto[]> {
|
||||
requireAdmin(actor);
|
||||
const exists = await deps.prisma.memberGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (exists === null) throw new FileLibError(404, "group_not_found", "group not found");
|
||||
const rows = await deps.prisma.memberGroupMembership.findMany({
|
||||
where: { groupId, revokedAt: null },
|
||||
select: {
|
||||
createdAt: true,
|
||||
user: { select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
userId: r.user.id,
|
||||
displayName: r.user.displayName,
|
||||
feishuOpenId: r.user.feishuOpenId,
|
||||
avatarUrl: r.user.avatarUrl,
|
||||
joinedAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 加成员(userId 或 feishuOpenId 解析);已是活跃成员 → 409。仅网站管理员。 */
|
||||
export async function addMember(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
groupId: string,
|
||||
input: AddMemberInput,
|
||||
): Promise<MemberGroupMemberDto> {
|
||||
requireAdmin(actor);
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const group = await requireActiveGroup(tx, groupId);
|
||||
const user = await resolveUser(tx, input);
|
||||
const existing = await tx.memberGroupMembership.findFirst({
|
||||
where: { groupId: group.id, userId: user.id, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) {
|
||||
throw new FileLibError(409, "already_member", "user is already a member of this group");
|
||||
}
|
||||
const created = await tx.memberGroupMembership.create({
|
||||
data: { groupId: group.id, userId: user.id },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupMemberAdd,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: { userId: user.id },
|
||||
});
|
||||
return {
|
||||
userId: user.id,
|
||||
displayName: user.displayName,
|
||||
feishuOpenId: user.feishuOpenId,
|
||||
avatarUrl: user.avatarUrl,
|
||||
joinedAt: created.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 移成员(软删 revokedAt);不在组 → 404。仅网站管理员。 */
|
||||
export async function removeMember(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
requireAdmin(actor);
|
||||
await deps.prisma.$transaction(async (tx) => {
|
||||
const group = await requireActiveGroup(tx, groupId);
|
||||
const membership = await tx.memberGroupMembership.findFirst({
|
||||
where: { groupId: group.id, userId, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (membership === null) throw new FileLibError(404, "member_not_found", "group member not found");
|
||||
await tx.memberGroupMembership.update({
|
||||
where: { id: membership.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupMemberRemove,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: { userId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 成员选择器:按显示名/openId 搜全局用户。**仅网站管理员**(与加成员同权,决策2)
|
||||
* —— 加成员本就能指定任意全局用户(resolveUser 不要求 org 成员),故此端点不扩大
|
||||
* 已有能力面,只是把"盲敲 id"变成"搜索选择"。
|
||||
* excludeGroupId 给定时,过滤掉该组的活跃成员(避免选中必然 409 的人)。
|
||||
*/
|
||||
export async function searchUsers(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
q: string,
|
||||
excludeGroupId?: string,
|
||||
limit = 20,
|
||||
): Promise<readonly UserSearchResult[]> {
|
||||
requireAdmin(actor);
|
||||
const keyword = q.trim();
|
||||
|
||||
let excludeIds: string[] = [];
|
||||
if (excludeGroupId !== undefined && excludeGroupId !== "") {
|
||||
const rows = await deps.prisma.memberGroupMembership.findMany({
|
||||
where: { groupId: excludeGroupId, revokedAt: null },
|
||||
select: { userId: true },
|
||||
});
|
||||
excludeIds = rows.map((r) => r.userId);
|
||||
}
|
||||
|
||||
const users = await deps.prisma.user.findMany({
|
||||
where: {
|
||||
...(excludeIds.length > 0 ? { id: { notIn: excludeIds } } : {}),
|
||||
...(keyword === ""
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ displayName: { contains: keyword, mode: "insensitive" as const } },
|
||||
{ feishuOpenId: { contains: keyword, mode: "insensitive" as const } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { displayName: "asc" },
|
||||
select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true },
|
||||
});
|
||||
|
||||
return users.map((u) => ({
|
||||
userId: u.id,
|
||||
displayName: u.displayName,
|
||||
feishuOpenId: u.feishuOpenId,
|
||||
avatarUrl: u.avatarUrl,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权选择器搜索(契约 C2 /groups/search)。**不限管理员**(决策2)。
|
||||
* 活跃组按名过滤,breadcrumb 由活跃祖先链按 depth 排序拼成。
|
||||
*/
|
||||
export async function searchMemberGroups(
|
||||
deps: MemberGroupServiceDeps,
|
||||
q: string,
|
||||
limit = 20,
|
||||
): Promise<readonly MemberGroupSearchResult[]> {
|
||||
const keyword = q.trim();
|
||||
const groups = await deps.prisma.memberGroup.findMany({
|
||||
where: {
|
||||
archivedAt: null,
|
||||
...(keyword === "" ? {} : { name: { contains: keyword, mode: "insensitive" as const } }),
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (groups.length === 0) return [];
|
||||
const ids = groups.map((g) => g.id);
|
||||
|
||||
// 祖先链(仅活跃祖先);depth 越大越靠根。
|
||||
const closure = await deps.prisma.memberGroupClosure.findMany({
|
||||
where: { descendantId: { in: ids }, ancestor: { archivedAt: null } },
|
||||
select: { descendantId: true, ancestorId: true, depth: true },
|
||||
});
|
||||
const ancestorIds = [...new Set(closure.map((c) => c.ancestorId))];
|
||||
const names = await deps.prisma.memberGroup.findMany({
|
||||
where: { id: { in: ancestorIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const nameById = new Map(names.map((n) => [n.id, n.name]));
|
||||
const chainByGroup = new Map<string, { ancestorId: string; depth: number }[]>();
|
||||
for (const row of closure) {
|
||||
const arr = chainByGroup.get(row.descendantId) ?? [];
|
||||
arr.push({ ancestorId: row.ancestorId, depth: row.depth });
|
||||
chainByGroup.set(row.descendantId, arr);
|
||||
}
|
||||
|
||||
return groups.map((g) => {
|
||||
const chain = (chainByGroup.get(g.id) ?? []).slice().sort((a, b) => b.depth - a.depth);
|
||||
const breadcrumb = chain
|
||||
.map((c) => nameById.get(c.ancestorId) ?? "")
|
||||
.filter((s) => s !== "")
|
||||
.join(" / ");
|
||||
return { id: g.id, name: g.name, breadcrumb: breadcrumb || g.name };
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* 管理员后台「用户管理」「Group 管理」面板(复用 hub 已有 org 管理 API)。
|
||||
* 管理员后台「用户管理」「Group 管理」面板。
|
||||
*
|
||||
* 用户管理 = org 成员(/api/org/:orgSlug/members);
|
||||
* Group 管理 = Team(当前 Group 的过渡实现,/api/org/:orgSlug/teams),
|
||||
* 真 Group 系统落地后此面板改接新 API 即可,文件库授权侧不动。
|
||||
* 用户管理 = org 成员(/api/org/:orgSlug/members)。
|
||||
* Group 管理 = **MemberGroup**(全局可无限嵌套,ADR-0028),走 /database/api/groups/*。
|
||||
* 已从旧的扁平 hub Team 迁过来 —— Team 无父子字段,建不出子组。此面板与
|
||||
* filelib-web 的 GroupAdmin.svelte 消费同一套 API,语义一致。
|
||||
* 交互:嵌套树(展开/折叠)+ 右键菜单(建子组/建根组/重命名/删除)。
|
||||
*/
|
||||
|
||||
function apiBase(orgSlug: string): string {
|
||||
@@ -105,35 +107,110 @@ export function renderUsersPanel(orgSlug: string): string {
|
||||
|
||||
/* ---------------------------------------------------------------- Group 管理 */
|
||||
|
||||
export function renderGroupsPanel(orgSlug: string): string {
|
||||
/**
|
||||
* MemberGroup 嵌套树管理(ADR-0028)。无 orgSlug 参数 —— MemberGroup 是全局主体,
|
||||
* 不归属任何 Organization,端点也不带 org 段。
|
||||
*/
|
||||
/** 内联 SVG 图标表(24x24 stroke 风格,与 dashboard 侧栏一致)。 */
|
||||
const GROUP_ICONS: Record<string, string> = {
|
||||
// Group 节点 = 人的集合。**不用文件夹图标** —— Group 不是目录,
|
||||
// 与文件库的 FOLDER/PROJECT 是两套体系,图标上也不应混淆。两人剪影。
|
||||
group: "M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19M10 11.5a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM20 19v-1.5a3.5 3.5 0 0 0-2.6-3.38M15.4 5.22a3.25 3.25 0 0 1 0 6.06",
|
||||
users: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
|
||||
user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",
|
||||
plus: "M12 5v14M5 12h14",
|
||||
pencil: "M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3Z",
|
||||
trash: "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14",
|
||||
search: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z",
|
||||
chevron: "m9 18 6-6-6-6",
|
||||
layers: "m12 2 9 5-9 5-9-5 9-5Zm9 11-9 5-9-5m18 5-9 5-9-5",
|
||||
clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-14v6l4 2",
|
||||
minus: "M5 12h14",
|
||||
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
|
||||
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6",
|
||||
restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5",
|
||||
};
|
||||
|
||||
/** `icon("users", 16)` → 内联 svg 串。 */
|
||||
function icon(name: keyof typeof GROUP_ICONS | string, size = 16): string {
|
||||
const d = GROUP_ICONS[name] ?? "";
|
||||
return `<svg style="width:${size}px;height:${size}px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
|
||||
}
|
||||
|
||||
export function renderGroupsPanel(): string {
|
||||
return `
|
||||
<div id="groups-root" style="display:flex;gap:14px;align-items:flex-start">
|
||||
<div style="width:340px;flex-shrink:0">
|
||||
<div class="panel" style="margin-bottom:14px">
|
||||
<div class="section-title">新建 Group</div>
|
||||
<div class="form-row"><label class="form-label">标识(slug)</label><input id="g-slug" class="input" placeholder="physics-dept"/></div>
|
||||
<div class="form-row"><label class="form-label">名称</label><input id="g-name" class="input" placeholder="物理教研组"/></div>
|
||||
<div class="form-row"><label class="form-label">描述(可选)</label><input id="g-desc" class="input" placeholder="一句话说明"/></div>
|
||||
<div style="text-align:right"><button id="g-create" class="btn btn-primary">创建</button></div>
|
||||
<div id="groups-root" style="display:flex;gap:14px;align-items:stretch;height:100%;min-height:0">
|
||||
<!-- 左:组树 -->
|
||||
<div class="panel" style="width:326px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;padding:14px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
|
||||
<span style="display:flex;color:var(--accent)">${icon("layers", 17)}</span>
|
||||
<div class="section-title" style="margin:0;flex:1">Group 树</div>
|
||||
<button id="g-new-root" class="btn" style="font-size:11.5px;padding:4px 9px;display:inline-flex;align-items:center;gap:4px">
|
||||
${icon("plus", 13)} 根组
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="section-title">Group 列表</div>
|
||||
<div id="g-list"></div>
|
||||
<div style="position:relative;margin-bottom:8px">
|
||||
<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">${icon("search", 13)}</span>
|
||||
<input id="g-filter" class="input" placeholder="过滤组名…" style="padding-left:28px;font-size:12.5px"/>
|
||||
</div>
|
||||
<label class="switch" style="margin-bottom:9px;font-size:11.5px;color:var(--text-3)">
|
||||
<input type="checkbox" id="g-show-archived"/>
|
||||
<span></span>
|
||||
显示已删除的组
|
||||
</label>
|
||||
<div id="g-tree" style="flex:1;overflow-y:auto;margin:0 -6px;min-height:0"></div>
|
||||
<div id="g-tree-foot" class="section-note" style="border-top:1px solid var(--border-soft);margin-top:8px;padding-top:8px"></div>
|
||||
</div>
|
||||
<div class="panel" style="flex:1;min-height:200px">
|
||||
<div id="g-detail" class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>
|
||||
|
||||
<!-- 右:成员表 -->
|
||||
<div class="panel" style="flex:1;min-width:0;display:flex;flex-direction:column;min-height:0;padding:0">
|
||||
<div id="g-detail" style="display:flex;flex-direction:column;min-height:0;flex:1">
|
||||
<div class="quiet" style="margin:auto;padding:28px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px">
|
||||
<span style="color:var(--border);display:flex">${icon("users", 40)}</span>
|
||||
从左侧选择一个 Group 查看成员
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="g-menu" class="hidden" style="position:fixed;z-index:60;min-width:184px;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-pop);padding:5px;font-size:13px"></div>
|
||||
<script>
|
||||
(function () {
|
||||
const BASE = "${apiBase(orgSlug)}";
|
||||
const listEl = document.getElementById("g-list");
|
||||
const treeEl = document.getElementById("g-tree");
|
||||
const detailEl = document.getElementById("g-detail");
|
||||
const menuEl = document.getElementById("g-menu");
|
||||
const esc = (s) => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
let selected = null;
|
||||
|
||||
const ICONS = ${JSON.stringify(GROUP_ICONS)};
|
||||
function ico(name, size) {
|
||||
return '<svg style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex-shrink:0" viewBox="0 0 24 24"' +
|
||||
' fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<path d="' + (ICONS[name] || "") + '"/></svg>';
|
||||
}
|
||||
/** 头像:有 avatarUrl 用图,否则首字母色块。 */
|
||||
function avatar(m, size) {
|
||||
const s = size || 28;
|
||||
if (m.avatarUrl) {
|
||||
return '<img src="' + esc(m.avatarUrl) + '" alt="" style="width:' + s + 'px;height:' + s +
|
||||
'px;border-radius:50%;object-fit:cover;flex-shrink:0"/>';
|
||||
}
|
||||
const ch = esc((m.displayName || m.userId || "?").slice(0, 1).toUpperCase());
|
||||
return '<span style="width:' + s + 'px;height:' + s + 'px;border-radius:50%;flex-shrink:0;background:var(--accent);' +
|
||||
'color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:' + Math.round(s * 0.42) +
|
||||
'px;font-weight:600">' + ch + "</span>";
|
||||
}
|
||||
function fmtDate(iso) {
|
||||
try { return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" }); }
|
||||
catch { return String(iso || ""); }
|
||||
}
|
||||
|
||||
let groups = []; // 后端返回的活跃组扁平列表
|
||||
let selected = null; // { id, name }
|
||||
const collapsed = new Set(); // 折叠的组 id(默认全展开)
|
||||
let filterText = ""; // 组名过滤(命中项的祖先链一并保留)
|
||||
let showArchived = false; // 是否列出已归档(软删)的组
|
||||
|
||||
async function req(path, opts = {}) {
|
||||
const res = await fetch(BASE + path, {
|
||||
const res = await fetch("/database/api/groups" + path, {
|
||||
credentials: "same-origin",
|
||||
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
||||
method: opts.method ?? "GET",
|
||||
@@ -145,83 +222,526 @@ export function renderGroupsPanel(orgSlug: string): string {
|
||||
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
|
||||
return data;
|
||||
}
|
||||
async function loadList() {
|
||||
const { teams } = await req("/teams");
|
||||
listEl.innerHTML = teams.length === 0
|
||||
? '<div class="quiet" style="text-align:center;padding:12px">暂无 Group</div>'
|
||||
: teams.map((t) =>
|
||||
'<div class="g-team" data-id="' + esc(t.id) + '" data-name="' + esc(t.name) + '" style="display:flex;align-items:center;gap:8px;padding:8px 6px;border-radius:8px;cursor:pointer">' +
|
||||
'<span style="flex:1"><b>' + esc(t.name) + '</b> <span class="file-meta">' + esc(t.slug) + "</span></span>" +
|
||||
'<button class="link-danger" data-archive="' + esc(t.id) + '" style="font-size:11px">归档</button>' +
|
||||
"</div>").join("");
|
||||
listEl.querySelectorAll(".g-team").forEach((el) => {
|
||||
|
||||
/* ---------------- 树渲染 ---------------- */
|
||||
|
||||
/** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
|
||||
function orderedVisible() {
|
||||
const byParent = new Map();
|
||||
const byId = new Map();
|
||||
for (const g of groups) {
|
||||
byId.set(g.id, g);
|
||||
const arr = byParent.get(g.parentId) || [];
|
||||
arr.push(g);
|
||||
byParent.set(g.parentId, arr);
|
||||
}
|
||||
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
|
||||
|
||||
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
|
||||
let keep = null;
|
||||
const q = filterText.trim().toLowerCase();
|
||||
if (q !== "") {
|
||||
keep = new Set();
|
||||
for (const g of groups) {
|
||||
if (!g.name.toLowerCase().includes(q)) continue;
|
||||
let cur = g;
|
||||
while (cur) { keep.add(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : null; }
|
||||
}
|
||||
}
|
||||
|
||||
const out = [];
|
||||
(function walk(parentId) {
|
||||
for (const g of byParent.get(parentId) || []) {
|
||||
if (keep !== null && !keep.has(g.id)) continue;
|
||||
const kids = (byParent.get(g.id) || []).filter((k) => keep === null || keep.has(k.id));
|
||||
out.push({ g, hasKids: kids.length > 0, hit: q === "" || g.name.toLowerCase().includes(q) });
|
||||
// 过滤态下强制展开(否则命中项被折叠祖先藏住)。
|
||||
if (keep !== null || !collapsed.has(g.id)) walk(g.id);
|
||||
}
|
||||
})(null);
|
||||
// 兜底:父不在活跃列表的孤儿(级联软删理论上不产生)也列出,避免"看不见"。
|
||||
const seen = new Set(out.map((r) => r.g.id));
|
||||
for (const g of groups) {
|
||||
if (seen.has(g.id)) continue;
|
||||
if (keep !== null && !keep.has(g.id)) continue;
|
||||
out.push({ g, hasKids: false, hit: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
const footEl = document.getElementById("g-tree-foot");
|
||||
if (groups.length === 0) {
|
||||
treeEl.innerHTML = '<div class="quiet" style="text-align:center;padding:22px 12px;display:flex;flex-direction:column;align-items:center;gap:8px">' +
|
||||
'<span style="color:var(--border);display:flex">' + ico("layers", 30) + "</span>" +
|
||||
"暂无成员组 · 点上方「根组」开始</div>";
|
||||
footEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
const rows = orderedVisible();
|
||||
if (rows.length === 0) {
|
||||
treeEl.innerHTML = '<div class="quiet" style="text-align:center;padding:22px 12px">无匹配的组</div>';
|
||||
} else {
|
||||
treeEl.innerHTML = rows.map(({ g, hasKids, hit }) => {
|
||||
const isSel = selected && selected.id === g.id;
|
||||
const caret = hasKids
|
||||
? '<span data-caret="' + esc(g.id) + '" style="display:inline-flex;width:15px;justify-content:center;cursor:pointer;' +
|
||||
'color:var(--text-3);transition:transform .12s;' +
|
||||
(collapsed.has(g.id) && filterText.trim() === "" ? "" : "transform:rotate(90deg)") + '">' +
|
||||
ico("chevron", 13) + "</span>"
|
||||
: '<span style="display:inline-block;width:15px"></span>';
|
||||
const arch = !!g.archivedAt;
|
||||
return '<div class="g-node" data-id="' + esc(g.id) + '" data-name="' + esc(g.name) + '"' +
|
||||
' data-archived="' + (arch ? "1" : "") + '"' +
|
||||
' style="display:flex;align-items:center;gap:6px;padding:6px 8px 6px ' + (8 + g.depth * 15) + 'px;' +
|
||||
'border-radius:8px;cursor:pointer;user-select:none;' + (isSel ? "background:var(--selected)" : "") +
|
||||
(hit ? "" : ";opacity:.5") + '">' +
|
||||
caret +
|
||||
'<span style="display:flex;color:' + (arch ? "var(--text-3)" : (isSel ? "var(--accent)" : "var(--text-3)")) + '">' +
|
||||
ico(arch ? "archive" : "group", 15) + "</span>" +
|
||||
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' +
|
||||
(arch ? ";color:var(--text-3);text-decoration:line-through" : "") + '">' + esc(g.name) + "</span>" +
|
||||
'<span class="tag" style="flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:10.5px' +
|
||||
(arch ? ";opacity:.7" : "") + '">' + ico("user", 10) + g.memberCount + "</span>" +
|
||||
(arch ? '<span class="tag" style="flex-shrink:0;font-size:10px;opacity:.85">已删除</span>' : "") +
|
||||
"</div>";
|
||||
}).join("");
|
||||
}
|
||||
const active = groups.filter((g) => !g.archivedAt);
|
||||
const archivedN = groups.length - active.length;
|
||||
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
|
||||
footEl.textContent = active.length + " 个活跃组 · " + totalMembers + " 条成员关系" +
|
||||
(archivedN > 0 ? " · " + archivedN + " 个已删除" : "");
|
||||
|
||||
treeEl.querySelectorAll(".g-node").forEach((el) => {
|
||||
el.onclick = (e) => {
|
||||
if (e.target.closest("button")) return;
|
||||
selected = { id: el.dataset.id, name: el.dataset.name };
|
||||
listEl.querySelectorAll(".g-team").forEach((x) => x.style.background = "");
|
||||
el.style.background = "var(--selected)";
|
||||
if (e.target.closest("[data-caret]")) return; // 折叠三角不触发选中
|
||||
selectGroup({ id: el.dataset.id, name: el.dataset.name });
|
||||
loadMembers();
|
||||
};
|
||||
el.oncontextmenu = (e) => {
|
||||
e.preventDefault();
|
||||
openMenu(e.clientX, e.clientY, {
|
||||
id: el.dataset.id,
|
||||
name: el.dataset.name,
|
||||
archived: el.dataset.archived === "1",
|
||||
});
|
||||
};
|
||||
});
|
||||
listEl.querySelectorAll("button[data-archive]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
if (!confirm("归档该 Group?其成员授权将失效。")) return;
|
||||
try { await req("/teams/" + encodeURIComponent(btn.dataset.archive) + "/archive", { method: "POST" }); selected = null; detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>'; loadList(); }
|
||||
catch (e) { alert(e.message); }
|
||||
treeEl.querySelectorAll("[data-caret]").forEach((el) => {
|
||||
el.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const id = el.dataset.caret;
|
||||
if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id);
|
||||
renderTree();
|
||||
};
|
||||
});
|
||||
}
|
||||
async function loadMembers() {
|
||||
if (!selected) return;
|
||||
detailEl.innerHTML = '<div class="quiet" style="padding:12px;text-align:center">加载中…</div>';
|
||||
const { members } = await req("/teams/" + encodeURIComponent(selected.id) + "/members");
|
||||
detailEl.innerHTML =
|
||||
'<div class="section-title">' + esc(selected.name) + " · 成员(" + members.length + ")</div>" +
|
||||
'<table class="list"><tbody>' +
|
||||
(members.length === 0
|
||||
? '<tr><td class="quiet" style="text-align:center;padding:14px">暂无成员</td></tr>'
|
||||
: members.map((m) =>
|
||||
"<tr><td>" + esc(m.displayName || m.userId) + '</td><td class="file-meta">' + esc(m.userId) + "</td>" +
|
||||
'<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '">移除</button></td></tr>').join("")) +
|
||||
"</tbody></table>" +
|
||||
'<div class="divider"></div>' +
|
||||
'<div class="section-title">添加成员</div>' +
|
||||
'<div class="inline-form">' +
|
||||
'<input id="g-add-openid" class="input" placeholder="用户 openId 或 userId"/>' +
|
||||
'<button id="g-add" class="btn btn-primary">添加</button>' +
|
||||
"</div>";
|
||||
detailEl.querySelectorAll("button[data-revoke]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
try { await req("/teams/" + encodeURIComponent(selected.id) + "/members/" + encodeURIComponent(btn.dataset.revoke) + "/revoke", { method: "POST" }); loadMembers(); }
|
||||
catch (e) { alert(e.message); }
|
||||
};
|
||||
|
||||
/* ---------------- 右键菜单 ---------------- */
|
||||
|
||||
function closeMenu() { menuEl.classList.add("hidden"); }
|
||||
document.addEventListener("click", closeMenu);
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu(); });
|
||||
// 树空白处右键 = 建根组
|
||||
treeEl.oncontextmenu = (e) => {
|
||||
if (e.target.closest(".g-node")) return;
|
||||
e.preventDefault();
|
||||
openMenu(e.clientX, e.clientY, null);
|
||||
};
|
||||
|
||||
function openMenu(x, y, target) {
|
||||
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
|
||||
const items = target === null
|
||||
? [{ label: "新建根 Group", ic: "plus", fn: () => createDialog(null, null) }]
|
||||
: target.archived
|
||||
? [
|
||||
{ label: "查看成员(只读)", ic: "users", fn: () => { selectGroup(target); loadMembers(); } },
|
||||
{ label: "恢复此 Group", ic: "restore", fn: () => restoreGroup(target) },
|
||||
{ sep: true },
|
||||
{ label: "新建根 Group", ic: "layers", fn: () => createDialog(null, null) },
|
||||
]
|
||||
: [
|
||||
{ label: "新建子 Group", ic: "plus", fn: () => createDialog(target.id, target.name) },
|
||||
{ label: "添加成员", ic: "user", fn: () => { selectGroup(target); addMemberDialog(); } },
|
||||
{ label: "重命名 / 改描述", ic: "pencil", fn: () => renameDialog(target) },
|
||||
{ sep: true },
|
||||
{ label: "新建根 Group", ic: "layers", fn: () => createDialog(null, null) },
|
||||
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => removeGroup(target) },
|
||||
];
|
||||
menuEl.innerHTML = items.map((it, i) =>
|
||||
it.sep
|
||||
? '<div style="height:1px;background:var(--border-soft);margin:4px 6px"></div>'
|
||||
: '<div data-mi="' + i + '" style="display:flex;align-items:center;gap:8px;padding:7px 11px;border-radius:6px;cursor:pointer' +
|
||||
(it.danger ? ";color:var(--danger)" : "") + '">' +
|
||||
'<span style="display:flex;opacity:.75">' + ico(it.ic, 14) + "</span>" + esc(it.label) + "</div>").join("");
|
||||
menuEl.querySelectorAll("[data-mi]").forEach((el) => {
|
||||
el.onmouseenter = () => { el.style.background = "var(--hover)"; };
|
||||
el.onmouseleave = () => { el.style.background = ""; };
|
||||
el.onclick = (e) => { e.stopPropagation(); closeMenu(); items[Number(el.dataset.mi)].fn(); };
|
||||
});
|
||||
detailEl.querySelector("#g-add").onclick = async () => {
|
||||
const v = detailEl.querySelector("#g-add-openid").value.trim();
|
||||
if (!v) return alert("请填写用户 id");
|
||||
menuEl.classList.remove("hidden");
|
||||
// 贴边翻转,避免菜单溢出视口。
|
||||
const r = menuEl.getBoundingClientRect();
|
||||
menuEl.style.left = Math.min(x, window.innerWidth - r.width - 8) + "px";
|
||||
menuEl.style.top = Math.min(y, window.innerHeight - r.height - 8) + "px";
|
||||
}
|
||||
|
||||
/* ---------------- 弹窗:建组 / 改名 ---------------- */
|
||||
|
||||
function modal(html, width) {
|
||||
const mask = document.createElement("div");
|
||||
mask.className = "modal-mask";
|
||||
mask.innerHTML = '<div class="modal-card"' +
|
||||
(width ? ' style="max-width:' + width + 'px"' : "") + ">" + html + "</div>";
|
||||
mask.onclick = (e) => { if (e.target === mask) mask.remove(); };
|
||||
document.body.appendChild(mask);
|
||||
const input = mask.querySelector("input");
|
||||
if (input) input.focus();
|
||||
return mask;
|
||||
}
|
||||
|
||||
function createDialog(parentId, parentName) {
|
||||
const m = modal(
|
||||
'<div class="modal-title">' + (parentId ? "在「" + esc(parentName) + "」下新建子 Group" : "新建根 Group") + "</div>" +
|
||||
'<div class="form-row"><label class="form-label">名称</label><input id="gc-name" class="input" placeholder="例如:物理教研组"/></div>' +
|
||||
'<div class="form-row"><label class="form-label">描述(可选)</label><input id="gc-desc" class="input" placeholder="一句话说明"/></div>' +
|
||||
'<div class="modal-actions"><button class="btn" data-x>取消</button><button class="btn btn-primary" data-ok>创建</button></div>'
|
||||
);
|
||||
m.querySelector("[data-x]").onclick = () => m.remove();
|
||||
const submit = async () => {
|
||||
const name = m.querySelector("#gc-name").value.trim();
|
||||
if (!name) return alert("名称必填");
|
||||
const description = m.querySelector("#gc-desc").value.trim();
|
||||
try {
|
||||
await req("/teams/" + encodeURIComponent(selected.id) + "/members", {
|
||||
method: "POST",
|
||||
body: v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v },
|
||||
});
|
||||
loadMembers();
|
||||
await req("", { method: "POST", body: { name, parentId, ...(description ? { description } : {}) } });
|
||||
m.remove();
|
||||
if (parentId) collapsed.delete(parentId); // 建完自动展开父节点
|
||||
await loadTree();
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
m.querySelector("[data-ok]").onclick = submit;
|
||||
m.querySelector("#gc-name").onkeydown = (e) => { if (e.key === "Enter") submit(); };
|
||||
}
|
||||
document.getElementById("g-create").onclick = async () => {
|
||||
const slug = document.getElementById("g-slug").value.trim();
|
||||
const name = document.getElementById("g-name").value.trim();
|
||||
const description = document.getElementById("g-desc").value.trim();
|
||||
if (!slug || !name) return alert("slug 和名称必填");
|
||||
|
||||
function renameDialog(target) {
|
||||
const cur = groups.find((g) => g.id === target.id);
|
||||
const m = modal(
|
||||
'<div class="modal-title">重命名 / 改描述</div>' +
|
||||
'<div class="form-row"><label class="form-label">名称</label><input id="gr-name" class="input" value="' + esc(target.name) + '"/></div>' +
|
||||
'<div class="form-row"><label class="form-label">描述</label><input id="gr-desc" class="input" value="' +
|
||||
esc((cur && cur.description) || "") + '" placeholder="留空则清除描述"/></div>' +
|
||||
'<div class="modal-actions"><button class="btn" data-x>取消</button><button class="btn btn-primary" data-ok>保存</button></div>'
|
||||
);
|
||||
m.querySelector("[data-x]").onclick = () => m.remove();
|
||||
const submit = async () => {
|
||||
const name = m.querySelector("#gr-name").value.trim();
|
||||
if (!name) return alert("名称必填");
|
||||
try {
|
||||
// description 总是回传(含空串)——空串即清除描述。
|
||||
await req("/" + encodeURIComponent(target.id), {
|
||||
method: "PATCH",
|
||||
body: { name, description: m.querySelector("#gr-desc").value.trim() },
|
||||
});
|
||||
m.remove();
|
||||
if (selected && selected.id === target.id) selected.name = name;
|
||||
await loadTree();
|
||||
if (selected && selected.id === target.id) loadMembers();
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
m.querySelector("[data-ok]").onclick = submit;
|
||||
m.querySelector("#gr-name").onkeydown = (e) => { if (e.key === "Enter") submit(); };
|
||||
}
|
||||
|
||||
async function restoreGroup(target) {
|
||||
// 恢复语义与删除不对称(决策7):只回该组 + 已归档祖先链,子树仍归档。
|
||||
if (!confirm("恢复「" + target.name + "」?\\n\\n其已删除的上级会一并恢复(否则它在树上无路径);" +
|
||||
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。")) return;
|
||||
try {
|
||||
await req("/teams", { method: "POST", body: { slug, name, ...(description ? { description } : {}) } });
|
||||
document.getElementById("g-slug").value = "";
|
||||
document.getElementById("g-name").value = "";
|
||||
document.getElementById("g-desc").value = "";
|
||||
loadList();
|
||||
const r = await req("/" + encodeURIComponent(target.id) + "/restore", { method: "POST" });
|
||||
await loadTree();
|
||||
if (selected && selected.id === target.id) loadMembers();
|
||||
alert("已恢复 " + r.restoredCount + " 个组");
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
|
||||
async function removeGroup(target) {
|
||||
if (!confirm("删除「" + target.name + "」?\\n\\n软删除:整棵子树一并标记删除,相关授权立即失效," +
|
||||
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。")) return;
|
||||
try {
|
||||
const r = await req("/" + encodeURIComponent(target.id), { method: "DELETE" });
|
||||
if (selected && selected.id === target.id) {
|
||||
selected = null;
|
||||
detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>';
|
||||
}
|
||||
await loadTree();
|
||||
alert("已删除 " + r.archivedCount + " 个组(软删除,可恢复)");
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
|
||||
/* ---------------- 成员面板 ---------------- */
|
||||
|
||||
let members = []; // 当前组的成员
|
||||
let memberFilter = ""; // 成员表内过滤(显示名/userId/openId)
|
||||
|
||||
function selectGroup(target) {
|
||||
selected = { id: target.id, name: target.name };
|
||||
memberFilter = "";
|
||||
renderTree();
|
||||
}
|
||||
|
||||
/** 组头部 + 成员表(表格列:成员 / userId / 飞书 openId / 加入时间 / 操作)。 */
|
||||
function renderMembers() {
|
||||
if (!selected) return;
|
||||
const g = groups.find((x) => x.id === selected.id);
|
||||
const q = memberFilter.trim().toLowerCase();
|
||||
const shown = q === ""
|
||||
? members
|
||||
: members.filter((m) =>
|
||||
(m.displayName || "").toLowerCase().includes(q) ||
|
||||
m.userId.toLowerCase().includes(q) ||
|
||||
(m.feishuOpenId || "").toLowerCase().includes(q));
|
||||
|
||||
// 面包屑:祖先链(根在前)。
|
||||
const byId = new Map(groups.map((x) => [x.id, x]));
|
||||
const chain = [];
|
||||
for (let cur = g; cur; cur = cur.parentId ? byId.get(cur.parentId) : null) chain.unshift(cur);
|
||||
const crumb = chain.map((c, i) =>
|
||||
(i > 0 ? '<span style="color:var(--text-3);margin:0 5px">/</span>' : "") +
|
||||
(i === chain.length - 1
|
||||
? '<span style="color:var(--text);font-weight:500">' + esc(c.name) + "</span>"
|
||||
: '<span style="color:var(--text-3)">' + esc(c.name) + "</span>")).join("");
|
||||
|
||||
const isArchived = !!(g && g.archivedAt);
|
||||
|
||||
detailEl.innerHTML =
|
||||
// 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。
|
||||
(isArchived
|
||||
? '<div style="display:flex;align-items:center;gap:9px;padding:10px 18px;flex-shrink:0;' +
|
||||
'background:var(--hover);border-bottom:1px solid var(--border-soft);font-size:12.5px">' +
|
||||
'<span style="display:flex;color:var(--text-3)">' + ico("archive", 15) + "</span>" +
|
||||
'<span style="flex:1">此 Group 已删除于 ' +
|
||||
esc(fmtDate(g.archivedAt)) + " · 成员只读,不再授予任何权限</span>" +
|
||||
'<button id="gm-restore" class="btn" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
|
||||
ico("restore", 13) + "恢复</button>" +
|
||||
"</div>"
|
||||
: "") +
|
||||
// 头部
|
||||
'<div style="padding:16px 18px 12px;border-bottom:1px solid var(--border-soft);flex-shrink:0">' +
|
||||
'<div style="font-size:12px;margin-bottom:6px">' + crumb + "</div>" +
|
||||
'<div style="display:flex;align-items:center;gap:10px">' +
|
||||
'<span style="display:flex;color:' + (isArchived ? "var(--text-3)" : "var(--accent)") + '">' +
|
||||
ico(isArchived ? "archive" : "group", 20) + "</span>" +
|
||||
'<div style="flex:1;min-width:0">' +
|
||||
'<div style="font-size:16px;font-weight:600;color:' + (isArchived ? "var(--text-3)" : "var(--text)") + '">' +
|
||||
esc(selected.name) + "</div>" +
|
||||
(g && g.description
|
||||
? '<div class="section-note" style="margin-top:2px">' + esc(g.description) + "</div>"
|
||||
: '<div class="section-note" style="margin-top:2px;opacity:.6">无描述</div>') +
|
||||
"</div>" +
|
||||
// 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。
|
||||
(isArchived
|
||||
? ""
|
||||
: '<button id="gm-rename" class="btn" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
|
||||
ico("pencil", 13) + "编辑</button>" +
|
||||
'<button id="gm-add" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
|
||||
ico("plus", 13) + "添加成员</button>") +
|
||||
"</div>" +
|
||||
// 统计条
|
||||
'<div style="display:flex;gap:16px;margin-top:12px;font-size:12px;color:var(--text-3)">' +
|
||||
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("user", 12) + members.length + " 名成员</span>" +
|
||||
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("layers", 12) + "层级 " + ((g && g.depth) || 0) + "</span>" +
|
||||
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("group", 12) +
|
||||
groups.filter((x) => x.parentId === selected.id).length + " 个子组</span>" +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
// 成员表工具条
|
||||
'<div style="padding:10px 18px;flex-shrink:0;display:flex;align-items:center;gap:10px">' +
|
||||
'<div style="position:relative;flex:1;max-width:280px">' +
|
||||
'<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">' +
|
||||
ico("search", 13) + "</span>" +
|
||||
'<input id="gm-filter" class="input" placeholder="搜索成员…" style="padding-left:28px;font-size:12.5px"' +
|
||||
' value="' + esc(memberFilter) + '"/>' +
|
||||
"</div>" +
|
||||
'<span class="file-meta">' + (q === "" ? "" : shown.length + " / " + members.length) + "</span>" +
|
||||
"</div>" +
|
||||
// 成员表
|
||||
'<div style="flex:1;overflow-y:auto;padding:0 18px 18px;min-height:0">' +
|
||||
(members.length === 0
|
||||
? '<div class="quiet" style="text-align:center;padding:36px 12px;display:flex;flex-direction:column;align-items:center;gap:10px">' +
|
||||
'<span style="color:var(--border);display:flex">' + ico("users", 34) + "</span>" +
|
||||
(isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」") + "</div>"
|
||||
: shown.length === 0
|
||||
? '<div class="quiet" style="text-align:center;padding:30px 12px">无匹配成员</div>'
|
||||
: '<table class="list" style="width:100%">' +
|
||||
"<thead><tr>" +
|
||||
"<th>成员</th><th>userId</th><th>飞书 openId</th><th>加入时间</th>" +
|
||||
(isArchived ? "" : "<th style=\\"text-align:right\\">操作</th>") +
|
||||
"</tr></thead><tbody>" +
|
||||
shown.map((m) =>
|
||||
"<tr>" +
|
||||
'<td><span style="display:inline-flex;align-items:center;gap:9px">' + avatar(m, 28) +
|
||||
'<span style="font-weight:500">' + esc(m.displayName || "(未命名)") + "</span></span></td>" +
|
||||
'<td class="file-meta">' + esc(m.userId) + "</td>" +
|
||||
'<td class="file-meta">' + esc(m.feishuOpenId || "—") + "</td>" +
|
||||
'<td class="file-meta">' + esc(fmtDate(m.joinedAt)) + "</td>" +
|
||||
(isArchived
|
||||
? ""
|
||||
: '<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '"' +
|
||||
' style="display:inline-flex;align-items:center;gap:4px">' + ico("minus", 12) + "移除</button></td>") +
|
||||
"</tr>").join("") +
|
||||
"</tbody></table>") +
|
||||
"</div>";
|
||||
|
||||
// 归档态下这些按钮不渲染,故逐个判空。
|
||||
const addBtn = detailEl.querySelector("#gm-add");
|
||||
if (addBtn) addBtn.onclick = addMemberDialog;
|
||||
const renameBtn = detailEl.querySelector("#gm-rename");
|
||||
if (renameBtn) renameBtn.onclick = () => renameDialog(selected);
|
||||
const restoreBtn = detailEl.querySelector("#gm-restore");
|
||||
if (restoreBtn) {
|
||||
restoreBtn.onclick = () => restoreGroup({ id: selected.id, name: selected.name, archived: true });
|
||||
}
|
||||
const f = detailEl.querySelector("#gm-filter");
|
||||
if (f) {
|
||||
f.oninput = () => { memberFilter = f.value; renderMembers(); detailEl.querySelector("#gm-filter").focus(); };
|
||||
}
|
||||
detailEl.querySelectorAll("button[data-revoke]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const uid = btn.dataset.revoke;
|
||||
const who = (members.find((m) => m.userId === uid) || {}).displayName || uid;
|
||||
if (!confirm("将「" + who + "」从「" + selected.name + "」移除?")) return;
|
||||
try {
|
||||
await req("/" + encodeURIComponent(selected.id) + "/members/" + encodeURIComponent(uid), { method: "DELETE" });
|
||||
await Promise.all([loadTree(), loadMembers()]);
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
if (!selected) return;
|
||||
// 归档组的成员照样拉取展示(只读)—— 软删是打标,成员行仍在库里。
|
||||
detailEl.innerHTML = '<div class="quiet" style="margin:auto;padding:18px;text-align:center">加载中…</div>';
|
||||
try {
|
||||
const r = await req("/" + encodeURIComponent(selected.id) + "/members");
|
||||
members = r.members;
|
||||
renderMembers();
|
||||
} catch (e) {
|
||||
detailEl.innerHTML = '<div style="color:var(--danger);padding:16px">' + esc(e.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 添加成员弹窗(搜索选择) ---------------- */
|
||||
|
||||
function addMemberDialog() {
|
||||
if (!selected) return;
|
||||
const m = modal(
|
||||
'<div class="modal-title" style="display:flex;align-items:center;gap:7px">' +
|
||||
'<span style="display:flex;color:var(--accent)">' + ico("user", 16) + "</span>" +
|
||||
"添加成员到「" + esc(selected.name) + "」</div>" +
|
||||
'<div style="position:relative;margin-bottom:10px">' +
|
||||
'<span style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">' +
|
||||
ico("search", 14) + "</span>" +
|
||||
'<input id="ga-q" class="input" placeholder="搜索用户显示名 / 飞书 openId…" style="padding-left:30px"/>' +
|
||||
"</div>" +
|
||||
'<div id="ga-list" style="max-height:290px;overflow-y:auto;margin:0 -4px"></div>' +
|
||||
'<div class="section-note" id="ga-note" style="margin-top:10px">已在本组的用户不会出现在结果里</div>' +
|
||||
'<div class="modal-actions"><button class="btn" data-x>关闭</button></div>',
|
||||
520
|
||||
);
|
||||
m.querySelector("[data-x]").onclick = () => m.remove();
|
||||
const qEl = m.querySelector("#ga-q");
|
||||
const listEl = m.querySelector("#ga-list");
|
||||
const noteEl = m.querySelector("#ga-note");
|
||||
|
||||
let seq = 0;
|
||||
async function search() {
|
||||
const mine = ++seq;
|
||||
listEl.innerHTML = '<div class="quiet" style="padding:16px;text-align:center">搜索中…</div>';
|
||||
try {
|
||||
const url = "/database/api/users/search?q=" + encodeURIComponent(qEl.value.trim()) +
|
||||
"&excludeGroupId=" + encodeURIComponent(selected.id);
|
||||
const res = await fetch(url, { credentials: "same-origin" });
|
||||
if (res.status === 401) { location.href = "/database/admin"; return; }
|
||||
const data = await res.json();
|
||||
if (mine !== seq) return; // 丢弃过期响应
|
||||
if (!res.ok) throw new Error((data.error && data.error.message) || res.statusText);
|
||||
render(data.users);
|
||||
} catch (e) {
|
||||
if (mine === seq) listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
function render(users) {
|
||||
if (users.length === 0) {
|
||||
listEl.innerHTML = '<div class="quiet" style="padding:20px;text-align:center">无匹配用户</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = users.map((u, i) =>
|
||||
'<div data-ui="' + i + '" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer">' +
|
||||
avatar(u, 30) +
|
||||
'<span style="flex:1;min-width:0">' +
|
||||
'<span style="display:block;font-size:13px;font-weight:500;color:var(--text)">' + esc(u.displayName || "(未命名)") + "</span>" +
|
||||
'<span class="file-meta" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
|
||||
esc(u.feishuOpenId) + "</span>" +
|
||||
"</span>" +
|
||||
'<span class="btn" style="flex-shrink:0;font-size:11.5px;padding:3px 9px;display:inline-flex;align-items:center;gap:4px">' +
|
||||
ico("plus", 12) + "添加</span>" +
|
||||
"</div>").join("");
|
||||
listEl.querySelectorAll("[data-ui]").forEach((el) => {
|
||||
el.onmouseenter = () => { el.style.background = "var(--hover)"; };
|
||||
el.onmouseleave = () => { el.style.background = ""; };
|
||||
el.onclick = async () => {
|
||||
const u = users[Number(el.dataset.ui)];
|
||||
el.style.pointerEvents = "none";
|
||||
el.style.opacity = ".5";
|
||||
try {
|
||||
await req("/" + encodeURIComponent(selected.id) + "/members", {
|
||||
method: "POST", body: { userId: u.userId },
|
||||
});
|
||||
noteEl.textContent = "已添加 " + (u.displayName || u.userId);
|
||||
noteEl.style.color = "var(--accent)";
|
||||
await Promise.all([loadTree(), loadMembers()]);
|
||||
await search(); // 刷新候选(已加的会被排除)
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
el.style.pointerEvents = "";
|
||||
el.style.opacity = "";
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
qEl.oninput = () => { clearTimeout(timer); timer = setTimeout(search, 200); };
|
||||
search(); // 打开即列出候选
|
||||
}
|
||||
|
||||
/* ---------------- 初始化 ---------------- */
|
||||
|
||||
async function loadTree() {
|
||||
const r = await req(showArchived ? "?includeArchived=1" : "");
|
||||
groups = r.groups;
|
||||
// 选中项可能已被级联归档 → 清空右栏(showArchived 下归档组仍在列表里,不清)。
|
||||
if (selected && !groups.some((g) => g.id === selected.id)) {
|
||||
selected = null;
|
||||
detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>';
|
||||
}
|
||||
renderTree();
|
||||
}
|
||||
|
||||
document.getElementById("g-new-root").onclick = () => createDialog(null, null);
|
||||
const filterEl = document.getElementById("g-filter");
|
||||
filterEl.oninput = () => { filterText = filterEl.value; renderTree(); };
|
||||
const archEl = document.getElementById("g-show-archived");
|
||||
archEl.onchange = () => {
|
||||
showArchived = archEl.checked;
|
||||
loadTree().catch((e) => alert(e.message));
|
||||
};
|
||||
loadList().catch((e) => { listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>"; });
|
||||
loadTree().catch((e) => {
|
||||
treeEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>";
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
@@ -26,11 +26,12 @@ import path from "node:path";
|
||||
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
|
||||
import { registerFileLibRoutes } from "./filelibRoutes.js";
|
||||
import { registerFileRoutes } from "./fileRoutes.js";
|
||||
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
|
||||
import { registerTeacherApp } from "./teacherApp.js";
|
||||
import { renderLibraryBrowser } from "./libraryBrowser.js";
|
||||
import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js";
|
||||
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
|
||||
import { createTeamGroupResolver } from "../filelib/groupResolver.js";
|
||||
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
||||
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
|
||||
import { createManifestStubAdapter } from "../filelib/exportService.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
|
||||
@@ -122,7 +123,8 @@ export async function registerDatabaseRoutes(
|
||||
|
||||
// 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。
|
||||
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
|
||||
// GroupResolver 默认读 hub Team,HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
|
||||
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
|
||||
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
|
||||
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
|
||||
const siloOrg = await config.prisma.organization.findUnique({
|
||||
where: { slug: config.siloOrganizationSlug },
|
||||
@@ -141,13 +143,14 @@ export async function registerDatabaseRoutes(
|
||||
organizationId: siloOrg.id,
|
||||
storageRoot,
|
||||
groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === ""
|
||||
? createTeamGroupResolver(config.prisma, siloOrg.id)
|
||||
? createMemberGroupResolver(config.prisma)
|
||||
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
||||
versionStore,
|
||||
exportAdapters: [createManifestStubAdapter(versionStore)],
|
||||
};
|
||||
await registerFileLibRoutes(app, filelibDeps);
|
||||
await registerFileRoutes(app, filelibDeps);
|
||||
await registerMemberGroupRoutes(app, filelibDeps);
|
||||
await registerTeacherApp(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
@@ -339,7 +342,6 @@ ${pageHead("Database Admin")}
|
||||
<div style="margin:10px;padding:10px 12px;border-top:1px solid var(--border-soft);display:flex;align-items:center;gap:9px">
|
||||
<div style="width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex-shrink:0">${initial}</div>
|
||||
<div style="min-width:0;flex:1">
|
||||
<p style="font-size:10.5px;color:var(--text-3)">已登录</p>
|
||||
<p style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--text)">${escapeHtml(displayName)}</p>
|
||||
</div>
|
||||
<button id="logout" class="btn" style="padding:3px 10px;font-size:11px">退出</button>
|
||||
@@ -368,9 +370,8 @@ ${pageHead("Database Admin")}
|
||||
${renderUsersPanel(orgSlug)}
|
||||
</section>
|
||||
|
||||
<section id="tab-groups" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
|
||||
<h1 style="font-size:18px;font-weight:600;margin-bottom:16px">Group 管理</h1>
|
||||
${renderGroupsPanel(orgSlug)}
|
||||
<section id="tab-groups" class="admin-tab-section" style="display:none;flex:1;overflow:hidden;padding:20px">
|
||||
${renderGroupsPanel()}
|
||||
</section>
|
||||
|
||||
<section id="tab-search" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
|
||||
|
||||
@@ -261,29 +261,8 @@ export async function registerFileLibRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ Group 搜索(过渡) */
|
||||
|
||||
// 过渡实现:读 hub Team(C2 /groups/search 由 Group 团队交付后切换)。
|
||||
app.get("/database/api/groups/search", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const q = ((request.query as { q?: string }).q ?? "").trim();
|
||||
const teams = await deps.prisma.team.findMany({
|
||||
where: {
|
||||
organizationId: deps.organizationId,
|
||||
archivedAt: null,
|
||||
...(q === "" ? {} : { name: { contains: q, mode: "insensitive" as const } }),
|
||||
},
|
||||
take: 20,
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
return { groups: teams.map((t) => ({ id: t.id, name: t.name, breadcrumb: t.name })) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
// Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
|
||||
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
|
||||
}
|
||||
|
||||
function parseGrants(raw: unknown): InitialGrant[] | undefined {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* /database/api/groups/* 成员组管理端点(ADR-0028)。
|
||||
* 约定:绝对路径;actorOrNull 前置 fail closed;业务全走 memberGroupService;
|
||||
* 错误统一 sendRouteError。
|
||||
*
|
||||
* 管理端点(CRUD + 成员)由 service 层门禁到网站管理员;搜索端点不限管理员
|
||||
* (授权选择器是 Manage 持有者的能力,决策2)。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
addMember,
|
||||
createMemberGroup,
|
||||
deleteMemberGroup,
|
||||
listMemberGroups,
|
||||
listMembers,
|
||||
removeMember,
|
||||
restoreMemberGroup,
|
||||
searchMemberGroups,
|
||||
searchUsers,
|
||||
updateMemberGroup,
|
||||
} from "../filelib/memberGroupService.js";
|
||||
import { FileLibError } from "../filelib/model.js";
|
||||
import {
|
||||
actorOrNull,
|
||||
bodyObject,
|
||||
optionalString,
|
||||
requireString,
|
||||
sendRouteError,
|
||||
type FileLibRouteDeps,
|
||||
} from "../filelib/routeShared.js";
|
||||
|
||||
export async function registerMemberGroupRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: FileLibRouteDeps,
|
||||
): Promise<void> {
|
||||
const svc = { prisma: deps.prisma, organizationId: deps.organizationId };
|
||||
|
||||
/* ------------------------------------------------------------ 搜索(授权选择器) */
|
||||
|
||||
// 契约 C2 /groups/search:活跃组 + breadcrumb。**非管理员可调**(决策2)。
|
||||
// 注:必须先于 "/database/api/groups" 之类的段前缀之外单独成路径,Fastify
|
||||
// 静态路由不会 shadow,顺序无关;此处与其它端点平级注册。
|
||||
app.get("/database/api/groups/search", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const q = (request.query as { q?: string }).q ?? "";
|
||||
return { groups: await searchMemberGroups(svc, q) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 成员选择器:搜全局用户。仅管理员(service 层门禁)。
|
||||
// excludeGroupId 过滤掉该组已有活跃成员。
|
||||
app.get("/database/api/users/search", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const query = request.query as { q?: string; excludeGroupId?: string };
|
||||
return { users: await searchUsers(svc, actor, query.q ?? "", query.excludeGroupId) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 组 CRUD */
|
||||
|
||||
// includeArchived=1 时连已归档组一并返回(带 archivedAt),供后台展示/恢复。
|
||||
app.get("/database/api/groups", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const raw = (request.query as { includeArchived?: string }).includeArchived;
|
||||
const includeArchived = raw === "1" || raw === "true";
|
||||
return { groups: await listMemberGroups(svc, actor, includeArchived) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/database/api/groups", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const body = bodyObject(request.body);
|
||||
const parentIdRaw = body["parentId"];
|
||||
if (parentIdRaw !== undefined && parentIdRaw !== null && typeof parentIdRaw !== "string") {
|
||||
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
|
||||
}
|
||||
const group = await createMemberGroup(svc, actor, {
|
||||
name: requireString(body, "name"),
|
||||
description: optionalString(body, "description"),
|
||||
parentId: parentIdRaw === undefined ? null : parentIdRaw,
|
||||
});
|
||||
return reply.status(201).send({ group });
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 改名 / 改描述(决策6)。不接受 parentId —— reparent 仍不在 v1(决策5)。
|
||||
app.patch("/database/api/groups/:id", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
if (body["parentId"] !== undefined) {
|
||||
throw new FileLibError(400, "invalid_request", "reparent is not supported (ADR-0028)");
|
||||
}
|
||||
// description 需区分"未传"(不动)与 ""(清空),故不用 optionalString
|
||||
// (它把 "" 也归为 undefined)。
|
||||
const descRaw = body["description"];
|
||||
if (descRaw !== undefined && descRaw !== null && typeof descRaw !== "string") {
|
||||
throw new FileLibError(400, "invalid_request", "description must be a string");
|
||||
}
|
||||
const group = await updateMemberGroup(svc, actor, id, {
|
||||
name: optionalString(body, "name"),
|
||||
description: descRaw === undefined || descRaw === null ? undefined : descRaw,
|
||||
});
|
||||
return { group };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/database/api/groups/:id", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const result = await deleteMemberGroup(svc, actor, id);
|
||||
return { archivedCount: result.archivedCount };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 恢复(取消归档)。与删除不对称:只恢复该组 + 已归档祖先链,不动子树(决策7)。
|
||||
app.post("/database/api/groups/:id/restore", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const result = await restoreMemberGroup(svc, actor, id);
|
||||
return { restoredCount: result.restoredCount };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 成员 */
|
||||
|
||||
app.get("/database/api/groups/:id/members", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return { members: await listMembers(svc, actor, id) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/database/api/groups/:id/members", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
const userId = optionalString(body, "userId");
|
||||
const feishuOpenId = optionalString(body, "feishuOpenId");
|
||||
if (userId === undefined && feishuOpenId === undefined) {
|
||||
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
|
||||
}
|
||||
const member = await addMember(svc, actor, id, { userId, feishuOpenId });
|
||||
return reply.status(201).send({ member });
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/database/api/groups/:id/members/:userId", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id, userId } = request.params as { id: string; userId: string };
|
||||
await removeMember(svc, actor, id, userId);
|
||||
return reply.status(204).send();
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -135,4 +135,21 @@ export const UI_THEME_CSS = `
|
||||
.file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
|
||||
.link-danger { color: var(--danger); font-size: 12.5px; background: none; border: none; cursor: pointer; padding: 0; }
|
||||
.link-danger:hover { text-decoration: underline; }
|
||||
|
||||
/* 开关(toggle)。用法:<label class="switch"><input type="checkbox"><span></span>文字</label>
|
||||
真正的 checkbox 藏在下面 —— 保留键盘可达与 :checked 语义,不做 div 假开关。 */
|
||||
.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
|
||||
.switch > input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.switch > span {
|
||||
position: relative; flex-shrink: 0; width: 30px; height: 17px; border-radius: 999px;
|
||||
background: var(--border); transition: background .16s;
|
||||
}
|
||||
.switch > span::after {
|
||||
content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px;
|
||||
border-radius: 50%; background: #fff; transition: transform .16s;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.25);
|
||||
}
|
||||
.switch > input:checked + span { background: var(--accent); }
|
||||
.switch > input:checked + span::after { transform: translateX(13px); }
|
||||
.switch > input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user