Compare commits

...

17 Commits

Author SHA1 Message Date
e6e23294a2 Merge branch 'feat/member-group-hierarchy'
MemberGroup 全局嵌套层级(ADR-0028)与 /database 前后端分离(ADR-0029)。
2026-07-26 20:43:59 +08:00
8a13e455fb chore: 删除 .omo/ 与文件库-接口契约.md
.omo/ 下 12 个 run-continuation/ses_*.json 是 agent 会话续跑状态,
机器生成,本不该进版本库;文件库-开工计划.md 一并删除。

《文件库-接口契约.md》(C/D 编号)同时删除。两份文档的内容都可从 git
历史取回。

代码注释里的 C/D 编号(契约 8.1、C2、C4、D11–D19 等)因此不再有在库
文档可查,分布在 filelib 的 model / grantService / treeService /
guards、prisma schema 与迁移、以及 ADR-0028。README 原先按路径引用
这两份文档,现改为说明出处与取回方式。
2026-07-26 20:32:38 +08:00
6990082247 build(deploy): 部署与限流配置切换到 filelib-web,并加共存回归测试
三处引用旧工程名/旧资源路径的地方一并更新,它们必须同时改 —— 少改一处
就是静默故障,而不是构建期报错:

1. 部署脚本(deploy_platform.sh / deploy_fleet_release.sh):npm ci 的
   prefix、rsync 排除项、构建产物存在性检查从 database-admin 换成
   filelib-web。最后一项是真门禁:static.ts 缺产物时只 warn 不注册路由,
   漏改会让 /app 与 /database 静默 404 —— 恰是 database-admin 长期处于
   禁用状态的原因。

2. silo 限流豁免:资源路径随 appDir 改名而变(/database/_app/* 已不存在,
   现为 /_filelib/*);/app/* 此前不在豁免列表,它现在也是 SPA 外壳,
   客户端路由无法预先枚举。
   注:/database/* 是整体豁免,filelib 的 JSON API 也绕过限流预算。这是
   迁移前就有的行为,原样保留,但覆盖面因多了 /app/* 而变宽。

3. 回归测试:把 registerStaticSpa 与 registerDatabaseSpa 挂到同一个
   Fastify 实例,断言 ready() 不因重复路由抛错 —— appDir 若用回默认的
   _app,这里会红(ADR-0029 的承重约束)。另断言 /app 与
   /database/dashboard/users 返回同一份字节(SPA 回退不读请求)、body 含
   /_filelib/。构建产物缺失时不 skip 而是直接失败:那说明该先跑
   filelib-web 的 build,不是测试不适用。
2026-07-26 20:23:15 +08:00
cdeb29ccf2 fix(database): 补回文件库的「授权」tab 与 /me 的显示名
迁移时整个授权 tab 连同四个端点一起漏掉了 —— 后端一直可用,前端零调用:
  GET/PUT/DELETE /nodes/:id/grants
  PUT /projects/:id/independent-permission
权限编辑是这个后台的核心用途,而它此前在界面上完全不可达。

tab 组装也修正为与旧 libraryBrowser 一致:概览恒有、文件仅 PROJECT、
授权仅 MANAGE。注意文件夹也有授权 tab —— 它虽是透明组织节点,授权仍
挂在节点上(ADR-0021);此前文件夹一个 tab 都没有。

GrantsPanel 的语义按契约 8.1:创建者授权不给收回入口;MANAGE 仅创建者
可授,前端不拦,后端 fail closed 的报错原样呈现;GROUP 主体走
/groups/search 下拉选,不手敲 id。

/database/api/me 加 displayName 与 avatarUrl:侧栏此前显示原始 userId。
旧页面是服务端渲染,handler 里查 Prisma 就有名字;页面不再服务端渲染后
(ADR-0029),模板闭包过的数据也是被迁移的契约的一部分,不是旧实现的
无关细节。

概览面板同时补回丢失的「类型」「更新时间」两行、导出 target 下拉、
节点标题旁的角色 tag,以及整块缺失的独立权限开关。
2026-07-26 20:19:47 +08:00
eeb8f56742 fix(filelib-web): 补齐 Group 管理面板,与旧后端面板逐条对齐
迁移时误把分支上一个早先存在的简易 GroupAdmin(281 行)当成迁移产物,
它与旧 renderGroupsPanel(747 行)从来不是同一个东西,于是后端 8 个
group 端点前端只调了 5 个。

补上的功能(端点一直可用,只是没有入口):
  PATCH /groups/:id          重命名 / 改描述
  GET  /groups?includeArchived=1  列出已归档组
  POST /groups/:id/restore   恢复(连带恢复已归档祖先链,子树仍归档)
  GET  /users/search         成员选择器,不再手敲 userId
影响最实际的是恢复:软删的组此前在界面上无法恢复。

补上的交互:折叠树、组名过滤(命中项保留整条祖先链,过滤态强制展开)、
右键菜单(归档组只给「恢复」)、面包屑、统计条、树底部计数、成员表的
头像/openId/加入时间三列。

types.ts 之前也是截断的:MemberGroupNode 少 archivedAt,
MemberGroupMember 少 feishuOpenId/avatarUrl/joinedAt —— 类型里没有,
UI 自然渲染不出来。

一处实现偏离:折叠状态用数组而非 Set。Svelte 5 的 $state 深层代理不
跟踪 Set 变更,用 Set 会点了没反应。

groups tab 外框补 padding:20px/overflow:hidden,对齐旧 #tab-groups,
否则面板贴着侧边栏。
2026-07-26 20:19:30 +08:00
325b4fc137 fix(filelib-web): 补回迁移丢失的共享组件样式层与图标集
第一版迁移只把 uiTheme.ts 的 @theme 颜色令牌搬了过来,155 行里约 90
行的组件类(.btn/.panel/.input/.select/table.list/.tag/.switch/
.link-danger/.quiet 等)被丢掉,于是每个组件各自内联重述按钮、输入框、
面板的样式 —— 正是旧代码的重复问题被原样复刻,后台观感明显退化。

现在 app.css 是设计系统的唯一去处:@theme 管令牌,@layer components
管组件类。组件只带布局工具类,不重述组件样式。

图标集同样是丢的:旧面板有 13 个内联 SVG,新版一个不剩,只有纯文字的
「+」「删除」—— 这是"简陋"最直接的来源。提成 Icon.svelte 共享。
Group 节点沿用两人剪影而非文件夹图标:MemberGroup 与文件库的
FOLDER/PROJECT 是两套无关层级,图标不应混淆(ADR-0028/0021)。

顺手修 FilesPanel 的 uploadInput:bind:this 的目标要用 $state,
否则 Svelte 5 下不保证更新。
2026-07-26 20:19:10 +08:00
a7f90f387d chore(database-admin): 删除该前端工程,已被 filelib-web 取代
12628c9 引入它意在替换后端渲染的 /database 页面,但从未接通:具体
路由 /database/dashboard 比 SPA 通配 /database/* 更具体,服务端
handler 永远胜出,SPA 的 dashboard 不可达。工程头注释声称 SPA 已
接管 dashboard、且 /database/config 存在,两者当时都不成立。

hub 的 build 脚本也从未构建它,于是 static.ts 里的 existsSync 守卫
每次部署都失败,这个外壳实际长期处于禁用状态 —— 它没服务过一个请求。

与 filelib-web 合并而非并存的理由:两者共用文件库浏览器、会话层、
toast 宿主与设计令牌,拆开就要把这些全复制一遍(ADR-0029)。

内容可从 git 历史取回。
2026-07-26 20:18:15 +08:00
d159e372d2 refactor(database)!: 后端不再渲染任何 HTML,只出 JSON
删掉约 1770 行服务端模板拼接:renderDashboard / renderLoginPage
(databaseRoutes)、adminPanels、libraryBrowser、uiTheme,以及
libraryPage —— 后者迁移前已是无人引用的死代码。

新增两个端点承接原先在 page handler 里 inline 算的东西:
  GET /database/config      免鉴权 bootstrap(org slug + dev 开关);
                            注册位置刻意早于 silo org 的提前返回,
                            org 未就绪时登录页仍要能渲染。
  GET /database/api/stats   概览统计,要求 silo org OWNER/ADMIN ——
                            它聚合的是 org 级计数与审计流,不是
                            单节点权限视图。

静态托管收敛到 static.ts:一份 filelib-web 构建产物挂 /app 与
/database 两个前缀,资源路由只注册一次。并发症是路由顺序成了硬约束
—— 具体页面路由必须先于 SPA 通配注册,否则重演 /database/dashboard
盖住 SPA 的老 bug(ADR-0029)。

/database/library 改为 302 到 /database/dashboard/library。

BREAKING: 部署需先构建 filelib-web,否则 static.ts 的 existsSync
守卫会让 /app 与 /database 全部 404。
2026-07-26 20:17:59 +08:00
3d0f4e5c2d feat(filelib-web): 把 /database 各页从后端 HTML 拼接迁到 SvelteKit 路由
登录页、后台外壳与六个 tab 全部成为客户端路由:
  /database/admin              登录(迁自 renderLoginPage)
  /database/dashboard          概览(迁自 renderDashboard)
  .../library .../users .../groups .../search .../settings

六个 tab 是真 URL,不再是 location.hash + display:none —— 刷新不丢
位置,链接可分享。

BrowserShell 拆成 LibraryView,加 showUserFooter:老师端 /app 显示
身份/登出页脚,后台的文件库 tab 不显示(外层已有身份区)。

bootstrap 走 /database/config 而非 /database/api/login-info:后者由
teacherApp 在 silo org 查找成功后才注册,前者无条件注册,登录页在
org 未就绪时也必须能拿到配置。

后端拥有的链接(OAuth、DEV 一键登录)标 data-sveltekit-reload,
否则被客户端路由拦下。
2026-07-26 20:17:43 +08:00
de9f846fd0 build(filelib-web): 从 Svelte+Vite 改为 SvelteKit(adapter-static)
纯 SPA:adapter-static + fallback index.html,不做 SSR/预渲染。
index.html / main.ts / App.svelte 由 app.html + src/routes/ 取代。

两项配置是承重的(ADR-0029),不是风格选择:
  appDir: '_filelib'   默认 _app 会与 admin-web 在根上注册的 /_app/*
                       撞成 Fastify 重复路由,启动即抛错。
  paths.relative: false 同一份 index.html 会在 /app 和
                       /database/dashboard/users 等不同深度送出,
                       相对资源路径会解析到错的 base。

dev 代理表列出后端拥有的全部路径:JSON API、免鉴权 bootstrap
(/database/config)、OAuth、以及 DEV 一键登录端点 —— 后者不代理会被
SPA 回退吃掉。
2026-07-26 20:17:27 +08:00
683e97ca53 docs(adr): 0029 web 界面一律静态 SPA,hub 只出 JSON
记录本次迁移的语义决策:没有 HTTP handler 渲染 HTML;/app 与
/database 是同一个前端工程 filelib-web,构建一次挂两个前缀;
客户端导航用真 URL 路由而非 hash 片段。

两条承重配置约束一并写明:appDir 必须改名(默认 _app 与 admin-web
在根上的 /_app/* 撞重复路由,Fastify 启动即失败),以及
paths.relative=false(同一份 index.html 在不同 URL 深度被送出)。
2026-07-26 20:17:09 +08:00
11a7ec8004 feat(database): 后台成员组(MemberGroup)管理与嵌套解析 2026-07-26 18:06:18 +08:00
2f2ece1a3a fix(database): expose submitCreate on window.__lib in library browser
新建根目录/子节点弹窗的「创建」按钮 onclick 调 window.__lib.submitCreate,
但该函数虽已定义却漏挂到 __lib 导出表,导致点击报 "submitCreate is not
a function"。补挂即可。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:39:38 +08:00
58c81d4379 Merge remote-tracking branch 'origin/main' into feat/member-group-hierarchy 2026-07-24 00:00:50 +08:00
bai d16bd4899d Merge pull request 'feat(database): init database folder frontend and permission' (#1) from maoyuanyang/curriculum-project-hub:main into main
Reviewed-on: bai/curriculum-project-hub#1
2026-07-23 23:44:17 +08:00
192cd43245 feat(hub): add global nestable member group hierarchy
Global, unlimited-depth member groups managed by the platform super
admin (requirement 3.1-3.3). Stores membership + nesting only, never
permission data; exposes user -> ancestor-closed group set.

- MemberGroup: soft delete via archivedAt; parentId FK RESTRICT.
  Deleting a group cascade-soft-deletes its whole subtree as an
  application operation, not a DB cascade.
- MemberGroupMembership: user<->group many-to-many, revokedAt soft
  delete, user/group indexed for resolution hot path.
- MemberGroupClosure: transitive closure (depth-0 self rows) for
  one-join ancestor/descendant resolution; maintained on
  create/reparent with a cycle guard.

Permission side (GROUP principal, FOLDER resource, grant inheritance)
is deferred. This principal is deliberately not org-scoped and will
need ADR-0028 to supersede the ADR-0020 cross-org invariant before the
GROUP principal ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:31:26 +08:00
12628c9233 feat(hub): migrate database admin pages to SPA (database-admin)
- scaffold hub/database-admin as SvelteKit 2 + Svelte 5 static SPA
  with aurora/glass visual style (paths.base='/database')
- add lib/{api,session,org}.ts + Aurora.svelte component
- add routes: root redirect, /admin login page, /dashboard (OWNER/ADMIN only)
- backend: replace server-rendered HTML routes with /database/config JSON endpoint
- add hub/src/database/static.ts to serve SPA under /database/*
- wire registerDatabaseSpa into plugin.ts
- exempt /database/* from silo rate-limit (same treatment as /admin/*)
- add database:dev + database:build npm scripts; update deploy scripts
- update hub/src/database/README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:51:53 +08:00
78 changed files with 4105 additions and 2792 deletions
@@ -1,10 +0,0 @@
{
"sessionID": "ses_0705f8afbffePWIq7GrFfwfFwV",
"updatedAt": "2026-07-23T15:40:15.630Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-23T15:40:15.630Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_07252990dffeRFmXnvdyynty2z",
"updatedAt": "2026-07-23T06:33:33.086Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-23T06:33:33.086Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_075d214a0ffe6mU8VNgukUfH5L",
"updatedAt": "2026-07-22T14:15:01.741Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-22T14:15:01.741Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_075d3282bffejz0HFE00taOaGM",
"updatedAt": "2026-07-22T14:13:54.785Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-22T14:13:54.785Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_0781484caffeKJHXVKiD4BqEJw",
"updatedAt": "2026-07-22T06:17:18.238Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-22T06:17:18.238Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_07b163c13ffeudULxSfIfK986T",
"updatedAt": "2026-07-21T13:55:58.878Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-21T13:55:58.878Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_07b1e6c8effeq7bdmj6NgO0LqC",
"updatedAt": "2026-07-21T13:38:01.132Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-21T13:38:01.132Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_07b932dddffecSipFLwlJOvoJ9",
"updatedAt": "2026-07-21T11:27:10.420Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-21T11:27:10.420Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_07b9c4268ffeic4mMXCPUt225j",
"updatedAt": "2026-07-21T11:18:53.449Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-21T11:18:53.449Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_07b9c4346ffeVBdV5pf4h6s9PW",
"updatedAt": "2026-07-21T11:18:29.979Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-21T11:18:29.979Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_07b9c9a75ffeOyJ4ewp3DBvXhz",
"updatedAt": "2026-07-21T11:21:09.776Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-21T11:21:09.776Z"
}
}
}
@@ -1,10 +0,0 @@
{
"sessionID": "ses_07cdbac0effeiBrdcHLUUUfwuF",
"updatedAt": "2026-07-23T15:37:59.918Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-07-23T15:37:59.918Z"
}
}
}
-114
View File
@@ -1,114 +0,0 @@
# 文件库 · 开工计划(v1.0
> 配套文档:《文件库-接口契约.md》(v0.1,仓库根目录)。本计划已吸收规划顾问评审意见,包含开工前必须先冻结的**架构收口决策 D11–D19**(回写契约 v0.2 时并入)。
> 工期按 1 人全栈估算;前端从 Phase 2 后可并行。
## 开工判定
**可以开工。** 四个外部依赖(版本包 / Group / 审计 / 平台身份)全部有契约可依、可 mock 并行开发;无阻塞项。唯一前置:本文件的 D11–D19 决策在写第一行 schema 前过一遍(半天,自查即可,有异议再升级)。
## 架构收口决策(新增,先冻结再动工)
| 编号 | 决策 | 理由 |
|---|---|---|
| D11 | `creator` 是 Node 的**不可变列**,创建时同时落一条 Manage grant;独立权限开关关闭时,**creator 的 Manage 仍生效**,其余项目级 grant 冻结不参与计算 | 否则 creator 可能被自己关掉的开关锁死 |
| D12 | 移动节点授权 = **本节点 Manage + 目标父 Edit+**;移动在事务 + Postgres 咨询锁内完成,防并发成环 | 预检环检测在并发下不安全 |
| D13 | Group resolve 失败返回 **503**(不伪装 404/403);每次 HTTP 请求 fresh resolve,不跨请求缓存;单请求内多次校验合并为一次调用(此点与 Group 团队确认,OPEN-9) | 依赖故障 ≠ 无权限;契约 G4 要求实时 |
| D14 | 命名规则:NFC 归一化、trim、≤128 字符、禁 `/` 与控制字符;**活跃兄弟节点大小写不敏感唯一**;根节点全局唯一 | 缺命名规则必有脏数据 |
| D15 | 删除 = 只给被删节点打标,后代**不递归打标**,靠"任一祖先已删"过滤;所有查询链路强制此过滤(含递归 SQL,不只靠 Prisma middleware) | 递归打标的写放大与恢复复杂度不可控 |
| D16 | `baseVersion` 为**文件级版本**GET file 返回的 version);无关文件的提交不产生冲突 | 与 C1 `head(dir, filePath)` 语义一致 |
| D17 | breadcrumb 只暴露用户有 View 的祖先名称;无权限祖先显示占位符 `…`,不暴露名字 | 404 不泄露语义(D8)延伸到面包屑 |
| D18 | v1 **单副本部署**(git 仓库在本地磁盘);多副本需共享存储或分片,后置 | 不解决不存在的扩展问题 |
| D19 | 网站管理员**不隐式读内容**force_adjust 凭 node id 操作(id 从审计或用户上报获得) | C4 已定的最小权限原则 |
## 阶段计划
### Phase 0 · 骨架与身份先行(1–2 天)
- [ ] 过 D11D19,回写契约 v0.2
- [ ] repo 骨架 `server/` + `web/`Postgres docker-composeCIlint / typecheck / vitest / 集成测试用真实 Postgres service container
- [ ] **JWT 中间件 + 本地 JWKS 测试服务 + 签名 token fixtures**(有效/过期/错 issuer/错 audience/错算法/未知 kid/轮换)——身份先行,不做"宽松 mock 身份"
- [ ] 4 个 port 接口定义 + mock`VersionStore``GroupResolver``AuditSink`(直写 outbox 表的实现就是真的,mock 的是对端服务)、`ExportAdapter`
- [ ] fastify-swagger 接入,OpenAPI 骨架——**OpenAPI 随每个端点同步产出,不留到最后**
- **验收**:骨架可 `pnpm dev` 起服务;本地 JWKS 六类 token fixture(有效/过期/错 issuer/错 audience/错算法/未知 kid)中间件行为全对;CI 流水线绿
### Phase 1 · 数据模型与权限引擎(2–3 天)⚑ 心脏
- [ ] Prisma schema`Node`parentId **权威** + id 编码的 materialized path 派生列,name 不入 path)、`Grant``ProjectIndependentSettings``OutboxEvent`(含 attempts/nextAttemptAt/lease/lastErrorrelay 字段一次到位)、`ExportJob`
- [ ] 数据库约束:kind 枚举、项目无子节点、活跃兄弟名唯一(部分唯一索引,root 的 NULL parent 特殊处理)、活跃 grant 按 (nodeId, principalType, principalId) 唯一、独立设置仅项目
- [ ] **纯权限 reducer**:输入已解析的 grants + 祖先链 + 用户组集合,输出 role|null;不碰 DB/网络。fast-check 属性测试:max 单调、加 grant 只升不降、祖先继承、toggle 行为、顺序无关、软删过滤
- [ ] 数据获取层 + `effective(user, node)` 组装
- [ ] 树操作服务:createcreator 自动 Manage、root 仅管理员且必带 ≥1 Manage)、rename、moveD12 事务+咨询锁)、soft deleteD15)、breadcrumbD17
- **验收**:并发对移(A→B 与 B→A)、移动+并发建子、property tests、删除祖先过滤,真实 Postgres 全绿
### Phase 2 · 树与授权 API12 天)
- [ ] nodes 端点组 + grants 端点组 + independent-permission 开关 + effective-permission 自查
- [ ] 授权矩阵校验(契约 8.1creator-only 授 Manage、Manage 不可动 creator、force_adjust 独立通道)
- [ ] 全部写操作落 outbox 事件(C3 词汇表)
- **验收**:契约 8.1 矩阵逐格 API 测试;D8 的 404/403/503 三分语义测试
### Phase 3 · 仓库生命周期与文件 API(2 天)
- [ ] 项目 provisioning 状态机 `PROVISIONING → READY | FAILED`:先建 DB 行(PROVISIONING)→ 调 `VersionStore.init`(幂等)→ 置 READY;失败可重试;孤儿仓库对账 job
- [ ] 每项目写锁(Postgres 咨询锁,跨进程安全)——不假设版本包能跨进程串行
- [ ] 路径安全:canonical 后必须落在项目根内、拒绝 `.git`、禁 symlink 逃逸、路径长度上限
- [ ] 上传限额(单文件 10MB / 单项目配额,数值 OPEN-5)+ 流式接收
- [ ] files 端点组全量(list/read/upload/commits→409/diff/history/delete+ 双客户端冲突 e2e
- **验收**DB/git 故障注入(init 失败、提交后崩溃)→ 对账能收敛;路径穿越语料库测试全拒
### Phase 4 · 外部集成(12 天)
- [ ] `GroupResolver` HTTP client:超时/5xx/429/畸形 JSON/万级 group 的故障注入测试;失败 → 503(D13)
- [ ] Audit relay worker:租约领取、指数退避、eventId 幂等、重启恢复、积压告警
- [ ] 导出 job:状态机 + 轮询 + 下载权限校验;`ExportAdapter` 桩(参数 OPEN-6 未定前**不计入完成标准**)
- **验收**:relay 重启不丢不重(对端幂等);group 故障时写操作 503、读操作按 D13
### Phase 5 · 前端(35 天,Phase 2 后并行启动)
- [ ] 登录(平台 SSO 占位 + 本地 JWKS 直通开关)
- [ ] 树浏览(懒加载 + 分页)、breadcrumb(D17 占位符)、创建对话框(类型 + 批量授权选择器,Group search 接 C2
- [ ] 权限管理面板(按 8.1 矩阵控制可选项,creator 标识)
- [ ] 文件页:查看/上传/下载/历史列表(编辑 UI 不归我方,留对接位)
- [ ] 导出按钮 + job 轮询
- **验收**:无权限节点全链路不可见;授权面板不会送出矩阵禁止的组合
### Phase 6 · 硬化与交付(12 天)
- [ ] 404/403/503 全端点核对;并发与重试幂等抽查
- [ ] 备份脚本(DB + git 仓库一致性快照)、健康检查(DB/JWKS/磁盘/Group)、磁盘水位告警
- [ ] OpenAPI 终稿 + 部署脚本 + 一页运维手册
## 里程碑
| 里程碑 | 内容 | 累计工期 |
|---|---|---|
| M1 | Phase 0–1:骨架 + 权限引擎 + 树操作可跑 | ~4 天 |
| M2 | Phase 2–3:后端 API 全量(含文件冲突流) | ~7 天 |
| M3 | Phase 4:外部集成(导出不计) | ~9 天 |
| M4 | Phase 56:前端 + 硬化,可交付 | ~14–18 天 |
## Mock 保真红线(mock 可以顶,但不许骗)
| Mock | 不许掩盖的事 | 真身到位后的契约测试 |
|---|---|---|
| VersionStore | 磁盘延迟、半初始化、锁残留、跨进程并发 | 真包跑临时仓库:并发 commit/init 重试/遍历与 symlink 语料 |
| GroupResolver | 超时、5xx、畸形响应、大规模组集 | 故障注入 HTTP 桩全过;我方绝不自己推祖先 |
| GroupSearch(前端选择器) | 与 resolve 是**两个独立端点**,不可用 resolve mock 顶替 | C2 `/groups/search` 真身到位后跑分页/空结果/超时 |
| Audit 对端 | 重复投递、乱序、长时间不可用 | relay 重启恢复 + 对端幂等 |
| JWT | issuer/audience/算法/轮换 | 本地 JWKS 全用例 |
| ExportAdapter | 参数、幂等、耗时 | 参数定了再写,之前不算完成 |
## Top 5 风险
1. **DB/git 分裂**(崩溃导致元数据、仓库、审计三边不一致)→ provisioning 状态机 + 对账 jobPhase 3 前置
2. **移动子树改变整树权限** → D12 事务+锁+源目标双授权,并发测试
3. **creator/独立权限开关交互** → D11 先冻结
4. **外部契约缺口**(导出参数、请求内 resolve 合并)→ OPEN 清单跟踪,mock 保真红线不许掩盖
5. **存储滥用/路径逃逸** → 路径安全 + 配额进 Phase 3 验收,不拖到硬化
## OPEN 清单(在契约文档基础上增补)
- OPEN-9:单请求内合并多次 resolve 是否符合 G4"实时"语义(找 Group 团队确认)
- OPEN-10:恶意软件扫描归属(我方/平台/不做)
- 其余 OPEN-1~8 见《文件库-接口契约.md》第 10 节
+4
View File
@@ -99,3 +99,7 @@ _Avoid_: Cost budget, unlimited run
**Emergency Workload Brake**:
An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident.
_Avoid_: Organization deletion, service restart
**Member Group**:
A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0028). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization.
_Avoid_: Team (the org-scoped flat grouping), Feishu department
@@ -0,0 +1,153 @@
# ADR 0028: Member Group Management And Resolution
## Status
Accepted.
## Context
ADR-0020 fixed `Organization` as the tenant root and ADR-0019 pinned the
principal-set permission model. The file library (《文件库-接口契约.md》) computes
effective permission over two principal kinds — `USER` and `GROUP` — and consumes
the group side through a single read-only port, `GroupResolver`
(`resolveMemberGroupIds(userId) → groupIds[]`, contract C2/G2).
The contract's v0.1 proposal framed the Group system as a *separate HTTP service*
owned by another team, consumed read-only. In practice the schema now carries the
group tables directly in the hub database (`MemberGroup`, `MemberGroupMembership`,
`MemberGroupClosure` — a global, unlimited-depth, closure-backed hierarchy), and
the product requirement is to build **group management in the backend admin**, not
to integrate a foreign service. Until this ADR, nothing read or wrote those tables:
the live `GroupResolver` was a transitional implementation reading flat hub `Team`
membership, and the admin "Group 管理" panel actually managed `Team`.
This ADR settles the semantics needed to make the `MemberGroup` tables the real,
in-hub group system.
## Decision
### Group system is in-hub, not a foreign service
`MemberGroup` is the platform's global member-group principal. It lives in the hub
database and is managed through the `/database` backend. The contract's "separate
service" framing was an unfrozen v0.1 proposal; the implementation aligns to the
tables that were actually built. The `GroupResolver` port stays — an external
`HUB_GROUP_SERVICE_URL` HTTP implementation remains a supported override — but the
default implementation reads the in-hub `MemberGroup` closure.
### Authority: website administrator only
Group create/delete and member add/remove are restricted to the **website
administrator**, defined (consistently with the rest of the file library, D19/C4
adaptation) as an `OWNER`/`ADMIN` of the silo Organization (`isWebsiteAdmin` in
`filelib/guards.ts`). ADR-0023's `PlatformIdentity` is the future "true" platform
control plane; the file library uniformly uses org OWNER/ADMIN today and this
feature stays consistent with that. Reading groups for the authorization selector
(`/groups/search`) is **not** admin-gated — picking a group to grant is a Manage
holder's ability, not an administrator's.
### Resolution semantics (the crux)
`resolveMemberGroupIds(user)` returns the user's **active direct groups the
active ancestors of those groups**, deduplicated (the closure's depth-0 self row
makes each direct group its own ancestor). This is the single query the permission
engine relies on; equivalently: a grant placed on group G applies to members of G
and of every descendant of G (requirement 3.2 — permission flows down the tree, so
resolution collects up the tree). It is computed **live, never cached** (contract
D4/G4): a membership change is visible on the very next protected request.
MemberGroup is global (no `organizationId`), so resolution is not org-scoped.
### Soft delete via `archivedAt`, cascading the subtree
Delete is soft: `MemberGroup.archivedAt` is a tag. Deleting a group
cascade-soft-deletes its **whole subtree** (walk `MemberGroupClosure` where
`ancestorId = G`, stamp `archivedAt` on each active descendant) — an application
operation, not a DB constraint. Closure and membership rows are **retained**;
resolution and listing filter by `archivedAt`, so an archived group and everything
under it stop contributing to permission at once.
### Closure maintenance
The closure is maintained on **create**: insert `(G, G, 0)`, then for a parent `P`
insert `(a.ancestorId, G, a.depth + 1)` for every `a` in
`closure where descendantId = P`. v1 does **not** support reparenting a group
(moving it under a new parent). The schema reserves reparent (closure rebuild plus
the cycle guard "reject a new parent inside the moved subtree"); it is a follow-on.
### Rename and description edits are in scope; reparent stays out
A group's `name` and `description` are mutable by the website administrator
(`PATCH /database/api/groups/:id`, audited as `group.update`). This is deliberately
separated from reparent: renaming touches **no** closure row and cannot create a
cycle, so it carries none of the invariant risk that keeps reparent out of v1. The
endpoint therefore **rejects** a `parentId` field outright rather than ignoring it,
so a future reparent cannot arrive silently through this route. Passing an empty
`description` clears it; omitting a field leaves it unchanged.
### Restore is deliberately asymmetric with delete
Archived groups stay visible to the administrator (`GET
/database/api/groups?includeArchived=1` returns them carrying `archivedAt`; the
console tags and greys them) and can be restored (`POST
/database/api/groups/:id/restore`, audited as `group.restore`).
Restore is **not** the mirror image of delete. Delete cascades down the whole
subtree; restore un-archives **the group plus every archived ancestor of it, and
nothing below it**:
- Restoring the ancestor chain is **mandatory**, not a convenience. An active group
whose parent is archived has no path in the tree, and the `depth` derivation
(closure row count) presumes "an active group's ancestors are active" — the
invariant that cascade-delete establishes. Restoring a node alone would break it.
- The subtree is deliberately **left archived**. A group's descendants may have been
archived for reasons of their own, and one click should not silently re-grant
permission across a whole historical branch. Descendants remain visible in their
archived state and are each restored explicitly.
Restore takes effect immediately, like every other membership change (D4/G4): the
group resumes contributing permission on the next resolution.
An archived group is **readable but not writable**. Its membership rows are never
revoked by archiving, so `listMembers` succeeds on an archived group — the console
must be able to show *who was in it* before deciding whether to restore it. Every
mutation, by contrast, still requires an active group (`requireActiveGroup` → 404):
rename, child creation, and member add/remove all reject. The group is inert for
permission purposes and frozen for editing, but not hidden and not forgotten.
### Member picker reads global users, admin-only
`GET /database/api/users/search` backs the "add member" picker: it matches `User`
by display name or Feishu open id and is gated to the website administrator, the
same authority that may add members. It widens no existing capability — adding a
member already accepts **any** global user (`resolveUser` does not require an org
membership), so the endpoint only replaces blind id entry with search. It is
deliberately **not** opened to the non-admin authorization-selector audience that
`/groups/search` serves: choosing a group to grant is a Manage-holder action,
whereas enumerating people is not. `excludeGroupId` filters out the target group's
active members so the picker cannot surface a candidate that must 409.
### Audit is written in-hub
The contract (C3 §6.3) originally deferred group actions to the foreign Group
service's own audit. With the group system in-hub, group mutations are audited
through the existing file-library sink (`filelib/audit.ts`, same-transaction
`AuditEntry`) under the silo Organization — `MemberGroup` has no `organizationId`,
so the audit row is attributed to the silo org. New actions: `group.create`,
`group.update`, `group.delete`, `group.restore`, `group.member_add`,
`group.member_remove`; new audit object type `group`.
## Consequences
- The default `GroupResolver` becomes the in-hub `MemberGroup` closure reader.
`createTeamGroupResolver` is retained but deprecated (no longer wired); existing
flat-Team group grants no longer resolve for the file library.
- Group grants take effect in real time through the existing `effectiveRole`
reducer (P6) with no change to the permission algebra — only the set of group ids
fed to it changes.
- v1 omits reparent; the closure invariants above must hold whenever reparent is
added later (rebuild descendants' ancestor rows, reject cycles).
- Group management is an admin-only surface; the authorization selector is not.
- Numeric limits (max depth, max members) and a hard-delete/restore path remain
follow-on operational decisions; they must not weaken the archived-filter,
admin-authority, or live-resolution invariants fixed here.
@@ -0,0 +1,132 @@
# ADR 0029: Web Surfaces Are Static SPAs; the Hub Serves JSON Only
## Status
Accepted.
## Context
The Hub exposes three browser surfaces: the org-admin console (`/admin`), the
teacher-facing file library (`/app`), and the database admin back office
(`/database`). They arrived at different times and diverged in how HTML reached
the browser.
`/admin` and `/app` were already separated: the backend serves a prebuilt static
`index.html` and never inspects the request; all data flows through JSON
endpoints. `/database` was not. Roughly 1770 lines across four modules
(`renderDashboard`/`renderLoginPage` in `routes/databaseRoutes.ts`,
`routes/adminPanels.ts`, `routes/libraryBrowser.ts`, `routes/libraryPage.ts`)
assembled HTML template strings server-side, reading the session cookie and
querying Prisma inside the page handler, with layout expressed as inline
`style="…"` attributes and behavior as `<script>` text.
A prior migration (`12628c9`) introduced a fourth frontend project,
`hub/database-admin/`, intended to replace those pages. It was never wired up:
the concrete route `/database/dashboard` is more specific than the SPA wildcard
`/database/*`, so the server-rendered handler always won and the SPA's dashboard
was unreachable. That project's file header claimed the SPA served the dashboard
and that `/database/config` existed; neither was true. The `npm run build` script
also never built it, so the `existsSync` guard in `database/static.ts` failed on
every deploy and the shell was permanently disabled.
Duplicated visual rules were the practical cost: card padding and type sizes were
restated in each render module, and only the CSS variables in `routes/uiTheme.ts`
were genuinely shared.
## Decision
**No Hub HTTP handler renders HTML.** Every browser surface is a prebuilt static
SPA. Page handlers send a byte-identical `index.html` that does not depend on the
request; all per-user and per-request data is fetched by the client from JSON
endpoints under `/api/*` or `/database/api/*`.
**`/app` and `/database` are one frontend project, `hub/filelib-web`, built once
and mounted at two prefixes.** They share the file library browser, the session
layer, the toast host, and the design tokens; splitting them would duplicate all
of it. `hub/database-admin` is deleted — superseded before it ever served a
request.
Two configuration constraints follow from co-hosting two SvelteKit SPAs on one
Fastify instance, and are load-bearing:
- `filelib-web` sets `appDir: '_filelib'`. The SvelteKit default `_app` collides
with the root `/_app/*` asset route that `admin-web` owns
(`src/admin/static.ts`); Fastify rejects duplicate routes at startup, so the
collision is a boot failure, not a silent misroute.
- `filelib-web` sets `paths.relative: false`. The same `index.html` is served at
different URL depths (`/app`, `/database/dashboard/users`), so relative asset
paths would resolve against the wrong base.
**Client-side navigation uses real URL routes, not hash fragments or hidden
sections.** The six back-office tabs are `/database/dashboard`,
`/database/dashboard/library`, `/users`, `/groups`, `/search`, `/settings`.
Refresh preserves position and links are shareable — the previous
`location.hash` + `display:none` scheme lost both.
Concrete routes must be registered before the SPA wildcards. This is an ordering
obligation on `database/plugin.ts`, not an incidental detail: the earlier
`/database/dashboard` shadowing bug is exactly what happens when a concrete page
route outranks the fallback.
## Consequences
- Authorization is enforced only by the JSON endpoints. A client-side guard (the
`isWebsiteAdmin` check in the dashboard layout) is a navigation convenience and
carries no security weight; every endpoint keeps its own `fail closed` guard.
- `/database/api/stats` is a new endpoint carrying what `loadDashboardStats` used
to compute inline. It requires silo org `OWNER`/`ADMIN` because it aggregates
org-wide counts and the audit stream rather than a per-node permission view.
- `/database/api/me` grew `displayName` and `avatarUrl`. Anything the old page
handler read from Prisma to render chrome has to become part of a JSON payload
or it is simply unavailable: the sidebar identity strip showed a raw `userId`
until these were added. When migrating a server-rendered surface, the data the
template closed over is part of the contract being ported, not an incidental
detail of the old implementation.
- Editing a page no longer requires a Hub restart in development; `vite dev`
serves the frontend and proxies data requests to the Hub. In production the
`index.html` is cached in memory at startup, so a frontend rebuild does require
a restart.
- Deploy scripts and the silo rate-limit exemption list name `filelib-web` and
`/_filelib/*`. Adding a fourth surface means picking another `appDir` and
extending that list.
- The design system is one file, `filelib-web/src/app.css`: an `@theme` block for
tokens plus an `@layer components` block for the shared component classes
(`.btn`, `.panel`, `.input`, `.select`, `.list`, `.tag`, `.quiet`, …).
`routes/uiTheme.ts` is deleted; both halves live there now.
The first cut of this migration kept only the tokens and restated button,
input, and panel styling inline in every component. That reproduced the
duplication the old code had — the admin panels visibly regressed — so the
component layer was ported too. Components carry layout utilities; they do not
restate component styling. The one admitted exception is a data-derived value
(tree indent computed from `depth`), which cannot be a static class.
The icon set (`lib/Icon.svelte`, 13 paths) is likewise shared rather than
restated. It came from `adminPanels.ts`; Group nodes deliberately use a
two-person silhouette, not a folder glyph, because `MemberGroup` and the file
library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0028, ADR-0021).
- **A migrated surface is only done when its endpoint coverage matches.** Two
panels were rebuilt from a superficially similar component that predated the
migration rather than from the server module they replaced, and the mismatch
was invisible in the rendered page:
- Group management called 5 of 8 endpoints. Rename (`PATCH`),
`?includeArchived=1`, `/restore`, and `/users/search` had no entry point, so
a soft-deleted group could not be restored through the UI at all even though
the backend fully supported it.
- The library browser dropped the `授权` tab entirely — `GET/PUT/DELETE
.../grants` and `PUT .../independent-permission` had no caller. Permission
editing is the point of the back office, and it was unreachable.
Diffing the route table against the frontend's `api()` call sites catches this;
reading the new page does not.
## Deferred
- `/admin` (admin-web) stays a separate project. It has its own design language
(`saas-*` classes, `surface-*`/`primary-*` scales) and a different audience;
merging it is not motivated by shared code.
- The `search` and `settings` tabs remain placeholders, as they were server-side.
- Serving `/admin` and `/database` from a single SPA, which would remove the
`appDir` collision constraint entirely.
+4
View File
@@ -8,6 +8,10 @@ dist/
.dev-keyring.json
.dev-workspaces/
.dev-skills/
.filelib-repos/
admin-web/node_modules/
admin-web/build/
admin-web/.svelte-kit/
filelib-web/node_modules/
filelib-web/build/
filelib-web/.svelte-kit/
+4 -1
View File
@@ -82,10 +82,11 @@ REMOTE
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
--exclude filelib-web/node_modules --exclude filelib-web/build --exclude filelib-web/.svelte-kit \
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web SPA)"
echo "[fleet] npm ci + build (tsc + admin-web & filelib-web SPAs)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
@@ -97,9 +98,11 @@ flock /var/lock/cph-hub-release-publish bash -c '
cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web
npm ci --prefix filelib-web
npm run audit:production
npm run build
test -f admin-web/build/index.html
test -f filelib-web/build/index.html
touch "$RELEASE_DIR/.complete"
'
REMOTE
+4 -3
View File
@@ -60,10 +60,11 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
# 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete.
# `npm run build` → tsc then admin:build + filelib:build → admin-web/build and
# filelib-web/build for registerStaticSpa / registerDatabaseSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm ci --prefix filelib-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart.
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>文件库</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+137
View File
@@ -8,6 +8,8 @@
"name": "filelib-web",
"version": "0.1.0",
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"svelte": "^5.56.1",
@@ -130,6 +132,13 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -412,6 +421,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
@@ -422,6 +438,58 @@
"acorn": "^8.9.0"
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/kit": {
"version": "2.70.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz",
"integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.9",
"@types/cookie": "^0.6.0",
"acorn": "^8.16.0",
"cookie": "^0.6.0",
"devalue": "^5.8.1",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
"mrmime": "^2.0.0",
"set-cookie-parser": "^3.0.0",
"sirv": "^3.0.0"
},
"bin": {
"svelte-kit": "svelte-kit.js"
},
"engines": {
"node": ">=18.13"
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3 || ^6.0.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@sveltejs/load-config": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz",
@@ -747,6 +815,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -820,6 +895,16 @@
"node": ">=6"
}
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -946,6 +1031,16 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1246,6 +1341,16 @@
"node": ">=4"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@@ -1389,6 +1494,28 @@
"node": ">=6"
}
},
"node_modules/set-cookie-parser": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
"integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
"dev": true,
"license": "MIT"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1490,6 +1617,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+5 -2
View File
@@ -4,12 +4,15 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"svelte": "^5.56.1",
-30
View File
@@ -1,30 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { api, UnauthenticatedError } from "./lib/api.js";
import { me, authChecked } from "./lib/stores.js";
import type { MeResponse } from "./lib/types.js";
import LoginView from "./lib/LoginView.svelte";
import BrowserShell from "./lib/BrowserShell.svelte";
import Toasts from "./lib/Toasts.svelte";
onMount(async () => {
try {
me.set(await api<MeResponse>("/database/api/me"));
} catch (e) {
if (!(e instanceof UnauthenticatedError)) console.error(e);
me.set(null);
} finally {
authChecked.set(true);
}
});
</script>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
{:else if $me}
<BrowserShell />
{:else}
<LoginView />
{/if}
<Toasts />
+199 -2
View File
@@ -23,8 +23,7 @@
}
html,
body,
#app {
body {
height: 100%;
}
@@ -47,6 +46,204 @@ body {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* 共享组件层(ADR-0029)。
*
* 从已删除的 hub/src/database/routes/uiTheme.ts 原样搬来。组件只带布局工具类,
* 不重述这里的组件样式 —— 第一版迁移只搬了上面的 @theme 令牌,把按钮/输入框/
* 面板样式在每个组件里内联重写了一遍,后台随即明显退化。 */
@layer components {
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--color-line);
background: var(--color-panel);
color: var(--color-ink);
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: all 120ms ease;
white-space: nowrap;
}
.btn:hover {
background: var(--color-hover);
}
.btn-sm {
padding: 4px 9px;
font-size: 11.5px;
}
.btn-primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.btn-primary:hover {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
.btn-danger {
border-color: transparent;
background: transparent;
color: var(--color-danger);
}
.btn-danger:hover {
background: color-mix(in srgb, var(--color-danger) 7%, transparent);
}
.panel {
background: var(--color-panel);
border: 1px solid var(--color-line-soft);
border-radius: 10px;
padding: 20px 22px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10.5px;
font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--color-line-soft);
color: var(--color-ink-3);
background: var(--color-panel);
}
.input,
.select,
.textarea {
width: 100%;
padding: 7px 11px;
border-radius: 8px;
border: 1px solid var(--color-line);
background: var(--color-panel);
font-size: 13px;
color: var(--color-ink);
font-family: inherit;
outline: none;
transition: border-color 120ms ease;
}
.input:focus,
.select:focus,
.textarea:focus {
border-color: var(--color-accent);
}
.textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.75;
resize: vertical;
}
.form-label {
display: block;
font-size: 11.5px;
color: var(--color-ink-3);
margin-bottom: 4px;
}
.form-row {
margin-bottom: 12px;
}
table.list {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table.list th {
text-align: left;
font-size: 11.5px;
font-weight: 500;
color: var(--color-ink-3);
padding: 4px 0;
}
table.list td {
padding: 8px 0;
border-top: 1px solid var(--color-line-soft);
}
table.list tr:first-child td {
border-top: none;
}
.quiet {
color: var(--color-ink-3);
font-size: 12.5px;
}
.section-title {
font-size: 13px;
font-weight: 600;
}
.section-note {
font-size: 11.5px;
color: var(--color-ink-3);
}
.file-meta {
font-size: 11px;
color: var(--color-ink-3);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.link-danger {
color: var(--color-danger);
font-size: 12.5px;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.link-danger:hover {
text-decoration: underline;
}
/* 开关。真 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(--color-line);
transition: background 0.16s;
}
.switch > span::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
transition: transform 0.16s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.switch > input:checked + span {
background: var(--color-accent);
}
.switch > input:checked + span::after {
transform: translateX(13px);
}
.switch > input:focus-visible + span {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
/* diff 渲染 */
pre.diff {
white-space: pre-wrap;
+12
View File
@@ -0,0 +1,12 @@
// See https://svelte.dev/docs/kit/types#app
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" style="height: 100%">
<div style="display: contents; height: 100%">%sveltekit.body%</div>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
/** 头像:有 avatarUrl 用图,否则显示首字母色块。 */
let {
displayName,
userId,
avatarUrl = null,
size = 28,
}: {
displayName?: string | null;
userId?: string | null;
avatarUrl?: string | null;
size?: number;
} = $props();
const initial = $derived((displayName || userId || "?").slice(0, 1).toUpperCase());
</script>
{#if avatarUrl}
<img
src={avatarUrl}
alt=""
class="shrink-0 rounded-full object-cover"
style="width:{size}px;height:{size}px"
/>
{:else}
<span
class="inline-flex shrink-0 items-center justify-center rounded-full bg-accent font-semibold text-white"
style="width:{size}px;height:{size}px;font-size:{Math.round(size * 0.42)}px"
aria-hidden="true">{initial}</span
>
{/if}
+15 -14
View File
@@ -3,6 +3,7 @@
import { toastOk, toastErr, toast } from "./stores.js";
import type { FileContent, VersionInfo, Role } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { projectId, path, role, onchanged, onclose }: { projectId: string; path: string; role: Role; onchanged: () => void; onclose?: () => void } = $props();
@@ -104,32 +105,32 @@
</script>
{#if loadError}
<div class="rounded-xl border border-line-soft bg-panel p-5 text-xs text-danger">{loadError}</div>
<div class="panel text-xs text-danger">{loadError}</div>
{:else if file}
<div class="rounded-xl border border-line-soft bg-panel p-5">
<div class="panel">
<div class="mb-2.5 flex items-center justify-between">
<span class="font-mono text-[11px] text-ink-3">{file.path} @ {file.version}</span>
<div class="flex items-center gap-2">
<a class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>下载</a>
<button class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" onclick={openHistory}>历史</button>
<span class="file-meta">{file.path} @ {file.version}</span>
<div class="flex items-center gap-1.5">
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>下载</a>
<button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button>
{#if canEdit}
<button class="rounded-lg border border-transparent px-3 py-1 text-[12px] font-medium text-danger transition hover:bg-[#A13A3312]" onclick={remove}>删除文件</button>
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除文件</button>
{/if}
{#if onclose}
<button class="rounded-lg border border-line bg-panel px-2.5 py-1 text-[12px] font-medium text-ink-3 transition hover:bg-hover hover:text-ink" onclick={onclose} title="关闭预览"></button>
<button class="btn !px-2.5" onclick={onclose} title="关闭预览" aria-label="关闭预览"></button>
{/if}
</div>
</div>
{#if file.encoding === "base64"}
<div class="text-xs text-ink-3">二进制文件({file.size} B),不支持在线编辑</div>
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
{:else}
<textarea rows="14" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs leading-7 outline-none focus:border-accent" bind:value={draft} readonly={!canEdit}></textarea>
<textarea rows="14" class="textarea !leading-7" bind:value={draft} readonly={!canEdit}></textarea>
{/if}
{#if canEdit && file.encoding !== "base64"}
<div class="mt-3 flex justify-end">
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={save}>提交修改</button>
<button class="btn btn-primary" onclick={save}>提交修改</button>
</div>
{/if}
@@ -139,13 +140,13 @@
<pre class="diff rounded-lg border border-line-soft bg-panel p-3">{@html renderDiff(conflict.diff)}</pre>
<div class="mt-2 text-[11.5px] text-[#8A8059]">请人工合并后,以最新内容为全文重新提交(基版将更新为 {conflict.currentVersion})</div>
<div class="mt-2 flex justify-end">
<button class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" onclick={acceptLatest}>载入最新内容</button>
<button class="btn" onclick={acceptLatest}>载入最新内容</button>
</div>
</div>
{/if}
</div>
{:else}
<div class="rounded-xl border border-line-soft bg-panel p-5 text-xs text-ink-3">加载中…</div>
<div class="panel quiet">加载中…</div>
{/if}
{#if showHistory}
@@ -159,7 +160,7 @@
{/each}
</div>
<div class="mt-3 flex justify-end">
<button class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" onclick={() => (showHistory = false)}>关闭</button>
<button class="btn" onclick={() => (showHistory = false)}>关闭</button>
</div>
</Modal>
{/if}
+23 -21
View File
@@ -4,6 +4,7 @@
import { selectedFilePath, filesVersion } from "./browser.js";
import type { FileEntry, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
@@ -12,7 +13,8 @@
let showNewFile = $state(false);
let newPath = $state("");
let newContent = $state("");
let uploadInput: HTMLInputElement;
// bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。
let uploadInput = $state<HTMLInputElement | null>(null);
const canEdit = $derived(node.role !== "VIEW");
@@ -84,34 +86,34 @@
}
</script>
<div class="rounded-xl border border-line-soft bg-panel p-5">
<div class="mb-2 flex items-center justify-between">
<div class="text-[13px] font-semibold">项目文件({files?.length ?? 0})</div>
<div class="panel">
<div class="mb-1.5 flex items-center justify-between">
<div class="section-title">项目文件({files?.length ?? 0})</div>
{#if canEdit}
<div class="flex gap-2">
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showNewFile = true)}>+ 新建文件</button>
<button class="rounded-lg bg-accent px-3 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={() => uploadInput.click()}>上传文件</button>
<div class="flex gap-1.5">
<button class="btn" onclick={() => (showNewFile = true)}><Icon name="plus" size={13} /> 新建文件</button>
<button class="btn btn-primary" onclick={() => uploadInput?.click()}>上传文件</button>
<input bind:this={uploadInput} type="file" class="hidden" onchange={doUpload} />
</div>
{/if}
</div>
{#if files === null && loadError === null}
<div class="py-5 text-center text-xs text-ink-3">加载中…</div>
<div class="quiet py-5 text-center">加载中…</div>
{:else if loadError}
<div class="py-5 text-center text-xs text-danger">{loadError}</div>
{:else if files && files.length === 0}
<div class="py-5 text-center text-xs text-ink-3">空仓库 · 可新建或上传文件</div>
<div class="quiet py-5 text-center">空仓库 · 可新建或上传文件</div>
{:else if files}
<table class="w-full border-collapse text-[13px]">
<table class="list">
<tbody>
{#each files as f (f.path)}
<tr
class="cursor-pointer border-t border-line-soft first:border-t-0 {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
onclick={() => selectedFilePath.set(f.path)}
>
<td class="py-2 font-mono text-[12.5px] text-ink">{f.path}</td>
<td class="py-2 text-right font-mono text-[11px] text-ink-3">{f.size} B</td>
<td class="font-mono text-[12.5px] text-ink">{f.path}</td>
<td class="file-meta text-right">{f.size} B</td>
</tr>
{/each}
</tbody>
@@ -121,17 +123,17 @@
{#if showNewFile}
<Modal title="新建文件" onclose={() => (showNewFile = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="nf-path">路径</label>
<input id="nf-path" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-[13px] outline-none focus:border-accent" bind:value={newPath} placeholder="docs/intro.md" />
<div class="form-row">
<label class="form-label" for="nf-path">路径</label>
<input id="nf-path" class="input font-mono" bind:value={newPath} placeholder="docs/intro.md" />
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="nf-content">内容</label>
<textarea id="nf-content" rows="8" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs outline-none focus:border-accent" bind:value={newContent} placeholder="内容…"></textarea>
<div class="form-row">
<label class="form-label" for="nf-content">内容</label>
<textarea id="nf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showNewFile = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={submitNewFile}>创建</button>
<button class="btn" onclick={() => (showNewFile = false)}>取消</button>
<button class="btn btn-primary" onclick={submitNewFile}>创建</button>
</div>
</Modal>
{/if}
+191
View File
@@ -0,0 +1,191 @@
<script lang="ts">
/**
* 节点授权面板。迁自已删除的 routes/libraryBrowser.ts `renderGrantsTab`(ADR-0029)。
*
* 迁移时整个「授权」tab 连同这四个端点一起漏掉了 —— 后端一直可用,只是前端没入口。
*
* 语义(契约 8.1 / ADR-0021):
* - 创建者授权(isCreatorGrant)不可收回、不可改;
* - MANAGE 仅创建者可授,这里不做前端拦截 —— 后端 fail closed,报错原样呈现;
* - GROUP 主体走 in-hub MemberGroup(ADR-0028),用 /groups/search 选,不手敲 id。
*/
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { currentNode } from "./browser.js";
import type { Grant, MemberGroupSearchResult, NodeDetail, Role } from "./types.js";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
const ROLES: readonly Role[] = ["VIEW", "EDIT", "MANAGE"];
let grants = $state<Grant[] | null>(null);
let error = $state<string | null>(null);
let principalType = $state<"USER" | "GROUP">("USER");
let userIdInput = $state("");
let groupId = $state("");
let groupOptions = $state<MemberGroupSearchResult[] | null>(null);
let role = $state<Role>("VIEW");
let saving = $state(false);
const canManage = $derived(node.role === "MANAGE");
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
$effect(() => {
void node.id;
void load();
});
async function load(): Promise<void> {
grants = null;
error = null;
try {
const r = await api<{ grants: Grant[] }>(`/database/api/nodes/${node.id}/grants`);
grants = r.grants;
} catch (e) {
error = errText(e);
}
}
/** 切到 GROUP 时懒加载候选组(活跃组 + breadcrumb)。 */
async function onTypeChange(): Promise<void> {
if (principalType !== "GROUP" || groupOptions !== null) return;
try {
const r = await api<{ groups: MemberGroupSearchResult[] }>("/database/api/groups/search?q=");
groupOptions = r.groups;
if (r.groups.length > 0 && groupId === "") groupId = r.groups[0]!.id;
} catch (e) {
toastErr(errText(e));
}
}
async function addGrant(): Promise<void> {
const principalId = principalType === "GROUP" ? groupId : userIdInput.trim();
if (principalId === "") {
toastErr("请填写主体");
return;
}
saving = true;
try {
// PUT /grants 是增量语义(putGrants),不是整表替换。
await api(`/database/api/nodes/${node.id}/grants`, {
method: "PUT",
body: { grants: [{ principalType, principalId, role }] },
});
toastOk("已授予");
userIdInput = "";
await load();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
async function revoke(g: Grant): Promise<void> {
if (!confirm(`收回「${g.principalId}」的 ${g.role} 授权?`)) return;
try {
await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, {
method: "DELETE",
});
toastOk("已收回");
await load();
} catch (e) {
toastErr(errText(e));
}
}
/** 独立权限开关(仅 PROJECT;关闭时只继承父级,创建者除外)。 */
async function toggleIndependent(): Promise<void> {
try {
await api(`/database/api/projects/${node.id}/independent-permission`, {
method: "PUT",
body: { enabled: !node.independentPermission },
});
toastOk("已切换");
currentNode.update((n) =>
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
);
await load();
} catch (e) {
toastErr(errText(e));
}
}
</script>
<div class="panel">
{#if error !== null}
<div class="py-2 text-[12.5px] text-danger">{error}</div>
{:else if grants === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else}
<table class="list">
<thead>
<tr><th>主体</th><th>级别</th><th></th></tr>
</thead>
<tbody>
{#if grants.length === 0}
<tr><td colspan="3" class="quiet !py-[18px] text-center">暂无显式授权</td></tr>
{:else}
{#each grants as g (g.id)}
<tr>
<td>
<span class="inline-flex items-center gap-2">
<span class="flex text-ink-3"><Icon name={g.principalType === "USER" ? "user" : "group"} size={14} /></span>
<span class="font-mono text-[12px]">{g.principalId}</span>
{#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if}
</span>
</td>
<td class="file-meta">{g.role}</td>
<td class="text-right">
<!-- 创建者授权不可动(契约 8.1);非 MANAGE 也不给收回入口。 -->
{#if !g.isCreatorGrant && canManage}
<button class="link-danger inline-flex items-center gap-1" onclick={() => void revoke(g)}>
<Icon name="minus" size={12} /> 收回
</button>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{/if}
{#if canManage}
<div class="my-3.5 border-t border-line-soft"></div>
<div class="section-title mb-2.5">新增授权</div>
<div class="flex flex-wrap items-center gap-2">
<select class="select !w-[110px]" bind:value={principalType} onchange={onTypeChange}>
<option value="USER">用户</option>
<option value="GROUP">Group</option>
</select>
{#if principalType === "USER"}
<input class="input min-w-0 flex-1" placeholder="用户 id" bind:value={userIdInput} />
{:else if groupOptions === null}
<span class="quiet flex-1">加载 Group 列表…</span>
{:else if groupOptions.length === 0}
<span class="quiet flex-1">暂无可选 Group · 先到「Group 管理」建一个</span>
{:else}
<select class="select min-w-0 flex-1" bind:value={groupId}>
{#each groupOptions as g (g.id)}
<option value={g.id}>{g.breadcrumb}</option>
{/each}
</select>
{/if}
<select class="select !w-[110px]" bind:value={role}>
{#each ROLES as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
<button class="btn btn-primary disabled:opacity-50" onclick={addGrant} disabled={saving}>
{saving ? "授予中…" : "授予"}
</button>
</div>
<div class="section-note mt-1.5">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>
{/if}
</div>
+762
View File
@@ -0,0 +1,762 @@
<script lang="ts">
/**
* Group 管理面板。从已删除的 routes/adminPanels.ts `renderGroupsPanel`(747 行)
* 迁来(ADR-0029),功能与视觉逐条对齐:折叠树 / 组名过滤(命中项保留祖先链)/
* 归档组展示与恢复 / 右键菜单 / 面包屑 / 统计条 / 成员表(头像·openId·加入时间)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { toastErr, toastOk } from "./stores.js";
import type { MemberGroupNode, MemberGroupMember, UserSearchResult } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
import Avatar from "./Avatar.svelte";
// 后端返回扁平列表(ADR-0028);前端按 parentId/depth 拼成有序树。
let groups = $state<MemberGroupNode[]>([]);
let loaded = $state(false);
let listError = $state<string | null>(null);
let selectedId = $state<string | null>(null);
// 折叠的组 id(默认全展开)。用数组而非 Set:$state 的深层代理只跟踪普通对象/
// 数组,Set 的变更不会触发重渲染。
let collapsedIds = $state<string[]>([]);
const isCollapsed = (id: string): boolean => collapsedIds.includes(id);
const toggleCollapsed = (id: string): void => {
collapsedIds = isCollapsed(id) ? collapsedIds.filter((x) => x !== id) : [...collapsedIds, id];
};
let filterText = $state("");
let showArchived = $state(false);
let members = $state<MemberGroupMember[]>([]);
let membersLoaded = $state(false);
let membersError = $state<string | null>(null);
let memberFilter = $state("");
const selected = $derived(groups.find((g) => g.id === selectedId) ?? null);
const isArchived = $derived(selected?.archivedAt != null);
interface Row {
readonly g: MemberGroupNode;
readonly hasKids: boolean;
/** 过滤态下:自身是否命中(祖先链上的非命中项半透明显示)。 */
readonly hit: boolean;
}
/** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
const rows = $derived.by((): Row[] => {
const byParent = new Map<string | null, MemberGroupNode[]>();
const byId = new Map<string, MemberGroupNode>();
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"));
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
const q = filterText.trim().toLowerCase();
let keep: Set<string> | null = null;
if (q !== "") {
keep = new Set<string>();
for (const g of groups) {
if (!g.name.toLowerCase().includes(q)) continue;
let cur: MemberGroupNode | undefined = g;
while (cur !== undefined) {
keep.add(cur.id);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
}
}
const out: Row[] = [];
const walk = (parentId: string | null): void => {
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 || !isCollapsed(g.id)) walk(g.id);
}
};
walk(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;
});
const treeFoot = $derived.by(() => {
const active = groups.filter((g) => g.archivedAt === null);
const archivedN = groups.length - active.length;
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
return (
`${active.length} 个活跃组 · ${totalMembers} 条成员关系` +
(archivedN > 0 ? ` · ${archivedN} 个已删除` : "")
);
});
/** 面包屑:祖先链(根在前,自身在末)。 */
const chain = $derived.by((): MemberGroupNode[] => {
if (selected === null) return [];
const byId = new Map(groups.map((g) => [g.id, g]));
const out: MemberGroupNode[] = [];
for (let cur: MemberGroupNode | undefined = selected; cur !== undefined; ) {
out.unshift(cur);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
return out;
});
const childCount = $derived(groups.filter((g) => g.parentId === selectedId).length);
const shownMembers = $derived.by(() => {
const q = memberFilter.trim().toLowerCase();
if (q === "") return members;
return members.filter(
(m) =>
m.displayName.toLowerCase().includes(q) ||
m.userId.toLowerCase().includes(q) ||
m.feishuOpenId.toLowerCase().includes(q),
);
});
function fmtDate(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
async function loadGroups(): Promise<void> {
try {
const r = await api<{ groups: MemberGroupNode[] }>(
`/database/api/groups${showArchived ? "?includeArchived=1" : ""}`,
);
groups = r.groups;
listError = null;
loaded = true;
if (selectedId !== null && !groups.some((g) => g.id === selectedId)) {
selectedId = null;
members = [];
membersLoaded = false;
}
} catch (e) {
listError = errText(e);
loaded = true;
}
}
async function loadMembers(): Promise<void> {
if (selectedId === null) return;
membersLoaded = false;
membersError = null;
try {
const r = await api<{ members: MemberGroupMember[] }>(
`/database/api/groups/${encodeURIComponent(selectedId)}/members`,
);
members = r.members;
membersLoaded = true;
} catch (e) {
membersError = errText(e);
membersLoaded = true;
}
}
onMount(loadGroups);
function select(id: string): void {
selectedId = id;
memberFilter = "";
void loadMembers();
}
async function toggleArchived(): Promise<void> {
showArchived = !showArchived;
await loadGroups();
}
/* ---------------- 右键菜单 ---------------- */
interface MenuItem {
readonly label?: string;
readonly ic?: import("./Icon.svelte").IconName;
readonly danger?: boolean;
readonly sep?: boolean;
readonly fn?: () => void;
}
let menu = $state<{ x: number; y: number; items: MenuItem[] } | null>(null);
function openMenu(e: MouseEvent, target: MemberGroupNode | null): void {
e.preventDefault();
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
const items: MenuItem[] =
target === null
? [{ label: "新建根 Group", ic: "plus", fn: () => openCreate(null) }]
: target.archivedAt !== null
? [
{ label: "查看成员(只读)", ic: "users", fn: () => select(target.id) },
{ label: "恢复此 Group", ic: "restore", fn: () => void restoreGroup(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
]
: [
{ label: "新建子 Group", ic: "plus", fn: () => openCreate(target) },
{ label: "添加成员", ic: "user", fn: () => { select(target.id); openAddMember(); } },
{ label: "重命名 / 改描述", ic: "pencil", fn: () => openRename(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => void deleteGroup(target) },
];
// 贴边翻转,避免菜单溢出视口(菜单宽 184、每项约 34)。
const w = 184;
const h = items.reduce((n, it) => n + (it.sep === true ? 9 : 34), 10);
menu = {
x: Math.min(e.clientX, window.innerWidth - w - 8),
y: Math.min(e.clientY, window.innerHeight - h - 8),
items,
};
}
/* ---------------- 弹窗 ---------------- */
let showCreate = $state(false);
let createParent = $state<MemberGroupNode | null>(null);
let newName = $state("");
let newDesc = $state("");
let showRename = $state(false);
let renameTarget = $state<MemberGroupNode | null>(null);
let editName = $state("");
let editDesc = $state("");
let showAdd = $state(false);
let addQuery = $state("");
let addResults = $state<UserSearchResult[]>([]);
let addSearching = $state(false);
function openCreate(parent: MemberGroupNode | null): void {
createParent = parent;
newName = "";
newDesc = "";
showCreate = true;
}
async function createGroup(): Promise<void> {
const name = newName.trim();
if (name === "") {
toastErr("名称必填");
return;
}
const parentId = createParent?.id ?? null;
try {
await api("/database/api/groups", {
method: "POST",
body: { name, parentId, ...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}) },
});
showCreate = false;
// 建完自动展开父节点,否则新子组藏在折叠的父下面看不见。
if (parentId !== null) collapsedIds = collapsedIds.filter((x) => x !== parentId);
toastOk("已创建成员组");
await loadGroups();
} catch (e) {
toastErr(errText(e));
}
}
function openRename(g: MemberGroupNode): void {
renameTarget = g;
editName = g.name;
editDesc = g.description ?? "";
showRename = true;
}
async function saveRename(): Promise<void> {
if (renameTarget === null) return;
const name = editName.trim();
if (name === "") {
toastErr("名称必填");
return;
}
try {
// description 总是回传(含空串)—— 空串即清除描述(ADR-0028 决策6)。
await api(`/database/api/groups/${encodeURIComponent(renameTarget.id)}`, {
method: "PATCH",
body: { name, description: editDesc.trim() },
});
showRename = false;
toastOk("已保存");
await loadGroups();
} catch (e) {
toastErr(errText(e));
}
}
async function deleteGroup(g: MemberGroupNode): Promise<void> {
if (
!confirm(
`删除「${g.name}」?\n\n软删除:整棵子树一并标记删除,相关授权立即失效,` +
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。",
)
)
return;
try {
const r = await api<{ archivedCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}`,
{ method: "DELETE" },
);
if (selectedId === g.id && !showArchived) {
selectedId = null;
members = [];
membersLoaded = false;
}
toastOk(`已删除 ${r.archivedCount} 个组(软删除,可恢复)`);
await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) {
toastErr(errText(e));
}
}
async function restoreGroup(g: MemberGroupNode): Promise<void> {
// 恢复语义与删除不对称(ADR-0028 决策7):只回该组 + 已归档祖先链,子树仍归档。
if (
!confirm(
`恢复「${g.name}」?\n\n其已删除的上级会一并恢复(否则它在树上无路径);` +
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。",
)
)
return;
try {
const r = await api<{ restoredCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}/restore`,
{ method: "POST" },
);
toastOk(`已恢复 ${r.restoredCount} 个组`);
await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) {
toastErr(errText(e));
}
}
function openAddMember(): void {
addQuery = "";
addResults = [];
showAdd = true;
}
/** 成员选择器:搜全局用户,excludeGroupId 过滤掉本组已有成员。 */
async function searchUsers(): Promise<void> {
if (selectedId === null) return;
addSearching = true;
try {
const r = await api<{ users: UserSearchResult[] }>(
`/database/api/users/search?q=${encodeURIComponent(addQuery.trim())}` +
`&excludeGroupId=${encodeURIComponent(selectedId)}`,
);
addResults = r.users;
} catch (e) {
toastErr(errText(e));
} finally {
addSearching = false;
}
}
async function addMember(userId: string): Promise<void> {
if (selectedId === null) return;
try {
await api(`/database/api/groups/${encodeURIComponent(selectedId)}/members`, {
method: "POST",
body: { userId },
});
toastOk("已添加成员");
addResults = addResults.filter((u) => u.userId !== userId);
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(errText(e));
}
}
async function removeMember(m: MemberGroupMember): Promise<void> {
if (selectedId === null) return;
if (!confirm(`将「${m.displayName || m.userId}」移出本组?其经由本组获得的授权立即失效。`)) return;
try {
await api(
`/database/api/groups/${encodeURIComponent(selectedId)}/members/${encodeURIComponent(m.userId)}`,
{ method: "DELETE" },
);
toastOk("已移除成员");
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(errText(e));
}
}
</script>
<svelte:window
onclick={() => (menu = null)}
onkeydown={(e) => {
if (e.key === "Escape") menu = null;
}}
/>
<div class="flex h-full min-h-0 items-stretch gap-3.5">
<!-- 左:组树 -->
<div class="panel flex w-[326px] shrink-0 flex-col !p-3.5" style="min-height:0">
<div class="mb-2.5 flex items-center gap-2">
<span class="flex text-accent"><Icon name="layers" size={17} /></span>
<div class="section-title flex-1">Group 树</div>
<button class="btn btn-sm" onclick={() => openCreate(null)}>
<Icon name="plus" size={13} /> 根组
</button>
</div>
<div class="relative mb-2">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="过滤组名…" bind:value={filterText} />
</div>
<label class="switch mb-2.5 text-[11.5px] text-ink-3">
<input type="checkbox" checked={showArchived} onchange={toggleArchived} />
<span></span>
显示已删除的组
</label>
<!-- 树空白处右键 = 建根组 -->
<div
class="-mx-1.5 min-h-0 flex-1 overflow-y-auto"
role="tree"
tabindex="-1"
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("[data-node]") !== null) return;
openMenu(e, null);
}}
>
{#if !loaded}
<div class="quiet px-3 py-6 text-center">加载中…</div>
{:else if listError !== null}
<div class="px-3 py-6 text-center text-xs text-danger">{listError}</div>
{:else if groups.length === 0}
<div class="quiet flex flex-col items-center gap-2 px-3 py-[22px] text-center">
<span class="flex text-line"><Icon name="layers" size={30} /></span>
暂无成员组 · 点上方「根组」开始
</div>
{:else if rows.length === 0}
<div class="quiet px-3 py-[22px] text-center">无匹配的组</div>
{:else}
{#each rows as { g, hasKids, hit } (g.id)}
{@const arch = g.archivedAt !== null}
<div
data-node
class="flex cursor-pointer select-none items-center gap-1.5 rounded-lg py-1.5 pr-2 text-[13px]"
class:bg-selected={selectedId === g.id}
class:opacity-50={!hit}
style="padding-left: {8 + g.depth * 15}px"
role="treeitem"
aria-selected={selectedId === g.id}
tabindex="-1"
onclick={() => select(g.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(g.id);
}
}}
oncontextmenu={(e) => openMenu(e, g)}
>
{#if hasKids}
<span
class="flex w-[15px] shrink-0 justify-center text-ink-3 transition-transform"
class:rotate-90={!(isCollapsed(g.id) && filterText.trim() === "")}
role="button"
tabindex="-1"
aria-label="折叠 / 展开"
onclick={(e) => {
e.stopPropagation();
toggleCollapsed(g.id);
}}
onkeydown={(e) => {
if (e.key === "Enter") toggleCollapsed(g.id);
}}
>
<Icon name="chevron" size={13} />
</span>
{:else}
<span class="inline-block w-[15px] shrink-0"></span>
{/if}
<span class="flex" class:text-accent={selectedId === g.id && !arch} class:text-ink-3={arch || selectedId !== g.id}>
<Icon name={arch ? "archive" : "group"} size={15} />
</span>
<span class="flex-1 truncate" class:text-ink-3={arch} class:line-through={arch}>{g.name}</span>
<span class="tag shrink-0" class:opacity-70={arch}>
<Icon name="user" size={10} />{g.memberCount}
</span>
{#if arch}
<span class="tag shrink-0 !text-[10px] opacity-85">已删除</span>
{/if}
</div>
{/each}
{/if}
</div>
<div class="section-note mt-2 border-t border-line-soft pt-2">{loaded ? treeFoot : ""}</div>
</div>
<!-- 右:成员表 -->
<div class="panel flex min-h-0 min-w-0 flex-1 flex-col !p-0">
{#if selected === null}
<div class="quiet m-auto flex flex-col items-center gap-2.5 p-7 text-center">
<span class="flex text-line"><Icon name="users" size={40} /></span>
从左侧选择一个 Group 查看成员
</div>
{:else}
{#if isArchived}
<!-- 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。 -->
<div
class="flex shrink-0 items-center gap-2.5 border-b border-line-soft bg-hover px-[18px] py-2.5 text-[12.5px]"
>
<span class="flex text-ink-3"><Icon name="archive" size={15} /></span>
<span class="flex-1">
此 Group 已删除于 {fmtDate(selected.archivedAt ?? "")} · 成员只读,不再授予任何权限
</span>
<button class="btn !text-xs" onclick={() => void restoreGroup(selected)}>
<Icon name="restore" size={13} /> 恢复
</button>
</div>
{/if}
<div class="shrink-0 border-b border-line-soft px-[18px] pb-3 pt-4">
<div class="mb-1.5 text-xs">
{#each chain as c, i (c.id)}
{#if i > 0}<span class="mx-[5px] text-ink-3">/</span>{/if}
<span class={i === chain.length - 1 ? "font-medium text-ink" : "text-ink-3"}>{c.name}</span>
{/each}
</div>
<div class="flex items-center gap-2.5">
<span class="flex" class:text-ink-3={isArchived} class:text-accent={!isArchived}>
<Icon name={isArchived ? "archive" : "group"} size={20} />
</span>
<div class="min-w-0 flex-1">
<div class="text-base font-semibold" class:text-ink-3={isArchived}>{selected.name}</div>
{#if selected.description !== null && selected.description !== ""}
<div class="section-note mt-0.5">{selected.description}</div>
{:else}
<div class="section-note mt-0.5 opacity-60">无描述</div>
{/if}
</div>
<!-- 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。 -->
{#if !isArchived}
<button class="btn !text-xs" onclick={() => openRename(selected)}>
<Icon name="pencil" size={13} /> 编辑
</button>
<button class="btn btn-primary !text-xs" onclick={openAddMember}>
<Icon name="plus" size={13} /> 添加成员
</button>
{/if}
</div>
<div class="mt-3 flex gap-4 text-xs text-ink-3">
<span class="inline-flex items-center gap-1"><Icon name="user" size={12} />{members.length} 名成员</span>
<span class="inline-flex items-center gap-1"><Icon name="layers" size={12} />层级 {selected.depth}</span>
<span class="inline-flex items-center gap-1"><Icon name="group" size={12} />{childCount} 个子组</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2.5 px-[18px] py-2.5">
<div class="relative max-w-[280px] flex-1">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="搜索成员…" bind:value={memberFilter} />
</div>
<span class="file-meta">
{memberFilter.trim() === "" ? "" : `${shownMembers.length} / ${members.length}`}
</span>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
{#if !membersLoaded}
<div class="quiet px-3 py-9 text-center">加载中…</div>
{:else if membersError !== null}
<div class="px-3 py-9 text-center text-xs text-danger">{membersError}</div>
{:else if members.length === 0}
<div class="quiet flex flex-col items-center gap-2.5 px-3 py-9 text-center">
<span class="flex text-line"><Icon name="users" size={34} /></span>
{isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」"}
</div>
{:else if shownMembers.length === 0}
<div class="quiet px-3 py-[30px] text-center">无匹配成员</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>飞书 openId</th>
<th>加入时间</th>
{#if !isArchived}<th class="!text-right">操作</th>{/if}
</tr>
</thead>
<tbody>
{#each shownMembers as m (m.userId)}
<tr>
<td>
<span class="inline-flex items-center gap-2.5">
<Avatar displayName={m.displayName} userId={m.userId} avatarUrl={m.avatarUrl} size={28} />
<span class="font-medium">{m.displayName || "(未命名)"}</span>
</span>
</td>
<td class="file-meta">{m.userId}</td>
<td class="file-meta">{m.feishuOpenId || "—"}</td>
<td class="file-meta">{fmtDate(m.joinedAt)}</td>
{#if !isArchived}
<td class="text-right">
<button class="link-danger inline-flex items-center gap-1" onclick={() => void removeMember(m)}>
<Icon name="minus" size={12} /> 移除
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
</div>
</div>
<!-- 右键菜单 -->
{#if menu !== null}
<div
class="fixed z-[60] min-w-[184px] rounded-[10px] border border-line bg-panel p-[5px] text-[13px] shadow-[0_4px_20px_rgba(26,26,24,.07)]"
style="left:{menu.x}px;top:{menu.y}px"
role="menu"
tabindex="-1"
>
{#each menu.items as it, i (i)}
{#if it.sep === true}
<div class="mx-1.5 my-1 h-px bg-line-soft"></div>
{:else}
<div
class="flex cursor-pointer items-center gap-2 rounded-md px-[11px] py-[7px] hover:bg-hover"
class:text-danger={it.danger === true}
role="menuitem"
tabindex="-1"
onclick={(e) => {
e.stopPropagation();
menu = null;
it.fn?.();
}}
onkeydown={(e) => {
if (e.key === "Enter") {
menu = null;
it.fn?.();
}
}}
>
{#if it.ic !== undefined}<span class="flex opacity-75"><Icon name={it.ic} size={14} /></span>{/if}
{it.label}
</div>
{/if}
{/each}
</div>
{/if}
{#if showCreate}
<Modal
title={createParent === null ? "新建根 Group" : `在「${createParent.name}」下新建子 Group`}
onclose={() => (showCreate = false)}
>
<div class="form-row">
<label class="form-label" for="gc-name">名称</label>
<input id="gc-name" class="input" bind:value={newName} placeholder="例如:物理教研组" />
</div>
<div class="form-row">
<label class="form-label" for="gc-desc">描述(可选)</label>
<input id="gc-desc" class="input" bind:value={newDesc} placeholder="一句话说明" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showCreate = false)}>取消</button>
<button class="btn btn-primary" onclick={createGroup}>创建</button>
</div>
</Modal>
{/if}
{#if showRename && renameTarget !== null}
<Modal title="重命名 / 改描述" onclose={() => (showRename = false)}>
<div class="form-row">
<label class="form-label" for="gr-name">名称</label>
<input id="gr-name" class="input" bind:value={editName} />
</div>
<div class="form-row">
<label class="form-label" for="gr-desc">描述</label>
<input id="gr-desc" class="input" bind:value={editDesc} placeholder="留空则清除描述" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showRename = false)}>取消</button>
<button class="btn btn-primary" onclick={saveRename}>保存</button>
</div>
</Modal>
{/if}
{#if showAdd && selected !== null}
<Modal title={`向「${selected.name}」添加成员`} onclose={() => (showAdd = false)}>
<div class="form-row">
<label class="form-label" for="ga-q">搜索用户(姓名 / userId / 飞书 openId)</label>
<div class="flex gap-2">
<input
id="ga-q"
class="input"
bind:value={addQuery}
placeholder="留空列出全部候选"
onkeydown={(e) => {
if (e.key === "Enter") void searchUsers();
}}
/>
<button class="btn" onclick={searchUsers}><Icon name="search" size={13} /> 搜索</button>
</div>
<div class="section-note mt-1.5">已在本组的成员不会出现在结果里。</div>
</div>
<div class="max-h-[280px] overflow-y-auto">
{#if addSearching}
<div class="quiet px-3 py-6 text-center">搜索中…</div>
{:else if addResults.length === 0}
<div class="quiet px-3 py-6 text-center">无候选用户 · 先点「搜索」</div>
{:else}
{#each addResults as u (u.userId)}
<div class="flex items-center gap-2.5 border-b border-line-soft py-2 last:border-b-0">
<Avatar displayName={u.displayName} userId={u.userId} avatarUrl={u.avatarUrl} size={26} />
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium">{u.displayName || "(未命名)"}</div>
<div class="file-meta truncate">{u.feishuOpenId || u.userId}</div>
</div>
<button class="btn btn-sm" onclick={() => void addMember(u.userId)}>
<Icon name="plus" size={12} /> 添加
</button>
</div>
{/each}
{/if}
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showAdd = false)}>关闭</button>
</div>
</Modal>
{/if}
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts" module>
// 从已删除的 routes/adminPanels.ts 的 GROUP_ICONS 原样搬来(ADR-0029)。
export const ICONS = {
// 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",
} as const;
export type IconName = keyof typeof ICONS;
</script>
<script lang="ts">
let { name, size = 16 }: { name: IconName; size?: number } = $props();
</script>
<svg
style="width:{size}px;height:{size}px"
class="shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
><path d={ICONS[name]} /></svg>
@@ -1,7 +1,14 @@
<script lang="ts">
/**
* 文件库浏览器(树 + 详情 + 文件编辑栏)。
*
* 两处复用:老师端 /app(showUserFooter=true,侧栏底部带身份与退出)
* 与管理后台 /database/dashboard/library(false —— 外层壳已有身份区)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { me, toastErr, toastOk } from "./stores.js";
import { logout } from "./session.js";
import { treeVersion, bumpTree, currentNode, selectedFilePath, clearSelectedFile, bumpFiles } from "./browser.js";
import type { NodeChild } from "./types.js";
import TreeNode from "./TreeNode.svelte";
@@ -9,6 +16,8 @@
import FileEditor from "./FileEditor.svelte";
import Modal from "./Modal.svelte";
let { showUserFooter = false }: { showUserFooter?: boolean } = $props();
let roots = $state<NodeChild[] | null>(null);
let treeError = $state<string | null>(null);
let showCreateRoot = $state(false);
@@ -54,21 +63,16 @@
}
}
async function logout(): Promise<void> {
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch { /* ignore */ }
me.set(null);
}
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase());
</script>
<div class="flex h-full">
<div class="flex min-h-0 flex-1">
<!-- 侧栏 -->
<aside class="flex w-[300px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
<span class="text-[15px] font-semibold text-ink">文件库</span>
{#if $me?.isWebsiteAdmin}
<button class="rounded-lg border border-line bg-panel px-2.5 py-1 text-[11.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateRoot = true)}>
<button class="btn btn-sm" onclick={() => (showCreateRoot = true)}>
+ 根目录
</button>
{/if}
@@ -90,11 +94,13 @@
{/if}
</div>
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
<span class="flex-1 truncate text-ink">{$me?.userId ?? ""}</span>
<button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button>
</div>
{#if showUserFooter}
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
<span class="flex-1 truncate text-ink">{$me?.userId ?? ""}</span>
<button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button>
</div>
{/if}
</aside>
<!-- 主区 -->
@@ -118,24 +124,24 @@
{#if showCreateRoot}
<Modal title="新建根目录" onclose={() => (showCreateRoot = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="root-name">名称</label>
<input id="root-name" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newName} placeholder="例如:物理教研" />
<div class="form-row">
<label class="form-label" for="root-name">名称</label>
<input id="root-name" class="input" bind:value={newName} placeholder="例如:物理教研" />
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="root-kind">类型</label>
<select id="root-kind" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newKind}>
<div class="form-row">
<label class="form-label" for="root-kind">类型</label>
<select id="root-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option>
</select>
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="root-desc">简介(可选)</label>
<textarea id="root-desc" rows="3" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs outline-none focus:border-accent" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
<div class="form-row">
<label class="form-label" for="root-desc">简介(可选)</label>
<textarea id="root-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateRoot = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={createRoot}>创建</button>
<button class="btn" onclick={() => (showCreateRoot = false)}>取消</button>
<button class="btn btn-primary" onclick={createRoot}>创建</button>
</div>
</Modal>
{/if}
+5 -9
View File
@@ -1,18 +1,13 @@
<script lang="ts">
import { onMount } from "svelte";
import { api } from "./api.js";
import { loadConfig, type AppConfig } from "./config.js";
interface LoginInfo {
orgSlug: string;
devLoginEnabled: boolean;
}
let info = $state<LoginInfo | null>(null);
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
try {
info = await api<LoginInfo>("/database/api/login-info");
info = await loadConfig();
} catch {
loadFailed = true;
}
@@ -27,6 +22,7 @@
{#if info}
<a
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
>使用飞书登录</a>
@@ -35,7 +31,7 @@
<span class="flex-1 border-t border-line-soft"></span>开发模式
<span class="flex-1 border-t border-line-soft"></span>
</div>
<a href="/app/dev-login-teacher" class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover">⚡ 一键登录(老师)</a>
<a href="/app/dev-login-teacher" data-sveltekit-reload class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover">⚡ 一键登录(老师)</a>
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
{:else if loadFailed}
+51 -32
View File
@@ -4,9 +4,12 @@
import { toastOk, toastErr } from "./stores.js";
import OverviewPanel from "./OverviewPanel.svelte";
import FilesPanel from "./FilesPanel.svelte";
import GrantsPanel from "./GrantsPanel.svelte";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let tab = $state<"detail" | "files">("detail");
type Tab = "detail" | "files" | "grants";
let tab = $state<Tab>("detail");
let showCreateChild = $state(false);
let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
@@ -17,6 +20,15 @@
const canManage = $derived(node?.role === "MANAGE");
const canEdit = $derived(canManage || node?.role === "EDIT");
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;授权仅 MANAGE
// (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。
const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => {
const out: Array<readonly [Tab, string]> = [["detail", "概览"]];
if (node?.kind === "PROJECT") out.push(["files", "文件"]);
if (canManage) out.push(["grants", "授权"]);
return out;
});
$effect(() => {
void node?.id;
tab = "detail";
@@ -84,36 +96,43 @@
</div>
<div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-2.5 text-[19px] font-semibold text-ink">
<div class="flex items-center gap-2 text-[17px] font-semibold text-ink">
{node.name}
<span class="rounded-full border border-line-soft bg-panel px-2 py-0.5 font-mono text-[10.5px] text-ink-3">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
<span class="tag">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
<span class="tag !border-line !text-ink-2">{node.role}</span>
</div>
<div class="flex gap-2">
<div class="flex gap-1.5">
{#if canEdit && node.kind === "FOLDER"}
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateChild = true)}>+ 新建</button>
<button class="btn" onclick={() => (showCreateChild = true)}>
<Icon name="plus" size={13} /> 新建子节点
</button>
{/if}
{#if canManage}
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={renameNode}>重命名</button>
<button class="rounded-lg border border-transparent px-3 py-1.5 text-[12.5px] font-medium text-danger transition hover:bg-[#A13A3312]" onclick={deleteNode}>删除</button>
<button class="btn" onclick={renameNode}><Icon name="pencil" size={13} /> 重命名</button>
<button class="btn btn-danger" onclick={deleteNode}><Icon name="trash" size={13} /> 删除</button>
{/if}
</div>
</div>
{#if node.kind === "FOLDER"}
<div class="py-7 text-[13px] text-ink-3">点击左侧树展开以浏览子内容</div>
<div class="mb-[18px] flex gap-0.5 border-b border-line-soft">
{#each tabs as [id, label] (id)}
<button
class="-mb-px border-b-2 px-3.5 py-2 text-[13px] transition {tab === id
? 'border-accent font-semibold text-ink'
: 'border-transparent text-ink-3 hover:text-ink'}"
onclick={() => (tab = id)}
>{label}</button>
{/each}
</div>
{#if tab === "grants"}
<GrantsPanel {node} />
{:else if tab === "files" && node.kind === "PROJECT"}
<FilesPanel {node} />
{:else}
<div class="mb-5 flex gap-1 border-b border-line-soft">
{#each [["detail", "概览"], ["files", "文件"]] as [id, label] (id)}
<button
class="border-b-2 px-3.5 py-2 text-[13px] transition {tab === id ? 'border-accent font-semibold text-ink' : 'border-transparent text-ink-3 hover:text-ink'}"
onclick={() => (tab = id as "detail" | "files")}
>{label}</button>
{/each}
</div>
{#if tab === "detail"}
<OverviewPanel {node} />
{:else}
<FilesPanel {node} />
<OverviewPanel {node} />
{#if node.kind === "FOLDER"}
<div class="quiet mt-3.5">文件夹是透明组织节点,点左侧树展开以浏览子内容。</div>
{/if}
{/if}
</div>
@@ -121,24 +140,24 @@
{#if showCreateChild && node}
<Modal title="新建子节点" onclose={() => (showCreateChild = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-name">名称</label>
<input id="child-name" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newName} placeholder="例如:物理必修一" />
<div class="form-row">
<label class="form-label" for="child-name">名称</label>
<input id="child-name" class="input" bind:value={newName} placeholder="例如:物理必修一" />
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-kind">类型</label>
<select id="child-kind" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newKind}>
<div class="form-row">
<label class="form-label" for="child-kind">类型</label>
<select id="child-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option>
</select>
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-desc">简介(可选)</label>
<textarea id="child-desc" rows="3" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs outline-none focus:border-accent" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
<div class="form-row">
<label class="form-label" for="child-desc">简介(可选)</label>
<textarea id="child-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateChild = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={createChild}>创建</button>
<button class="btn" onclick={() => (showCreateChild = false)}>取消</button>
<button class="btn btn-primary" onclick={createChild}>创建</button>
</div>
</Modal>
{/if}
+57 -25
View File
@@ -12,8 +12,25 @@
let exportJob = $state<ExportJob | null>(null);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const canManage = $derived(node.role === "MANAGE");
const roleLabel = $derived(node.role === "MANAGE" ? "可管理" : node.role === "EDIT" ? "可编辑" : "只读");
/** 独立权限开关(仅 PROJECT;关闭时只继承父级权限,创建者除外)。 */
async function toggleIndependent(): Promise<void> {
try {
await api(`/database/api/projects/${node.id}/independent-permission`, {
method: "PUT",
body: { enabled: !node.independentPermission },
});
toastOk("已切换");
currentNode.update((n) =>
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
$effect(() => {
void node.id;
exportJob = null;
@@ -67,7 +84,7 @@
}
</script>
<div class="rounded-xl border border-line-soft bg-panel p-5">
<div class="panel">
<div class="mb-4">
<div class="mb-1.5 text-[11.5px] text-ink-3">简介</div>
<div class="text-[13.5px] leading-7 text-ink">
@@ -77,7 +94,7 @@
<span class="italic text-ink-3">暂无简介</span>
{/if}
{#if canEdit}
<button class="ml-2.5 rounded-lg border border-line bg-panel px-2.5 py-0.5 align-middle text-[11.5px] text-ink transition hover:bg-hover" onclick={openEditDesc}>编辑</button>
<button class="btn ml-2.5 !px-2.5 !py-0.5 align-middle !text-[11.5px]" onclick={openEditDesc}>编辑</button>
{/if}
</div>
</div>
@@ -85,39 +102,54 @@
<div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-col gap-1.5 text-[13px] text-ink-2">
<div>类型 <b class="font-semibold text-ink">{node.kind === "PROJECT" ? "项目" : "文件夹"}</b></div>
<div>我的角色 <b class="font-semibold text-ink">{roleLabel}</b></div>
<div>创建时间 <b class="font-semibold text-ink">{new Date(node.createdAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}</b></div>
<div>最近修改 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}</b></div>
<div>创建时间 <b class="font-semibold text-ink">{new Date(node.createdAt).toLocaleString("zh-CN")}</b></div>
<div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
</div>
<div class="my-4 border-t border-line-soft"></div>
<!-- 独立权限与导出都只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
{#if node.kind === "PROJECT"}
<div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-wrap items-center gap-2.5">
<span class="quiet">独立权限</span>
<b class="text-[13px]">{node.independentPermission ? "开启" : "关闭"}</b>
{#if canManage}
<button class="btn" onclick={toggleIndependent}>{node.independentPermission ? "关闭" : "开启"}</button>
{/if}
<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>
</div>
<div class="mb-2 text-[12.5px] font-semibold">导出</div>
<div class="flex items-center gap-2">
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={submitExport}>开始导出</button>
{#if exportJob}
<span class="font-mono text-[11px] text-ink-3">
{#if exportJob.status === "DONE"}
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
{:else if exportJob.status === "FAILED"}
失败:{exportJob.error ?? ""}
{:else}
{exportJob.status}
{/if}
</span>
{/if}
</div>
<div class="my-4 border-t border-line-soft"></div>
<div class="section-title mb-2">导出</div>
<div class="flex items-center gap-2">
<select class="select !w-auto"><option value="manifest">manifest(stub)</option></select>
<button class="btn" onclick={submitExport}>开始导出</button>
{#if exportJob}
<span class="file-meta">
{#if exportJob.status === "DONE"}
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
{:else if exportJob.status === "FAILED"}
失败:{exportJob.error ?? ""}
{:else}
{exportJob.status}
{/if}
</span>
{/if}
</div>
{/if}
</div>
{#if showEditDesc}
<Modal title="编辑简介" onclose={() => (showEditDesc = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="desc-draft">简要说明这个项目的内容</label>
<textarea id="desc-draft" rows="5" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] leading-7 outline-none focus:border-accent" bind:value={descDraft} placeholder="例如:高中物理必修一第三章,表面张力相关内容……"></textarea>
<div class="form-row">
<label class="form-label" for="desc-draft">简要说明这个项目的内容</label>
<textarea id="desc-draft" rows="5" class="input !leading-7" bind:value={descDraft} placeholder="例如:高中物理必修一第三章,表面张力相关内容……"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showEditDesc = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={saveDesc}>保存</button>
<button class="btn" onclick={() => (showEditDesc = false)}>取消</button>
<button class="btn btn-primary" onclick={saveDesc}>保存</button>
</div>
</Modal>
{/if}
+23
View File
@@ -0,0 +1,23 @@
/**
* 前端 bootstrap:silo org slug(拼飞书 OAuth 链接用)+ dev 一键登录开关。
*
* 打 `/database/config` 而非 `/database/api/login-info`:后者由 teacherApp.ts 在
* silo org 查找成功之后才注册,org 缺失时整条链路不存在;前者在
* databaseRoutes.ts 顶部无条件注册。两者形状相同(见 src/database/README.md)。
*
* 免鉴权 —— org slug 本就出现在 OAuth URL 里,不构成敏感信息。
*/
import { api } from "./api.js";
export interface AppConfig {
readonly orgSlug: string;
readonly devLoginEnabled: boolean;
}
let cached: AppConfig | null = null;
export async function loadConfig(): Promise<AppConfig> {
if (cached !== null) return cached;
cached = await api<AppConfig>("/database/config");
return cached;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 会话装载:GET /database/api/me 一次,结果进 `me` store。
* 老师端与管理后台共用 —— 两处的区别只是拿到 me 之后怎么用
* (老师端未登录显示登录视图;管理后台未登录跳 /database/admin,
* 非 isWebsiteAdmin 显示无权提示)。
*/
import { get } from "svelte/store";
import { api, UnauthenticatedError } from "./api.js";
import { me, authChecked } from "./stores.js";
import type { MeResponse } from "./types.js";
/** 幂等:已检查过就不再打请求(路由间切换不重复拉取)。 */
export async function loadSession(force = false): Promise<void> {
if (get(authChecked) && !force) return;
try {
me.set(await api<MeResponse>("/database/api/me"));
} catch (e) {
if (!(e instanceof UnauthenticatedError)) console.error(e);
me.set(null);
} finally {
authChecked.set(true);
}
}
/** 退出登录:清后端 cookie 再清前端 store。 */
export async function logout(): Promise<void> {
try {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
} catch {
/* 网络失败也照样清前端状态 */
}
me.set(null);
}
+79
View File
@@ -36,6 +36,9 @@ export interface NodeDetail {
export interface MeResponse {
readonly userId: string;
readonly isWebsiteAdmin: boolean;
/** 侧栏身份区显示用;后端取不到 User 行时回落为 userId。 */
readonly displayName: string;
readonly avatarUrl: string | null;
}
export interface FileEntry {
@@ -83,3 +86,79 @@ export interface GroupSearchResult {
readonly name: string;
readonly breadcrumb: string;
}
/** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */
export interface MemberGroupNode {
readonly id: string;
readonly parentId: string | null;
readonly name: string;
readonly description: string | null;
readonly depth: number;
readonly memberCount: number;
/** 软删标记(ADR-0028 决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。
* 仅在 ?includeArchived=1 时可能非 null。ISO 串(后端 JSON 序列化后不再是 Date)。 */
readonly archivedAt: string | null;
}
export interface MemberGroupMember {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
/** 加入本组时间;ISO 串。 */
readonly joinedAt: string;
}
/** 节点授权(GET /database/api/nodes/:id/grants)。 */
export interface Grant {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: Role;
/** 创建者授权不可收回、不可改(契约 8.1)。 */
readonly isCreatorGrant: boolean;
readonly createdAt: string;
}
/** Group 选择器候选(GET /database/api/groups/search)。 */
export interface MemberGroupSearchResult {
readonly id: string;
readonly name: string;
/** 祖先链(根在前,自身在末),用 " / " 连接。 */
readonly breadcrumb: string;
}
/** 成员选择器候选(GET /database/api/users/search)。 */
export interface UserSearchResult {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
}
/** 管理后台概览统计(GET /database/api/stats)。 */
export interface DashboardStats {
readonly folders: number;
readonly projects: number;
readonly files: number;
readonly grants: number;
readonly recent: ReadonlyArray<{
readonly action: string;
readonly actor: string;
readonly label: string;
/** ISO 串;后端 JSON 序列化后不再是 Date。 */
readonly when: string;
}>;
}
/** org 成员(GET /api/org/:slug/members);用户管理面板消费。 */
export type OrgRole = "OWNER" | "ADMIN" | "MEMBER";
export interface OrgMember {
readonly userId: string;
readonly feishuOpenId: string;
readonly displayName: string;
readonly avatarUrl: string | null;
readonly role: OrgRole;
readonly createdAt: string;
}
-5
View File
@@ -1,5 +0,0 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./app.css";
export default mount(App, { target: document.getElementById("app")! });
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import "../app.css";
import Toasts from "$lib/Toasts.svelte";
let { children } = $props();
</script>
<div class="h-full">
{@render children()}
</div>
<Toasts />
+7
View File
@@ -0,0 +1,7 @@
/**
* 纯 SPA:关掉 SSR 与预渲染,构建产物只有一个 fallback index.html
* (adapter-static + fallback,见 svelte.config.js),由 hub 后端在
* /app 与 /database/* 两个前缀下原样送出。
*/
export const ssr = false;
export const prerender = false;
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// 根路径不承载界面:老师端在 /app,管理后台在 /database。
onMount(() => {
void goto("/app", { replaceState: true });
});
</script>
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转中…</div>
@@ -0,0 +1,22 @@
<script lang="ts">
/** 老师端。未登录显示登录卡片;登录后直接是文件库浏览器。 */
import { onMount } from "svelte";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import LoginView from "$lib/LoginView.svelte";
import LibraryView from "$lib/LibraryView.svelte";
onMount(loadSession);
</script>
<svelte:head><title>文件库</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
{:else if $me}
<div class="flex h-full flex-col">
<LibraryView showUserFooter />
</div>
{:else}
<LoginView />
{/if}
@@ -0,0 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// /database 本身不承载界面(与旧后端 /database/admin → dashboard 的跳转一致)。
onMount(() => {
void goto("/database/dashboard", { replaceState: true });
});
</script>
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转中…</div>
@@ -0,0 +1,63 @@
<script lang="ts">
/**
* 管理后台登录页(迁自后端 renderLoginPage)。
* 已登录直接跳 dashboard —— 与旧后端路由 /database/admin 的行为一致。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import { loadConfig, type AppConfig } from "$lib/config.js";
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
await loadSession();
if ($me !== null) {
void goto("/database/dashboard", { replaceState: true });
return;
}
try {
info = await loadConfig();
} catch {
loadFailed = true;
}
});
</script>
<svelte:head><title>Database Admin · 登录</title></svelte:head>
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[380px] rounded-2xl border border-line-soft bg-panel p-9 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<div class="text-center text-[26px] font-semibold text-ink">Database Admin</div>
<p class="mt-2.5 mb-8 text-center text-[13px] text-ink-3">使用飞书登录以管理数据库</p>
{#if !$authChecked}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{:else if info}
<a
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
>使用飞书登录</a>
{#if info.devLoginEnabled}
<div class="my-5 flex items-center gap-2.5 text-[11px] text-ink-3">
<span class="flex-1 border-t border-line-soft"></span>开发模式
<span class="flex-1 border-t border-line-soft"></span>
</div>
<a
href="/database/dev-login"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover"
>⚡ 一键登录管理员</a>
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
{:else if loadFailed}
<p class="text-center text-[12.5px] text-danger">无法加载登录配置,请稍后重试</p>
{:else}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{/if}
</div>
</div>
@@ -0,0 +1,99 @@
<script lang="ts">
/**
* 管理后台外壳(迁自后端 renderDashboard 的侧栏 + 身份区)。
*
* 与旧实现的区别:六个 tab 是真 URL 路由(/database/dashboard/library 等),
* 不再是 location.hash + display:none 切换 —— 刷新不丢位置,链接可分享。
*
* 权限门:未登录跳 /database/admin;登录但非 OWNER/ADMIN(isWebsiteAdmin)
* 显示无权提示。语义与 ADR-0028 一致 —— 管理面板要求 silo org 的 OWNER/ADMIN。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { me, authChecked } from "$lib/stores.js";
import { loadSession, logout } from "$lib/session.js";
import Avatar from "$lib/Avatar.svelte";
let { children } = $props();
const NAV = [
{ seg: "", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" },
{ seg: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" },
{ seg: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" },
{ seg: "groups", label: "Group 管理", icon: "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.75M23 21v-2a4 4 0 0 0-3-3.87" },
{ seg: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" },
{ seg: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" },
] as const;
const BASE = "/database/dashboard";
onMount(async () => {
await loadSession();
if ($me === null) void goto("/database/admin", { replaceState: true });
});
function href(seg: string): string {
return seg === "" ? BASE : `${BASE}/${seg}`;
}
function isActive(seg: string): boolean {
const path = page.url.pathname.replace(/\/$/, "");
return seg === "" ? path === BASE : path === `${BASE}/${seg}`;
}
</script>
<svelte:head><title>Database Admin</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">加载中…</div>
{:else if $me === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转到登录页…</div>
{:else if !$me.isWebsiteAdmin}
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[420px] rounded-2xl border border-line-soft bg-panel p-9 text-center shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<h2 class="mb-2 text-lg font-semibold text-ink">无权访问管理后台</h2>
<p class="mb-6 text-[13px] text-ink-3">
当前账号不是本组织的所有者或管理员。普通老师请到文件库使用。
</p>
<a href="/app" class="btn btn-primary justify-center">前往文件库</a>
<button class="btn mt-3 w-full justify-center" onclick={logout}>退出登录</button>
</div>
</div>
{:else}
<div class="flex h-full">
<aside class="flex w-[240px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="border-b border-line-soft px-4 py-4">
<span class="text-[15px] font-semibold text-ink">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-0.5 p-2.5">
{#each NAV as item (item.seg)}
{@const active = isActive(item.seg)}
<a
href={href(item.seg)}
class="flex items-center gap-2.5 rounded-[10px] px-3.5 py-2 text-[13px] transition"
class:bg-selected={active}
class:text-ink={active}
class:font-semibold={active}
class:text-ink-3={!active}
class:hover:bg-hover={!active}
>
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={item.icon} /></svg>
{item.label}
</a>
{/each}
</nav>
<div class="m-2.5 flex items-center gap-2.5 border-t border-line-soft px-3 py-2.5">
<Avatar displayName={$me.displayName} userId={$me.userId} avatarUrl={$me.avatarUrl} size={26} />
<p class="min-w-0 flex-1 truncate text-[12.5px] text-ink" title={$me.userId}>{$me.displayName}</p>
<button class="btn !px-2.5 !py-[3px] !text-[11px]" onclick={logout}>退出</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col">
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,67 @@
<script lang="ts">
/** 概览(迁自后端 renderDashboard 的统计卡片 + 最近活动)。数据走 GET /database/api/stats。 */
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import type { DashboardStats } from "$lib/types.js";
let stats = $state<DashboardStats | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
try {
stats = await api<DashboardStats>("/database/api/stats");
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
const cards = $derived([
{ label: "文件夹", value: stats?.folders },
{ label: "项目", value: stats?.projects },
{ label: "文件", value: stats?.files },
{ label: "活跃授权", value: stats?.grants },
]);
function fmtWhen(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN");
} catch {
return iso;
}
}
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">概览</h1>
<p class="mb-5 text-[11.5px] text-ink-3">文件库实时数据</p>
{#if error}
<div class="panel text-[12.5px] text-danger">{error}</div>
{:else}
<div class="grid grid-cols-4 gap-4">
{#each cards as card (card.label)}
<div class="panel !px-5 !py-[18px]">
<p class="text-[12.5px] text-ink-3">{card.label}</p>
<p class="mt-1.5 text-[28px] font-semibold text-ink">{card.value ?? "—"}</p>
</div>
{/each}
</div>
<div class="panel mt-[18px]">
<h2 class="mb-2 text-[13.5px] font-semibold text-ink">最近活动</h2>
{#if stats === null}
<div class="quiet py-6 text-center">加载中…</div>
{:else if stats.recent.length === 0}
<div class="quiet py-[26px] text-center">暂无文件库活动 · 到「文件库」里创建第一个文件夹吧</div>
{:else}
{#each stats.recent as row (row.action + row.when + row.label)}
<div class="flex items-center gap-3 border-t border-line-soft py-2.5 text-[13px]">
<span class="tag shrink-0">{row.action}</span>
<span class="truncate text-ink">{row.label}</span>
<span class="ml-auto shrink-0 text-[11.5px] text-ink-3">{row.actor} · {fmtWhen(row.when)}</span>
</div>
{/each}
{/if}
</div>
{/if}
</section>
@@ -0,0 +1,10 @@
<script lang="ts">
/** Group 管理 tab —— MemberGroup 嵌套树(ADR-0028)。
* 外框 padding/overflow 对齐旧 `#tab-groups`(padding:20px;overflow:hidden):
* 两栏各自内部滚动,外层不滚。 */
import GroupAdmin from "$lib/GroupAdmin.svelte";
</script>
<div class="min-h-0 flex-1 overflow-hidden p-5">
<GroupAdmin />
</div>
@@ -0,0 +1,8 @@
<script lang="ts">
/** 文件库 tab —— 与老师端 /app 同一个浏览器组件,区别只在侧栏身份区由外壳提供。 */
import LibraryView from "$lib/LibraryView.svelte";
</script>
<div class="flex min-h-0 flex-1 flex-col">
<LibraryView />
</div>
@@ -0,0 +1,4 @@
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">查询</h1>
<p class="text-[12.5px] text-ink-3">查询功能建设中</p>
</section>
@@ -0,0 +1,4 @@
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">设置</h1>
<p class="text-[12.5px] text-ink-3">设置功能建设中</p>
</section>
@@ -0,0 +1,168 @@
<script lang="ts">
/**
* 用户管理(迁自后端 adminPanels.ts renderUsersPanel)。
*
* 用户 = silo org 的成员,走平台层 /api/org/:slug/members(见 src/admin/routes/membersRoutes.ts)。
* 与 Group 管理是两套体系:MemberGroup 是全局主体、不归属 org(ADR-0028),
* 这里管的是 org 成员与其角色。
*/
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import { loadConfig } from "$lib/config.js";
import { toastOk, toastErr } from "$lib/stores.js";
import type { OrgMember, OrgRole } from "$lib/types.js";
const ROLE_LABEL: Record<OrgRole, string> = {
OWNER: "所有者",
ADMIN: "管理员",
MEMBER: "普通老师",
};
const ROLES: readonly OrgRole[] = ["OWNER", "ADMIN", "MEMBER"];
let orgSlug = $state<string | null>(null);
let members = $state<OrgMember[] | null>(null);
let error = $state<string | null>(null);
let newOpenId = $state("");
let newName = $state("");
let newRole = $state<OrgRole>("MEMBER");
let adding = $state(false);
const base = $derived(orgSlug === null ? null : `/api/org/${encodeURIComponent(orgSlug)}`);
async function load(): Promise<void> {
if (base === null) return;
try {
const r = await api<{ members: OrgMember[] }>(`${base}/members`);
members = r.members;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
onMount(async () => {
try {
orgSlug = (await loadConfig()).orgSlug;
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
async function addMember(): Promise<void> {
const feishuOpenId = newOpenId.trim();
if (feishuOpenId === "") {
toastErr("请填写用户 openId");
return;
}
if (base === null) return;
adding = true;
try {
const displayName = newName.trim();
await api(`${base}/members`, {
method: "POST",
body: { feishuOpenId, role: newRole, ...(displayName !== "" ? { displayName } : {}) },
});
newOpenId = "";
newName = "";
toastOk("已添加");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
} finally {
adding = false;
}
}
async function setRole(userId: string, role: string): Promise<void> {
if (base === null) return;
try {
await api(`${base}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: { role } });
toastOk("角色已更新");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
await load();
}
}
async function revoke(userId: string, displayName: string): Promise<void> {
if (base === null) return;
if (!confirm(`移除成员「${displayName || userId}」?`)) return;
try {
await api(`${base}/members/${encodeURIComponent(userId)}/revoke`, { method: "POST" });
toastOk("已移除");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-4 text-lg font-semibold text-ink">用户管理</h1>
<div class="max-w-[880px]">
<div class="panel mb-3.5">
<div class="section-title mb-2.5">添加成员</div>
<div class="flex flex-wrap items-center gap-2">
<input class="input min-w-0 flex-[2]" placeholder="用户 openId(飞书 ou_ 开头)" bind:value={newOpenId} />
<input class="input min-w-0 flex-1" placeholder="显示名(可选)" bind:value={newName} />
<select class="select !w-[130px]" bind:value={newRole}>
{#each ROLES as role (role)}
<option value={role}>{ROLE_LABEL[role]}</option>
{/each}
</select>
<button class="btn btn-primary disabled:opacity-50" onclick={addMember} disabled={adding}>
{adding ? "添加中…" : "添加"}
</button>
</div>
</div>
<div class="panel">
<div class="section-title mb-2.5">成员列表</div>
{#if error}
<div class="py-3 text-[12.5px] text-danger">{error}</div>
{:else if members === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else if members.length === 0}
<div class="quiet py-[18px] text-center">暂无成员</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>角色</th>
<th></th>
</tr>
</thead>
<tbody>
{#each members as m (m.userId)}
<tr>
<td class="text-ink">{m.displayName || m.userId}</td>
<td class="file-meta">{m.userId}</td>
<td>
<select
class="select !w-[110px] !px-2 !py-[3px] !text-xs"
value={m.role}
onchange={(e) => setRole(m.userId, e.currentTarget.value)}
>
{#each ROLES as role (role)}
<option value={role}>{ROLE_LABEL[role]}</option>
{/each}
</select>
</td>
<td class="text-right">
<button class="link-danger" onclick={() => revoke(m.userId, m.displayName)}>移除</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</section>
+35
View File
@@ -0,0 +1,35 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/**
* 老师端 /app 与管理后台 /database/* 共用这一份 SPA 构建产物,由 hub 后端静态托管
* (见 hub/src/database/static.ts)。服务端不渲染任何页面,只提供 /database/api/*。
*
* 两个关键配置:
*
* - `appDir: '_filelib'` —— 默认 `_app` 会与 admin-web 在同一个 Fastify 实例上注册的
* 根 `/_app/*` 资源路由撞车(见 hub/src/admin/static.ts),Fastify 重复路由会直接
* 在启动时抛错。改名后两套 SPA 的资源路径互不干扰。
*
* - `paths.relative: false` —— 同一份 index.html 会在不同深度的 URL 下被送出
* (`/app`、`/database/dashboard/users`),相对资源路径会解析错。必须用绝对路径。
*/
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
appDir: '_filelib',
paths: {
base: '',
relative: false,
},
},
};
export default config;
+2 -2
View File
@@ -1,4 +1,5 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
@@ -12,6 +13,5 @@
"resolveJsonModule": true,
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}
}
+12 -10
View File
@@ -1,25 +1,27 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
// 老师端独立 SPA:构建产物由 hub 后端静态托管于 /app;
// 开发时 vite dev(:5173)把 API/认证请求代理到后端(:8788)
// 老师端 /app + 管理后台 /database/* 的唯一前端工程;构建产物由 hub 后端静态托管
// 开发时 vite dev(:5173)把 API/认证/一键登录请求代理到后端(:8788);
// 页面路由全部由 SvelteKit 客户端路由处理,后端不参与。
const backend = "http://127.0.0.1:8788";
export default defineConfig({
plugins: [svelte(), tailwindcss()],
base: "/app/",
build: {
outDir: "dist",
emptyOutDir: true,
},
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5173,
proxy: {
"/database/api": backend,
// 免鉴权 bootstrap(org slug + dev 开关);登录页和用户管理页都靠它。
"/database/config": backend,
"/auth": backend,
// 后端拥有的 DEV 一键登录端点(签 cookie 后 302);不代理会被 SPA 回退吃掉。
"/database/dev-login": backend,
"/app/dev-login": backend,
"/app/dev-login-teacher": backend,
// 平台层 org 成员 API(用户管理面板)。
"/api/org": backend,
},
},
});
@@ -0,0 +1,70 @@
-- Global, unlimited-depth member group hierarchy (requirement 3.1-3.3).
-- Managed only by the platform super administrator; deliberately NOT
-- org-scoped. Stores membership + nesting only, never permission data.
-- Deletion is soft (archivedAt / revokedAt markers); a group delete
-- cascade-soft-deletes its whole subtree as an application operation.
-- The permission side (GROUP principal, FOLDER resource, grant inheritance)
-- is intentionally deferred to a later migration.
-- CreateTable
CREATE TABLE "MemberGroup" (
"id" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "MemberGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MemberGroupMembership" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "MemberGroupMembership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MemberGroupClosure" (
"ancestorId" TEXT NOT NULL,
"descendantId" TEXT NOT NULL,
"depth" INTEGER NOT NULL,
CONSTRAINT "MemberGroupClosure_pkey" PRIMARY KEY ("ancestorId", "descendantId")
);
-- CreateIndex
CREATE INDEX "MemberGroup_parentId_archivedAt_idx" ON "MemberGroup"("parentId", "archivedAt");
-- CreateIndex
CREATE INDEX "MemberGroupMembership_userId_revokedAt_idx" ON "MemberGroupMembership"("userId", "revokedAt");
-- CreateIndex
CREATE INDEX "MemberGroupMembership_groupId_revokedAt_idx" ON "MemberGroupMembership"("groupId", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "MemberGroupMembership_groupId_userId_revokedAt_key" ON "MemberGroupMembership"("groupId", "userId", "revokedAt");
-- CreateIndex
CREATE INDEX "MemberGroupClosure_descendantId_idx" ON "MemberGroupClosure"("descendantId");
-- AddForeignKey
ALTER TABLE "MemberGroup" ADD CONSTRAINT "MemberGroup_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "MemberGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_ancestorId_fkey" FOREIGN KEY ("ancestorId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_descendantId_fkey" FOREIGN KEY ("descendantId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+62
View File
@@ -194,6 +194,7 @@ model User {
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[]
memberGroupMemberships MemberGroupMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
@@ -394,6 +395,67 @@ model TeamExternalBinding {
@@index([teamId, revokedAt])
}
// --- Member groups (global, nestable authorization principal) ------------
/// Global, unlimited-depth member group. Managed only by the platform super administrator (ADR-0023);
/// Deletion is soft: `archivedAt` is a marker.
/// Deleting a group cascade-soft-deletes its whole subtree — an application
/// operation (walk the subtree, stamp archivedAt), not a DB constraint.
model MemberGroup {
id String @id @default(cuid())
parentId String?
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
parent MemberGroup? @relation("groupTree", fields: [parentId], references: [id], onDelete: Restrict)
children MemberGroup[] @relation("groupTree")
memberships MemberGroupMembership[]
asAncestor MemberGroupClosure[] @relation("ancestor")
asDescendant MemberGroupClosure[] @relation("descendant")
@@index([parentId, archivedAt])
}
/// User↔group many-to-many; a user may belong to multiple groups. Soft delete
/// via `revokedAt` (same pattern as TeamMembership) allows re-adding a removed
/// member. `userId, revokedAt` index is the resolution hot path: fetch a user's direct groups.
model MemberGroupMembership {
id String @id @default(cuid())
groupId String
userId String
createdAt DateTime @default(now())
revokedAt DateTime?
group MemberGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([groupId, userId, revokedAt])
@@index([userId, revokedAt])
@@index([groupId, revokedAt])
}
/// Transitive closure of the MemberGroup tree. Every group has a depth=0
/// self row. Turns ancestor/descendant resolution into one indexed join
/// instead of a recursive CTE; maintained only on create / reparent (rare
/// super-admin ops, so the write cost is amortized against hot reads). On soft
/// delete the closure rows are retained; resolution filters by
/// MemberGroup.archivedAt. Reparent must reject a new parent inside the moved
/// subtree (cycle guard).
model MemberGroupClosure {
ancestorId String
descendantId String
depth Int
ancestor MemberGroup @relation("ancestor", fields: [ancestorId], references: [id], onDelete: Cascade)
descendant MemberGroup @relation("descendant", fields: [descendantId], references: [id], onDelete: Cascade)
@@id([ancestorId, descendantId])
@@index([descendantId])
}
/// Locally synchronized Feishu external principal membership.
model ExternalDirectoryConnection {
id String @id @default(cuid())
+47 -16
View File
@@ -3,13 +3,36 @@
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
所以服务器启动时能正确识别这些路由。
页面:
**前后端分离**:页面全部在 SvelteKit 静态 SPA `hub/filelib-web/`(与 `hub/admin-web/`
同一套框架)。**老师端 `/app` 与管理后台 `/database` 共用这一份工程和这一份构建产物** ——
两个挂载前缀,一个 SPA。本目录的后端只保留三件事:鉴权透传、JSON 数据端点、
以及把构建产物托管出去。服务端不渲染任何 HTML。
- `/database/admin` —— 飞书登录页(唯一登录方式)
- `/database/dashboard` —— 左菜单 + 右内容的后台,未登录会跳回 `/database/admin`
后端路由:
登录走平台既有的飞书 OAuth:登录页的按钮指向 `/auth/feishu/<orgSlug>`
回调由 `src/admin/routes/authRoutes.ts` 处理并种下 session cookie
- `GET /database/config` —— 免鉴权。返回 `{ orgSlug, devLoginEnabled }`
给 SPA 登录页拼飞书链接、决定是否显示 dev 按钮用。不含任何敏感数据
`/database/api/login-info` 是同形状的既有端点,由 `routes/teacherApp.ts` 注册。)
- `GET /database/api/stats` —— 概览页统计。需登录 **且** 是 silo org OWNER/ADMIN。
- `GET /database/dev-login` —— 仅开发。见下。
- `GET /database``GET /database/*``GET /app``GET /app/*` —— SPA shell /
客户端路由 fallback`static.ts``registerDatabaseSpa`)。
- `GET /_filelib/*` —— 构建产物资源。SvelteKit 的 `appDir` 改名为 `_filelib`
以避开 `admin-web` 在根上注册的 `/_app/*`(同名会让 Fastify 启动即抛重复路由)。
SPA 页面(`filelib-web`,真 URL 路由、无 hash):
- `/app` —— 老师端文件库。未登录显示登录卡片。
- `/database/admin` —— 管理员飞书登录页。按钮指向 `/auth/feishu/<orgSlug>`
回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。
- `/database/dashboard` —— 后台外壳(侧栏 + 权限门)。未登录跳登录页;
**登录但非 OWNER/ADMIN 显示无权提示**。六个 tab 都是子路由:
`/database/dashboard`(概览)、`/library``/users``/groups``/search``/settings`
> **注册顺序要点**concrete 路由(`/database/config`、`/database/api/*`、
> `/database/dev-login`、`/app/dev-login-teacher`)必须在 `registerDatabaseSpa` 的
> `/database/*`、`/app/*` fallback 之前注册(已在 `plugin.ts` 保证),
> 否则通配会 shadow 它们。
## 开发模式:用环境变量开启一键登录
@@ -29,7 +52,8 @@ HUB_DEV_LOGIN_BYPASS="true"
开启后:
- `/database/admin` 登录页显示「⚡ 一键登录管理员」按钮
- `/database/config` 返回 `devLoginEnabled: true`SPA 登录页据此显示
「⚡ 一键登录管理员」按钮
- 后端注册 `/database/dev-login` 端点:按钮就是打它,它签发一个和飞书 OAuth
回调完全一样的 session,然后跳到 `/database/dashboard`
@@ -58,21 +82,25 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
| 文件 | 职责 |
|------|------|
| `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()` |
| `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 |
| `routes/filelibRoutes.ts` | 文件库 树/授权/搜索 API |
| `routes/databaseRoutes.ts` | `/database/config``/database/api/stats`、dev 旁路 + 各子路由装配点 |
| `routes/filelibRoutes.ts` | 文件库 树/授权 API |
| `routes/fileRoutes.ts` | 文件库 文件内容/导出 API |
| `routes/libraryPage.ts` | `/database/library` 文件库浏览页 |
| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) |
| `routes/teacherApp.ts` | `/database/api/login-info` + 老师端 DEV 一键登录 |
| `static.ts` | filelib-web 构建产物托管:`/_filelib/*` 资源 + `/app``/database` 两个 SPA 回退 |
| `filelib/` | 文件库领域层(见下) |
新增一类端点时:要么直接往 `databaseRoutes.ts``app.get("/database/...")`
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts``app.get("/database/api/...")`
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts``registerXxxRoutes(app, {...})`
注册一次。
注册一次。**不要在后端拼 HTML** —— 页面一律加在 `hub/filelib-web/src/routes/` 下。
## 文件库(filelib/)
独立文件库模块,语义由仓库根《文件库-接口契约.md》(C/D 编号)与
`.omo/文件库-开工计划.md`(D11D19)锚定。**与 hub 自己的 Folder/Project
(ADR-0021 explorer)是两套体系,不复用。**
独立文件库模块。代码注释里的 C/D 编号(契约 8.1、C2、C4、D11D19 等)
出自两份已删除的文档:《文件库-接口契约.md》与 `.omo/文件库-开工计划.md`,
内容可从 git 历史取回。其中 D19(网站管理员 = silo org OWNER/ADMIN)
另见 ADR-0028。**与 hub 自己的 Folder/Project(ADR-0021 explorer)是
两套体系,不复用。**
| 文件 | 职责 |
|------|------|
@@ -83,7 +111,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) |
@@ -92,7 +122,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`(需重建项目)。版本团队的
+8 -1
View File
@@ -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;
+9 -5
View File
@@ -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 };
});
}
+5
View File
@@ -16,6 +16,7 @@
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { registerDatabaseRoutes } from "./routes/databaseRoutes.js";
import { registerDatabaseSpa } from "./static.js";
export interface DatabasePluginConfig {
readonly prisma: PrismaClient;
@@ -42,4 +43,8 @@ export async function registerDatabasePlugin(
siloOrganizationSlug: config.siloOrganizationSlug,
allowDevLoginBypass,
});
// SPA shell + fallback, registered after the concrete /database/* routes above
// so the /database/* wildcard does not shadow them.
await registerDatabaseSpa(app);
}
-227
View File
@@ -1,227 +0,0 @@
/**
* 管理员后台「用户管理」「Group 管理」面板(复用 hub 已有 org 管理 API)。
*
* 用户管理 = org 成员(/api/org/:orgSlug/members);
* Group 管理 = Team(当前 Group 的过渡实现,/api/org/:orgSlug/teams),
* 真 Group 系统落地后此面板改接新 API 即可,文件库授权侧不动。
*/
function apiBase(orgSlug: string): string {
return `/api/org/${encodeURIComponent(orgSlug)}`;
}
/* ---------------------------------------------------------------- 用户管理 */
export function renderUsersPanel(orgSlug: string): string {
return `
<div id="users-root" style="max-width:880px">
<div class="panel" style="margin-bottom:14px">
<div class="section-title">添加成员</div>
<div class="inline-form">
<input id="u-openid" class="input" placeholder="用户 openId(飞书 ou_ 开头)" style="flex:2"/>
<input id="u-name" class="input" placeholder="显示名(可选)" style="flex:1"/>
<select id="u-role" class="select" style="width:130px">
<option value="MEMBER">普通老师</option>
<option value="ADMIN">管理员</option>
<option value="OWNER">所有者</option>
</select>
<button id="u-add" class="btn btn-primary">添加</button>
</div>
</div>
<div class="panel">
<div class="section-title">成员列表</div>
<table class="list">
<thead><tr><th>成员</th><th>userId</th><th>角色</th><th></th></tr></thead>
<tbody id="u-tbody"></tbody>
</table>
</div>
</div>
<script>
(function () {
const BASE = "${apiBase(orgSlug)}";
const tbody = document.getElementById("u-tbody");
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
const ROLE_LABEL = { OWNER: "所有者", ADMIN: "管理员", MEMBER: "普通老师" };
async function req(path, opts = {}) {
const res = await fetch(BASE + path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
method: opts.method ?? "GET",
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
return data;
}
async function load() {
const { members } = await req("/members");
tbody.innerHTML = members.length === 0
? '<tr><td colspan="4" class="quiet" style="text-align:center;padding:18px">暂无成员</td></tr>'
: members.map((m) =>
"<tr>" +
"<td>" + esc(m.displayName || m.userId) + "</td>" +
'<td class="file-meta">' + esc(m.userId) + "</td>" +
'<td><select class="select" style="width:110px;padding:3px 8px;font-size:12px" data-user="' + esc(m.userId) + '">' +
["OWNER","ADMIN","MEMBER"].map((r) =>
'<option value="' + r + '"' + (m.role === r ? " selected" : "") + ">" + ROLE_LABEL[r] + "</option>").join("") +
"</select></td>" +
'<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '">移除</button></td>' +
"</tr>").join("");
tbody.querySelectorAll("select[data-user]").forEach((sel) => {
sel.onchange = async () => {
try { await req("/members/" + encodeURIComponent(sel.dataset.user), { method: "PATCH", body: { role: sel.value } }); }
catch (e) { alert(e.message); load(); }
};
});
tbody.querySelectorAll("button[data-revoke]").forEach((btn) => {
btn.onclick = async () => {
if (!confirm("移除该成员?")) return;
try { await req("/members/" + encodeURIComponent(btn.dataset.revoke) + "/revoke", { method: "POST" }); load(); }
catch (e) { alert(e.message); }
};
});
}
document.getElementById("u-add").onclick = async () => {
const openId = document.getElementById("u-openid").value.trim();
if (!openId) return alert("请填写用户 openId");
const displayName = document.getElementById("u-name").value.trim();
try {
await req("/members", { method: "POST", body: {
feishuOpenId: openId,
role: document.getElementById("u-role").value,
...(displayName ? { displayName } : {}),
}});
document.getElementById("u-openid").value = "";
document.getElementById("u-name").value = "";
load();
} catch (e) { alert(e.message); }
};
load().catch((e) => { tbody.innerHTML = '<tr><td colspan="4" style="color:var(--danger);padding:12px">' + esc(e.message) + "</td></tr>"; });
})();
</script>`;
}
/* ---------------------------------------------------------------- Group 管理 */
export function renderGroupsPanel(orgSlug: string): 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>
<div class="panel">
<div class="section-title">Group 列表</div>
<div id="g-list"></div>
</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>
</div>
<script>
(function () {
const BASE = "${apiBase(orgSlug)}";
const listEl = document.getElementById("g-list");
const detailEl = document.getElementById("g-detail");
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
let selected = null;
async function req(path, opts = {}) {
const res = await fetch(BASE + path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
method: opts.method ?? "GET",
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
const text = await res.text();
const data = text ? JSON.parse(text) : null;
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) => {
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)";
loadMembers();
};
});
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); }
};
});
}
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); }
};
});
detailEl.querySelector("#g-add").onclick = async () => {
const v = detailEl.querySelector("#g-add-openid").value.trim();
if (!v) return alert("请填写用户 id");
try {
await req("/teams/" + encodeURIComponent(selected.id) + "/members", {
method: "POST",
body: v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v },
});
loadMembers();
} catch (e) { alert(e.message); }
};
}
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 和名称必填");
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();
} catch (e) { alert(e.message); }
};
loadList().catch((e) => { listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>"; });
})();
</script>`;
}
+51 -223
View File
@@ -5,36 +5,43 @@
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
* literal string it serves.
*
* /database/admin — Feishu-only login page (+ dev one-click button)
* /database/dashboard — sidebar + content admin shell, session-gated
* 标准前后端分离:本文件**不渲染任何 HTML**。登录页与管理后台六个 tab 全部由
* `hub/filelib-web` 这一个 SvelteKit SPA 提供(同一份产物挂 /app 与 /database,
* 见 ../static.ts / registerDatabaseSpa)。此处只留 SPA 依赖的 JSON/跳转端点:
*
* /database/config — unauthenticated bootstrap: silo org slug + dev toggle
* /database/api/stats — 概览页统计(需登录 + silo org OWNER/ADMIN)
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
*
* The dev bypass (button + /database/dev-login) is self-contained here and
* gated by allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS
* + NODE_ENV). Production requires real Feishu OAuth.
* The dev bypass (/database/dev-login) is self-contained here and gated by
* allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS +
* NODE_ENV). Production requires real Feishu OAuth.
*
* NOTE: concrete routes here MUST be registered before the SPA fallback
* (registerDatabaseSpa serves /database and /database/*), or the wildcard would
* shadow them.
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import path from "node:path";
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
import { SESSION_COOKIE_NAME, signSession } 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";
import { UI_HEAD_FONTS, UI_THEME_CSS } from "./uiTheme.js";
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
export interface DatabaseRouteConfig {
readonly prisma: PrismaClient;
/** HMAC secret for the signed session cookie — reused from the admin plane. */
readonly sessionSecret: string;
/** Silo Organization slug — builds the org-scoped Feishu login link. */
/** Silo Organization slug — the SPA builds the org-scoped Feishu login link from it. */
readonly siloOrganizationSlug: string;
/** DEV ONLY. Enables the one-click button and the /database/dev-login route. */
readonly allowDevLoginBypass: boolean;
@@ -47,19 +54,30 @@ export async function registerDatabaseRoutes(
// 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。
let filelibDepsForStats: FileLibRouteDeps | null = null;
app.get("/database/admin", async (request, reply) => {
// Already signed in → straight to the dashboard.
if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) {
return reply.redirect("/database/dashboard");
}
return reply.type("text/html").send(renderLoginPage(config));
});
// 登录页/前端 bootstrap(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。
// 与老师端 /database/api/login-info 同形状 —— 后者由 teacherApp.ts 注册,
// 两处并存是为了兼容既有前端调用点。
app.get("/database/config", async () => ({
orgSlug: config.siloOrganizationSlug,
devLoginEnabled: config.allowDevLoginBypass,
}));
app.get("/database/dashboard", async (request, reply) => {
const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config);
if (user === null) return reply.redirect("/database/admin");
const stats = await loadDashboardStats(config.prisma, filelibDepsForStats);
return reply.type("text/html").send(renderDashboard(user.displayName, stats, config.siloOrganizationSlug));
// 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的
// 计数与审计流,不是某个节点的授权视图,所以不走 per-node 的 role 判定。
app.get("/database/api/stats", async (request, reply) => {
if (filelibDepsForStats === null) {
return reply.status(503).send({ error: { code: "unavailable", message: "filelib not ready" } });
}
const actor = await actorOrNull(request, reply, filelibDepsForStats);
if (actor === null) return reply;
if (!actor.isWebsiteAdmin) {
return reply.status(403).send({ error: { code: "forbidden", message: "requires organization OWNER/ADMIN" } });
}
try {
return await loadDashboardStats(config.prisma, filelibDepsForStats);
} catch (error) {
return sendRouteError(reply, error);
}
});
// DEV ONLY bypass — self-contained here, registered only when the flag is on
@@ -113,9 +131,10 @@ export async function registerDatabaseRoutes(
});
}
// 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。
// 文件库(独立模块,《文件库-接口契约.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 },
@@ -134,13 +153,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,
@@ -148,9 +168,10 @@ export async function registerDatabaseRoutes(
allowDevLoginBypass: config.allowDevLoginBypass,
});
// 独立文件库页已并入后台「文件库」tab(/database/dashboard#library),旧地址跳转保留兼容。
// 独立文件库页已并入后台「文件库」tab;旧地址跳转保留兼容。
// SPA 化后目标是真路由(不再是 #library 锚点)。
app.get("/database/library", async (_request, reply) =>
reply.redirect("/database/dashboard#library"),
reply.redirect("/database/dashboard/library"),
);
filelibDepsForStats = filelibDeps;
@@ -174,9 +195,8 @@ interface DashboardStats {
/** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */
async function loadDashboardStats(
prisma: PrismaClient,
deps: FileLibRouteDeps | null,
): Promise<DashboardStats | null> {
if (deps === null) return null;
deps: FileLibRouteDeps,
): Promise<DashboardStats> {
const organizationId = deps.organizationId;
const [folders, projects, grants] = await Promise.all([
prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }),
@@ -224,195 +244,3 @@ async function loadDashboardStats(
});
return { folders, projects, files, grants, recent };
}
/** Verify the session cookie and load the user, or null if not signed in. */
async function resolveUser(
rawCookie: string | undefined,
config: DatabaseRouteConfig,
): Promise<{ displayName: string } | null> {
if (rawCookie === undefined || rawCookie === "") return null;
const session = verifySession(rawCookie, config.sessionSecret);
if (session === null) return null;
const user = await config.prisma.user.findUnique({
where: { id: session.userId },
select: { displayName: true },
});
return user;
}
/** 管理后台共享 head(全局 UI 主题,与老师端 /app 同源)。 */
function pageHead(title: string): string {
return `<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${title}</title>
${UI_HEAD_FONTS}
<style>${UI_THEME_CSS}</style>
</head>`;
}
function renderLoginPage(config: DatabaseRouteConfig): string {
const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`;
const devButton = config.allowDevLoginBypass
? `<div style="display:flex;align-items:center;gap:10px;margin:22px 0;color:var(--text-3);font-size:11px">
<span style="flex:1;border-top:1px solid var(--border-soft)"></span>开发模式
<span style="flex:1;border-top:1px solid var(--border-soft)"></span>
</div>
<a href="/database/dev-login" class="btn" style="width:100%;justify-content:center">⚡ 一键登录管理员</a>
<p style="margin:10px 0 0;text-align:center;font-size:11px;color:var(--text-3)">仅开发环境可见 · 跳过飞书 OAuth</p>`
: "";
return `<!doctype html>
<html lang="zh-CN">
${pageHead("Database Admin · 登录")}
<body>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px">
<div style="width:100%;max-width:380px;background:var(--panel);border:1px solid var(--border-soft);border-radius:16px;padding:40px 36px;box-shadow:var(--shadow-pop)">
<div style="font-size:26px;font-weight:600;text-align:center">Database Admin</div>
<p style="margin:10px 0 30px;text-align:center;font-size:13px;color:var(--text-3)">使用飞书登录以管理数据库</p>
<a href="${feishuHref}" class="btn btn-primary" style="width:100%;justify-content:center;padding:11px 16px;font-size:14px">使用飞书登录</a>
${devButton}
</div>
</div>
</body>
</html>`;
}
/** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */
const NAV_TABS: ReadonlyArray<{ id: string; label: string; icon: string }> = [
{ id: "overview", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" },
{ id: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" },
{ id: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" },
{ id: "groups", label: "Group 管理", icon: "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.75M23 21v-2a4 4 0 0 0-3-3.87" },
{ id: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" },
{ id: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" },
];
function renderDashboard(displayName: string, stats: DashboardStats | null, orgSlug: string): string {
const nav = NAV_TABS.map((t) => `
<button class="admin-tab" data-tab="${t.id}" style="display:flex;align-items:center;gap:10px;border-radius:10px;padding:9px 14px;font-size:13px;color:var(--text-3);background:none;border:none;cursor:pointer;text-align:left;width:100%">
<svg style="width:16px;height:16px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${t.icon}"/></svg>
${t.label}
</button>`).join("\n");
const cards = [
{ label: "文件夹", value: stats?.folders ?? "—" },
{ label: "项目", value: stats?.projects ?? "—" },
{ label: "文件", value: stats?.files ?? "—" },
{ label: "活跃授权", value: stats?.grants ?? "—" },
].map((s) => `
<div class="panel" style="padding:18px 20px">
<p style="font-size:12.5px;color:var(--text-3)">${s.label}</p>
<p style="margin-top:6px;font-size:28px;font-weight:600;color:var(--text)">${s.value}</p>
</div>`).join("");
const recentRows = stats === null || stats.recent.length === 0
? `<div style="padding:26px 0;text-align:center;font-size:12.5px;color:var(--text-3)">暂无文件库活动 · 到「文件库」里创建第一个文件夹吧</div>`
: stats.recent.map((r) => `
<div style="display:flex;align-items:center;gap:12px;border-top:1px solid var(--border-soft);padding:9px 0;font-size:13px">
<span class="tag">${escapeHtml(r.action)}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)">${escapeHtml(r.label)}</span>
<span style="margin-left:auto;flex-shrink:0;font-size:11.5px;color:var(--text-3)">${escapeHtml(r.actor)} · ${escapeHtml(r.when.toLocaleString("zh-CN"))}</span>
</div>`).join("");
const initial = escapeHtml(displayName.slice(0, 1) || "U");
return `<!doctype html>
<html lang="zh-CN">
${pageHead("Database Admin")}
<body>
<div style="display:flex;height:100vh">
<aside style="width:240px;flex-shrink:0;background:var(--sidebar);border-right:1px solid var(--border-soft);display:flex;flex-direction:column">
<div style="padding:16px 16px 12px;border-bottom:1px solid var(--border-soft)">
<span style="font-size:15px;font-weight:600">Database Admin</span>
</div>
<nav style="flex:1;display:flex;flex-direction:column;gap:2px;padding:10px">
${nav}
</nav>
<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>
</div>
</aside>
<div style="flex:1;display:flex;flex-direction:column;min-width:0">
<section id="tab-overview" class="admin-tab-section" style="flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">概览</h1>
<p style="font-size:11.5px;color:var(--text-3);margin-bottom:20px">文件库实时数据</p>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px">
${cards}
</div>
<div class="panel" style="margin-top:18px">
<h2 style="font-size:13.5px;font-weight:600;margin-bottom:8px">最近活动</h2>
${recentRows}
</div>
</section>
<section id="tab-library" class="admin-tab-section" style="display:none;flex:1;min-height:0">
${renderLibraryBrowser()}
</section>
<section id="tab-users" 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">用户管理</h1>
${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>
<section id="tab-search" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">查询</h1>
<p style="font-size:12.5px;color:var(--text-3)">查询功能建设中</p>
</section>
<section id="tab-settings" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">设置</h1>
<p style="font-size:12.5px;color:var(--text-3)">设置功能建设中</p>
</section>
</div>
</div>
<script>
(function () {
const tabs = [...document.querySelectorAll(".admin-tab")];
const sections = Object.fromEntries(
[...document.querySelectorAll(".admin-tab-section")].map((s) => [s.id.replace("tab-", ""), s]),
);
function activate(id) {
tabs.forEach((t) => {
const on = t.dataset.tab === id;
t.style.background = on ? "var(--selected)" : "none";
t.style.color = on ? "var(--text)" : "var(--text-3)";
t.style.fontWeight = on ? "600" : "400";
});
Object.entries(sections).forEach(([key, s]) => {
s.style.display = key === id ? (key === "library" ? "block" : "block") : "none";
});
if (location.hash !== "#" + id) history.replaceState(null, "", "#" + id);
}
tabs.forEach((t) => t.addEventListener("click", () => activate(t.dataset.tab)));
document.getElementById("logout").addEventListener("click", async () => {
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch (e) {}
location.href = "/database/admin";
});
const fromHash = location.hash.replace(/^#/, "");
activate(tabs.some((t) => t.dataset.tab === fromHash) ? fromHash : "overview");
})();
</script>
</body>
</html>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
+15 -24
View File
@@ -44,10 +44,22 @@ export async function registerFileLibRoutes(
/* ------------------------------------------------------------ 身份 */
// 前端判断能力面用:是否网站管理员(root 创建按钮显隐)。
// displayName/avatarUrl 供侧栏身份区显示 —— 页面不再服务端渲染(ADR-0029),
// 旧 renderDashboard 在 handler 里查 Prisma 拿到的名字,现在必须由这里带出去,
// 否则前端只有 userId 可显示。
app.get("/database/api/me", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
return { userId: actor.userId, isWebsiteAdmin: actor.isWebsiteAdmin };
const user = await deps.prisma.user.findUnique({
where: { id: actor.userId },
select: { displayName: true, avatarUrl: true },
});
return {
userId: actor.userId,
isWebsiteAdmin: actor.isWebsiteAdmin,
displayName: user?.displayName ?? actor.userId,
avatarUrl: user?.avatarUrl ?? null,
};
});
/* ------------------------------------------------------------ 树节点 */
@@ -261,29 +273,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 {
-596
View File
@@ -1,596 +0,0 @@
/**
* 管理员后台「文件库」页内浏览器区块(树 | 内容 | 预览三栏,含授权管理)。
*
* renderLibraryBrowser() 返回可嵌入任意后台布局的 HTML+JS 片段:
* 以 #lib-root 为根、内部用 q() 作用域选择器,不与宿主页其他元素冲突。
* 与独立页版浏览器的区别:去掉了自己的 wordmark/用户栏(由后台外壳提供),
* 保留管理员工具(根目录创建、授权面板、独立权限开关)。
*/
export function renderLibraryBrowser(): string {
return `
<div id="lib-root" style="display:flex;height:100%;min-height:0">
<!-- 左:目录树 -->
<div style="width:250px;flex-shrink:0;border-right:1px solid var(--border-soft);display:flex;flex-direction:column;background:var(--sidebar)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--border-soft)">
<span style="font-size:13px;font-weight:600;color:var(--text)">文件库</span>
<button id="lib-btn-new-root" class="btn hidden" style="padding:3px 10px;font-size:11px" title="新建根目录">+ 根目录</button>
</div>
<div id="lib-tree" style="flex:1;overflow-y:auto;padding:8px 8px 16px;font-size:13px"></div>
</div>
<!-- 中:节点内容 -->
<div id="lib-main" style="flex:1;overflow-y:auto;min-width:0">
<div class="lib-empty-hint">从左侧选择一个文件夹或项目</div>
</div>
<!-- 右:预览/编辑(选中文件时出现) -->
<div id="lib-preview" style="display:none;width:44%;min-width:380px;flex-shrink:0;overflow-y:auto;border-left:1px solid var(--border-soft);padding:16px"></div>
<div id="lib-modal-root"></div>
<div id="lib-toast-root" style="position:fixed;bottom:16px;right:16px;z-index:50;display:flex;flex-direction:column;gap:8px"></div>
</div>
<style>
#lib-root .lib-empty-hint { height:100%;display:flex;align-items:center;justify-content:center;color:var(--text-3);font-size:13px; }
#lib-root .tree-item { display:flex;align-items:center;gap:4px;padding:4px 6px;border-radius:6px;cursor:pointer;color:var(--text);transition:background 120ms ease;user-select:none; }
#lib-root .tree-item:hover { background:var(--hover); }
#lib-root .tree-item.selected { background:var(--selected); }
#lib-root .tree-children { border-left:1px solid var(--guide);margin-left:15px;padding-left:3px; }
#lib-root .tree-icon { width:16px;height:16px;flex-shrink:0;display:flex;align-items:center;justify-content:center; }
#lib-root .tree-caret { color:var(--text-3); }
#lib-root .tree-badge { margin-left:auto;padding-right:4px;font-size:10px;color:var(--text-3);font-family:var(--mono); }
#lib-root .tree-empty { padding:24px 12px;text-align:center;font-size:12px;color:var(--text-3); }
#lib-root .lib-toast { border-radius:8px;padding:8px 14px;font-size:12px;color:#fff;background:#333230;max-width:320px; }
#lib-root .lib-toast-err { background:#7E2C26; }
#lib-root .modal-mask { position:fixed;inset:0;z-index:40;background:rgba(26,26,24,.3);display:flex;align-items:center;justify-content:center;padding:16px; }
#lib-root .modal-card { width:100%;max-width:440px;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:22px; }
#lib-root pre.diff { white-space:pre-wrap;font-family:var(--mono);font-size:12px;line-height:1.7;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px;margin:0; }
#lib-root pre.diff .add { display:block;color:var(--diff-add-text);background:var(--diff-add-bg); }
#lib-root pre.diff .del { display:block;color:var(--diff-del-text);background:var(--diff-del-bg); }
#lib-root .meta-grid { display:grid;grid-template-columns:1fr 1fr;gap:10px 24px;font-size:13px;color:var(--text-2); }
#lib-root .meta-grid b { color:var(--text);font-weight:600; }
#lib-root .conflict-panel { margin-top:14px;border:1px solid #E8E2C8;background:#FCFBF4;border-radius:10px;padding:14px 16px; }
#lib-root .conflict-title { font-size:13px;font-weight:600;color:#6E6329;margin-bottom:8px; }
#lib-root .conflict-note { font-size:11px;color:#8A8059;margin-top:8px; }
#lib-root .export-status { font-size:11px;color:var(--text-3);font-family:var(--mono); }
</style>
<script>
(function () {
const libRoot = document.getElementById("lib-root");
if (!libRoot) return;
const $ = (sel) => libRoot.querySelector(sel);
const $$ = (sel) => [...libRoot.querySelectorAll(sel)];
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
if (res.status === 204) return null;
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = (data && data.error) || { code: "unknown", message: res.statusText };
const e = new Error(err.message); e.code = err.code; e.status = res.status; Object.assign(e, err);
throw e;
}
return data;
}
function toast(msg, kind = "info") {
const el = document.createElement("div");
el.className = "lib-toast" + (kind === "err" ? " lib-toast-err" : "");
el.textContent = msg;
$("#lib-toast-root").appendChild(el);
setTimeout(() => el.remove(), 3600);
}
const ok = (m) => toast(m, "ok");
const fail = (e) => toast(e.message || String(e), "err");
function modal(html) {
const root = $("#lib-modal-root");
root.innerHTML = '<div class="modal-mask"><div class="modal-card">' + html + "</div></div>";
root.firstElementChild.addEventListener("click", (e) => { if (e.target === e.currentTarget) closeModal(); });
}
function closeModal() { $("#lib-modal-root").innerHTML = ""; }
let me = { userId: null, isWebsiteAdmin: false };
let current = null;
const expanded = new Set();
let currentFile = null;
const ICONS = {
caretRight: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M9 6l6 6-6 6z"/></svg>',
caretDown: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M6 9l6 6 6-6z"/></svg>',
folder: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z"/></svg>',
project: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>',
};
async function loadChildren(parentId) {
const q = parentId === null ? "" : "?parentId=" + encodeURIComponent(parentId);
return (await api("/database/api/nodes" + q)).nodes;
}
async function renderTree() {
const tree = $("#lib-tree");
tree.innerHTML = "";
try {
const roots = await loadChildren(null);
if (roots.length === 0) {
tree.innerHTML = '<div class="tree-empty">空文件库' +
(me.isWebsiteAdmin ? " · 点上方「+ 根目录」开始" : "") + "</div>";
return;
}
for (const node of roots) tree.appendChild(await treeItem(node, 0));
} catch (e) { tree.innerHTML = '<div class="tree-empty">' + esc(e.message) + "</div>"; }
}
async function treeItem(node, depth) {
const wrap = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-item" + (current && current.id === node.id ? " selected" : "");
const caret = document.createElement("span");
caret.className = "tree-icon tree-caret";
if (node.kind === "FOLDER") {
caret.innerHTML = expanded.has(node.id) ? ICONS.caretDown : ICONS.caretRight;
caret.onclick = (e) => { e.stopPropagation(); toggleExpand(node.id); };
}
row.appendChild(caret);
const iconSpan = document.createElement("span");
iconSpan.className = "tree-icon";
iconSpan.style.color = node.kind === "PROJECT" ? "#1A1A18" : "#9C9B96";
iconSpan.innerHTML = node.kind === "PROJECT" ? ICONS.project : ICONS.folder;
row.appendChild(iconSpan);
const nameSpan = document.createElement("span");
nameSpan.className = "truncate";
nameSpan.textContent = node.name;
row.appendChild(nameSpan);
if (node.role !== "MANAGE") {
const badge = document.createElement("span");
badge.className = "tree-badge";
badge.textContent = node.role;
row.appendChild(badge);
}
row.onclick = () => {
if (node.kind === "FOLDER") {
if (expanded.has(node.id)) expanded.delete(node.id); else expanded.add(node.id);
}
selectNode(node.id);
};
wrap.appendChild(row);
if (node.kind === "FOLDER" && expanded.has(node.id)) {
wrap.appendChild(await childrenBlock(node.id, depth));
}
return wrap;
}
async function childrenBlock(parentId, depth) {
const block = document.createElement("div");
block.className = "tree-children";
const kids = await loadChildren(parentId);
for (const kid of kids) block.appendChild(await treeItem(kid, depth + 1));
return block;
}
async function toggleExpand(id) {
if (expanded.has(id)) expanded.delete(id); else expanded.add(id);
await renderTree();
}
async function selectNode(id) {
try {
const [{ node }, { breadcrumb }] = await Promise.all([
api("/database/api/nodes/" + id),
api("/database/api/nodes/" + id + "/breadcrumb"),
]);
current = node; current.breadcrumb = breadcrumb;
closePreview();
await renderTree();
renderMain();
} catch (e) { fail(e); }
}
function crumbsHtml() {
return current.breadcrumb.map((c) =>
c.name === null ? "<span>…</span>" : "<span>" + esc(c.name) + "</span>"
).join('<span style="margin:0 4px;color:var(--border)">/</span>');
}
function renderMain() {
const n = current;
const canManage = n.role === "MANAGE";
const canEdit = canManage || n.role === "EDIT";
const tabs = [];
tabs.push(["detail", "概览"]);
if (n.kind === "PROJECT") tabs.push(["files", "文件"]);
if (canManage) tabs.push(["grants", "授权"]);
current.tab = current.tab && tabs.some(t => t[0] === current.tab) ? current.tab : "detail";
$("#lib-main").innerHTML =
'<div style="max-width:860px;margin:0 auto;padding:28px 28px 56px">' +
'<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">' + crumbsHtml() + "</div>" +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">' +
'<div style="font-size:17px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:8px">' + esc(n.name) +
'<span class="tag">' + n.kind + "</span>" +
'<span class="tag tag-role">' + n.role + "</span>" +
"</div>" +
'<div style="display:flex;gap:6px">' +
(canEdit && n.kind === "FOLDER" ? '<button class="btn" onclick="window.__lib.createChild()">+ 新建子节点</button>' : "") +
(canManage ? '<button class="btn" onclick="window.__lib.renameCurrent()">重命名</button>' : "") +
(canManage ? '<button class="btn btn-danger" onclick="window.__lib.deleteCurrent()">删除</button>' : "") +
"</div>" +
"</div>" +
'<div class="tabs">' +
tabs.map(([id, label]) =>
'<button onclick="window.__lib.switchTab(\\'' + id + '\\')" class="tab' +
(current.tab === id ? " active" : "") + '">' + label + "</button>").join("") +
"</div>" +
'<div id="lib-tab-body"></div>' +
"</div>";
renderTab();
}
function renderTab() {
const body = $("#lib-tab-body");
if (current.tab === "files") return renderFilesTab(body);
if (current.tab === "grants") return renderGrantsTab(body);
let extra = "";
if (current.kind === "PROJECT") {
extra =
'<div class="divider"></div>' +
'<div class="inline-form" style="justify-content:flex-start">' +
'<span class="quiet">独立权限</span>' +
'<b style="font-size:13px">' + (current.independentPermission ? "开启" : "关闭") + "</b>" +
(current.role === "MANAGE"
? '<button class="btn" onclick="window.__lib.toggleIndependent()">' + (current.independentPermission ? "关闭" : "开启") + "</button>"
: "") +
'<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>' +
"</div>" +
'<div class="divider"></div>' +
'<div class="section-title">导出</div>' +
'<div class="inline-form">' +
'<select id="lib-x-target" class="select" style="width:auto"><option value="manifest">manifest(stub)</option></select>' +
'<button class="btn" onclick="window.__lib.submitExport()">开始导出</button>' +
'<span id="lib-x-status" class="export-status"></span>' +
"</div>";
}
body.innerHTML =
'<div class="panel">' +
'<div class="meta-grid">' +
"<div>类型 <b>" + current.kind + "</b></div>" +
"<div>我的权限 <b>" + current.role + "</b></div>" +
"<div>创建时间 " + new Date(current.createdAt).toLocaleString("zh-CN") + "</div>" +
"<div>更新时间 " + new Date(current.updatedAt).toLocaleString("zh-CN") + "</div>" +
"</div>" + extra +
"</div>";
pollExport();
}
/* ---------------- 节点操作 ---------------- */
function createDialog(parentId) {
modal(
'<div class="modal-title">' + (parentId ? "新建子节点" : "新建根目录") + "</div>" +
'<div class="form-row"><label class="form-label">名称</label>' +
'<input id="lib-f-name" class="input" placeholder="例如:物理必修一"/></div>' +
'<div class="form-row"><label class="form-label">类型</label>' +
'<select id="lib-f-kind" class="select">' +
'<option value="FOLDER">文件夹</option><option value="PROJECT">项目(Git 仓库)</option>' +
"</select></div>" +
'<div class="modal-actions">' +
'<button class="btn" onclick="window.__lib.closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="window.__lib.submitCreate(\\'' + (parentId ?? "") + '\\')">创建</button>' +
"</div>"
);
}
async function submitCreate(parentId) {
try {
await api("/database/api/nodes", { method: "POST", body: {
parentId: parentId === "" ? null : parentId,
kind: $("#lib-f-kind").value, name: $("#lib-f-name").value,
}});
closeModal(); ok("已创建");
if (parentId) expanded.add(parentId);
await renderTree();
} catch (e) { fail(e); }
}
/* ---------------- 授权 ---------------- */
async function renderGrantsTab(body) {
try {
const { grants } = await api("/database/api/nodes/" + current.id + "/grants");
const rows = grants.map((g) =>
"<tr>" +
"<td>" + (g.principalType === "USER" ? "👤" : "👥") + " " + esc(g.principalId) +
(g.isCreatorGrant ? ' <span class="quiet">(创建者)</span>' : "") + "</td>" +
'<td class="file-meta">' + g.role + "</td>" +
"<td style='text-align:right'>" + (g.isCreatorGrant ? "" :
'<button class="link-danger" onclick="window.__lib.revokeGrant(\\'' + g.id + '\\')">收回</button>') + "</td>" +
"</tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<table class="list"><thead><tr><th>主体</th><th>级别</th><th></th></tr></thead>' +
"<tbody>" + rows + "</tbody></table>" +
'<div class="divider"></div>' +
'<div class="section-title">新增授权</div>' +
'<div class="inline-form">' +
'<select id="lib-g-type" class="select" style="width:110px" onchange="window.__lib.gTypeChanged()">' +
'<option value="USER">用户</option><option value="GROUP">Group</option></select>' +
'<input id="lib-g-principal" class="input" placeholder="用户 id"/>' +
'<select id="lib-g-group" class="select hidden"></select>' +
'<select id="lib-g-role" class="select" style="width:110px">' +
'<option value="VIEW">VIEW</option><option value="EDIT">EDIT</option><option value="MANAGE">MANAGE</option></select>' +
'<button class="btn btn-primary" onclick="window.__lib.addGrant()">授予</button>' +
"</div>" +
'<div class="section-note">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>' +
"</div>";
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
/* ---------------- 文件 ---------------- */
async function renderFilesTab(body) {
try {
const { files } = await api("/database/api/projects/" + current.id + "/files");
const rows = files.map((f) =>
'<tr class="file-row" onclick="window.__lib.openFile(\\'' + esc(f.path) + '\\')">' +
'<td class="file-path">' + esc(f.path) + "</td>" +
'<td class="file-meta" style="text-align:right">' + f.size + " B</td></tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:6px">' +
'<div class="section-title" style="margin:0">项目文件(' + files.length + ")</div>" +
(current.role !== "VIEW"
? '<div class="inline-form" style="gap:6px">' +
'<button class="btn" onclick="window.__lib.newFileDialog()">+ 新建文件</button>' +
'<button class="btn btn-primary" onclick="document.getElementById(\\'lib-upload-input\\').click()">上传文件</button>' +
'<input id="lib-upload-input" type="file" class="hidden"/>' +
"</div>"
: "") +
"</div>" +
(files.length === 0 ? '<div class="quiet" style="padding:20px 0;text-align:center">空仓库 · 可新建或上传文件</div>'
: '<table class="list">' + rows + "</table>") +
"</div>";
const up = $("#lib-upload-input");
if (up) up.onchange = (e) => doUpload(e.target);
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
function newFileDialog() {
modal(
'<div class="modal-title">新建文件</div>' +
'<div class="form-row"><label class="form-label">路径</label>' +
'<input id="lib-nf-path" class="input" style="font-family:var(--mono)" placeholder="docs/intro.md"/></div>' +
'<div class="form-row"><label class="form-label">内容</label>' +
'<textarea id="lib-nf-content" rows="8" class="textarea" placeholder="内容…"></textarea></div>' +
'<div class="modal-actions"><button class="btn" onclick="window.__lib.closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="window.__lib.submitNewFile()">创建</button></div>'
);
}
async function submitNewFile() {
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body: {
path: $("#lib-nf-path").value, content: $("#lib-nf-content").value,
}});
closeModal(); ok("已创建"); renderTab();
} catch (e) { fail(e); }
}
function u8ToBase64(bytes) {
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
async function doUpload(input) {
const file = input.files && input.files[0];
input.value = "";
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("文件超过 10MB 上限", "err");
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
if (!targetPath) return;
const bytes = new Uint8Array(await file.arrayBuffer());
const isBinary = bytes.includes(0);
const body = isBinary
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body });
ok("已上传 " + file.name);
renderTab();
} catch (e) { fail(e); }
}
async function openFile(path) {
try {
const f = await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(path));
currentFile = f;
const canEdit = current.role !== "VIEW";
const prev = $("#lib-preview");
prev.style.display = "block";
prev.innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:10px">' +
'<span class="file-meta">' + esc(f.path) + " @ " + esc(f.version) + "</span>" +
'<div class="inline-form" style="gap:6px">' +
'<a class="btn" href="/database/api/projects/' + current.id + '/file/raw?path=' +
encodeURIComponent(f.path) + '" download>下载</a>' +
'<button class="btn" onclick="window.__lib.showHistory()">历史</button>' +
(canEdit ? '<button class="btn btn-danger" onclick="window.__lib.deleteFile()">删除文件</button>' : "") +
'<button class="btn" onclick="window.__lib.closePreview()" title="关闭预览">✕</button>' +
"</div>" +
"</div>" +
(f.encoding === "base64"
? '<div class="quiet">二进制文件(' + f.size + " B),不支持在线编辑</div>"
: '<textarea id="lib-ef-content" rows="16" class="textarea" ' + (canEdit ? "" : "readonly") +
">" + esc(f.content) + "</textarea>") +
(canEdit && f.encoding !== "base64"
? '<div class="modal-actions"><button class="btn btn-primary" onclick="window.__lib.saveFile()">提交修改</button></div>'
: "") +
'<div id="lib-conflict-zone"></div>' +
"</div>";
} catch (e) { fail(e); }
}
function closePreview() {
const prev = $("#lib-preview");
if (prev) { prev.style.display = "none"; prev.innerHTML = ""; }
currentFile = null;
}
async function saveFile() {
try {
const r = await api("/database/api/projects/" + current.id + "/file/commits", { method: "POST", body: {
path: currentFile.path, baseVersion: currentFile.version, content: $("#lib-ef-content").value,
}});
ok("已提交 " + r.version); await openFile(currentFile.path);
} catch (e) {
if (e.status === 409 && e.currentVersion) return showConflict(e.currentVersion);
fail(e);
}
}
async function showConflict(currentVersion) {
const { diff } = await api("/database/api/projects/" + current.id + "/file/diff?path=" +
encodeURIComponent(currentFile.path) + "&from=" + encodeURIComponent(currentFile.version) +
"&to=" + encodeURIComponent(currentVersion));
$("#lib-conflict-zone").innerHTML =
'<div class="conflict-panel">' +
'<div class="conflict-title">冲突:他人已提交 ' + esc(currentVersion) + ",差异如下(你的基版 → 最新版)</div>" +
'<pre class="diff">' + esc(diff)
.replace(/^\\+(.*)$/gm, '<span class="add">+$1</span>')
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>') + "</pre>" +
'<div class="conflict-note">请人工合并后,以最新内容为全文重新提交(基版将更新为 ' + esc(currentVersion) + ")</div>" +
'<div class="modal-actions"><button class="btn" onclick="window.__lib.acceptLatest()">载入最新内容</button></div>' +
"</div>";
currentFile.version = currentVersion;
}
async function deleteFile() {
if (!confirm("删除文件 " + currentFile.path + "?")) return;
try {
await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(currentFile.path),
{ method: "DELETE", body: { baseVersion: currentFile.version } });
ok("已删除"); closePreview(); renderTab();
} catch (e) { fail(e); }
}
async function showHistory() {
const { history } = await api("/database/api/projects/" + current.id + "/file/history?path=" +
encodeURIComponent(currentFile.path));
modal(
'<div class="modal-title">版本历史</div>' +
'<div style="max-height:320px;overflow-y:auto">' +
history.map((v) =>
'<div style="border-top:1px solid #F5F5F4;padding:8px 0;font-size:12px">' +
'<span class="file-meta" style="color:var(--accent)">' + esc(v.version) + "</span> " + esc(v.message) +
'<div class="quiet">' + new Date(v.committedAt).toLocaleString("zh-CN") +
(v.author ? " · " + esc(v.author) : "") + "</div></div>").join("") +
"</div>" +
'<div class="modal-actions"><button class="btn" onclick="window.__lib.closeModal()">关闭</button></div>'
);
}
/* ---------------- 导出 ---------------- */
let exportJobId = null;
async function submitExport() {
try {
const r = await api("/database/api/projects/" + current.id + "/exports", { method: "POST", body: { target: $("#lib-x-target").value } });
exportJobId = r.jobId;
$("#lib-x-status").textContent = "任务 " + r.jobId.slice(0, 8) + "… 排队中";
pollExport();
} catch (e) { fail(e); }
}
async function pollExport() {
if (!exportJobId) return;
try {
const job = await api("/database/api/exports/" + exportJobId);
const el = $("#lib-x-status");
if (!el) return;
if (job.status === "DONE") {
el.innerHTML = '完成 · <a href="/database/api/exports/' + exportJobId + '/download">下载</a>';
} else if (job.status === "FAILED") {
el.textContent = "失败:" + (job.error || "");
} else {
el.textContent = "状态:" + job.status + "…";
setTimeout(pollExport, 1000);
}
} catch { /* ignore */ }
}
/* ---------------- 暴露给内联 onclick 的操作表 ---------------- */
window.__lib = {
createChild() { createDialog(current.id); },
async renameCurrent() {
const name = prompt("新名称", current.name);
if (name === null) return;
try {
await api("/database/api/nodes/" + current.id, { method: "PATCH", body: { name } });
ok("已重命名"); await selectNode(current.id);
} catch (e) { fail(e); }
},
async deleteCurrent() {
if (!confirm("确认删除「" + current.name + "」?软删除后不可见。")) return;
try {
await api("/database/api/nodes/" + current.id, { method: "DELETE" });
ok("已删除"); current = null; await renderTree();
$("#lib-main").innerHTML = '<div class="lib-empty-hint">从左侧选择一个文件夹或项目</div>';
closePreview();
} catch (e) { fail(e); }
},
switchTab(id) { current.tab = id; renderMain(); },
async toggleIndependent() {
try {
await api("/database/api/projects/" + current.id + "/independent-permission", {
method: "PUT", body: { enabled: !current.independentPermission },
});
ok("已切换"); await selectNode(current.id);
} catch (e) { fail(e); }
},
async gTypeChanged() {
const isGroup = $("#lib-g-type").value === "GROUP";
$("#lib-g-principal").classList.toggle("hidden", isGroup);
$("#lib-g-group").classList.toggle("hidden", !isGroup);
if (isGroup && $("#lib-g-group").options.length === 0) {
const { groups } = await api("/database/api/groups/search?q=");
$("#lib-g-group").innerHTML = groups.map((g) => '<option value="' + esc(g.id) + '">' + esc(g.name) + "</option>").join("");
}
},
async addGrant() {
const type = $("#lib-g-type").value;
const principalId = type === "GROUP" ? $("#lib-g-group").value : $("#lib-g-principal").value.trim();
if (!principalId) return toast("请填写主体", "err");
try {
await api("/database/api/nodes/" + current.id + "/grants", { method: "PUT", body: {
grants: [{ principalType: type, principalId, role: $("#lib-g-role").value }],
}});
ok("已授予"); renderTab();
} catch (e) { fail(e); }
},
async revokeGrant(grantId) {
try {
await api("/database/api/nodes/" + current.id + "/grants/" + grantId, { method: "DELETE" });
ok("已收回"); renderTab();
} catch (e) { fail(e); }
},
newFileDialog, submitNewFile, openFile, closePreview, saveFile, deleteFile, showHistory,
acceptLatest() { openFile(currentFile.path); toast("已载入最新内容,请在此基础上合并", "info"); },
submitExport, closeModal,
};
/* ---------------- 初始化 ---------------- */
(async function init() {
try {
me = await api("/database/api/me");
if (me.isWebsiteAdmin) {
const btn = $("#lib-btn-new-root");
btn.classList.remove("hidden");
btn.onclick = () => createDialog(null);
}
} catch (e) { /* 401 已由 api 处理跳转 */ }
await renderTree();
})();
})();
</script>`;
}
-801
View File
@@ -1,801 +0,0 @@
/**
* /database/library —— 文件库浏览页(管理员后台的文件工具)。
*
* 服务端渲染外壳 + 浏览器端 JS 调 /database/api/*(同源 session cookie)。
* 设计令牌与全局 UI 主题(uiTheme.ts)一致。能力面全部由 API 的 404/403 表达(D8)。
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { SESSION_COOKIE_NAME, verifySession } from "../../admin/auth/session.js";
export interface LibraryPageConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
}
export async function registerLibraryPage(
app: FastifyInstance,
config: LibraryPageConfig,
): Promise<void> {
app.get("/database/library", async (request, reply) => {
const raw = request.cookies[SESSION_COOKIE_NAME];
const session = raw === undefined || raw === "" ? null : verifySession(raw, config.sessionSecret);
if (session === null) return reply.redirect("/database/admin");
const user = await config.prisma.user.findUnique({
where: { id: session.userId },
select: { displayName: true },
});
if (user === null) return reply.redirect("/database/admin");
return reply.type("text/html").send(renderLibraryPage(user.displayName));
});
}
function renderLibraryPage(displayName: string): string {
const initial = displayName.slice(0, 1).replace(/[&<>"']/, "U");
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>文件库</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
<style>
/* 设计令牌:与全局 UI 主题(uiTheme.ts)保持一致。 */
:root {
--bg: #FCFCFB;
--panel: #FFFFFF;
--sidebar: #F7F7F5;
--text: #1A1A18;
--text-2: #6B6A66;
--text-3: #9C9B96;
--border: #ECECE8;
--border-soft: #F1F1EE;
--accent: #1A1A18;
--accent-hover: #333330;
--selected: #EBEBE7;
--hover: #F4F4F1;
--danger: #A13A33;
--guide: #E9E9E5;
--diff-add-bg: #F3F6F2;
--diff-add-text: #4A6741;
--diff-del-bg: #F8F2F1;
--diff-del-text: #A13A33;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: 'Inter', -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg); color: var(--text);
font-size: 14px; line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
/* 布局 */
.shell { display: flex; height: 100vh; }
.sidebar {
width: 292px; flex-shrink: 0; background: var(--sidebar);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
}
.sidebar-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.wordmark { font-size: 14px; font-weight: 600; color: var(--text); }
.sidebar-actions { display: flex; gap: 6px; }
.tree { flex: 1; overflow-y: auto; padding: 8px 8px 16px; font-size: 13px; }
.sidebar-user {
margin: 0; padding: 10px 16px;
border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--text-2);
}
.avatar {
width: 24px; height: 24px; 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;
}
.main { flex: 1; overflow-y: auto; }
.main-inner { max-width: 860px; margin: 0 auto; padding: 32px 32px 64px; }
.empty-hint {
height: 100%; display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-size: 13px;
}
/* 树 */
.tree-item {
display: flex; align-items: center; gap: 4px;
padding: 4px 6px; border-radius: 6px; cursor: pointer;
color: var(--text); transition: background 120ms ease;
user-select: none;
}
.tree-item:hover { background: var(--hover); }
.tree-item.selected { background: var(--selected); color: #1C1917; }
.tree-children { border-left: 1px solid var(--guide); margin-left: 15px; padding-left: 3px; }
.tree-icon { width: 16px; height: 16px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.tree-caret { color: var(--text-3); }
.tree-badge { margin-left: auto; padding-right: 4px; font-size: 10px; color: var(--text-3); font-family: var(--mono); }
.tree-empty { padding: 24px 12px; text-align: center; font-size: 12px; color: var(--text-3); }
/* 按钮 */
.btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 5px 12px; border-radius: 8px;
border: 1px solid var(--border); background: var(--panel);
color: var(--accent); font-size: 12px; font-weight: 500;
cursor: pointer; transition: all 120ms ease; text-decoration: none;
}
.btn:hover { background: var(--hover); border-color: #D6D3D1; }
.btn-primary {
background: var(--accent); border-color: var(--accent); color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger {
border-color: transparent; background: transparent; color: var(--danger);
}
.btn-danger:hover { background: var(--diff-del-bg); border-color: transparent; }
/* 内容区 */
.crumbs { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
.crumbs span + span::before { content: " / "; color: var(--border); }
.node-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.node-title { font-size: 17px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.tag {
font-size: 10px; font-weight: 500; font-family: var(--mono);
padding: 2px 7px; border-radius: 999px;
border: 1px solid var(--border); color: var(--text-2); background: var(--panel);
}
.tag-role { color: var(--accent); border-color: #D6D3D1; }
.node-actions { display: flex; gap: 6px; }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.tab {
padding: 8px 14px; font-size: 13px; color: var(--text-2);
border: none; background: none; cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color 120ms ease;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); }
.panel {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 20px 22px;
}
.panel + .panel, .panel + #file-editor { margin-top: 14px; }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; font-size: 13px; color: var(--text-2); }
.meta-grid b { color: var(--text); font-weight: 600; }
.section-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 10px; }
.section-note { font-size: 11px; color: var(--text-3); margin-top: 6px; }
/* 表格 */
table.list { width: 100%; border-collapse: collapse; font-size: 13px; }
table.list th { text-align: left; font-size: 11px; font-weight: 500; color: var(--text-3); padding: 4px 0; }
table.list td { padding: 7px 0; border-top: 1px solid #F5F5F4; }
table.list tr:first-child td { border-top: none; }
.file-row { cursor: pointer; }
.file-row:hover td { background: var(--hover); }
.file-path { font-family: var(--mono); font-size: 12px; color: var(--text); }
.file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.link-danger { color: var(--danger); font-size: 12px; background: none; border: none; cursor: pointer; padding: 0; }
.link-danger:hover { text-decoration: underline; }
/* 表单 */
.input, .select, .textarea {
width: 100%; padding: 7px 10px; border-radius: 8px;
border: 1px solid var(--border); background: var(--panel);
font-size: 13px; color: var(--text); font-family: inherit;
outline: none; transition: border-color 120ms ease;
}
.input:focus, .select:focus, .textarea:focus { border-color: var(--accent); }
.textarea { font-family: var(--mono); font-size: 12px; line-height: 1.7; resize: vertical; }
.form-label { display: block; font-size: 11px; color: var(--text-2); margin-bottom: 4px; }
.form-row { margin-bottom: 12px; }
.inline-form { display: flex; gap: 8px; align-items: center; }
.inline-form .input { flex: 1; }
/* 弹层 */
.modal-mask {
position: fixed; inset: 0; z-index: 40;
background: rgba(26, 26, 24, .3);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal-card {
width: 100%; max-width: 440px; background: var(--panel);
border: 1px solid var(--border); border-radius: 12px; padding: 22px;
}
.modal-title { font-size: 15px; font-weight: 600; margin-bottom: 14px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.toast {
border-radius: 8px; padding: 8px 14px; font-size: 12px; color: #fff;
background: #333230; max-width: 320px;
}
.toast-ok { background: #333230; }
.toast-err { background: #7E2C26; }
#toast-root { position: fixed; bottom: 16px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; }
/* 冲突与 diff */
.conflict-panel {
margin-top: 14px; border: 1px solid #E8E2C8; background: #FCFBF4;
border-radius: 10px; padding: 14px 16px;
}
.conflict-title { font-size: 13px; font-weight: 600; color: #6E6329; margin-bottom: 8px; }
.conflict-note { font-size: 11px; color: #8A8059; margin-top: 8px; }
pre.diff {
white-space: pre-wrap; font-family: var(--mono); font-size: 12px; line-height: 1.7;
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px;
margin: 0;
}
pre.diff .add { display: block; color: var(--diff-add-text); background: var(--diff-add-bg); }
pre.diff .del { display: block; color: var(--diff-del-text); background: var(--diff-del-bg); }
.divider { border-top: 1px solid var(--border); margin: 14px 0; }
.quiet { color: var(--text-3); font-size: 12px; }
.export-status { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.export-status a { color: var(--accent); }
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="sidebar-head">
<span class="wordmark">文件库</span>
<div class="sidebar-actions">
<button id="btn-new-root" class="btn hidden" title="新建根目录">+ 根目录</button>
<a class="btn" href="/database/dashboard">后台</a>
</div>
</div>
<div id="tree" class="tree"></div>
<div class="sidebar-user">
<div class="avatar">${initial}</div>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)">${displayName}</span>
<button id="btn-logout" class="btn" style="padding:3px 10px;font-size:11px" title="退出登录">退出</button>
</div>
</aside>
<main class="main" id="main">
<div class="empty-hint">从左侧选择一个文件夹或项目</div>
</main>
</div>
<div id="modal-root"></div>
<div id="toast-root"></div>
<script>
/* ---------------- 基础设施 ---------------- */
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
if (res.status === 204) return null;
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = (data && data.error) || { code: "unknown", message: res.statusText };
const e = new Error(err.message); e.code = err.code; e.status = res.status; Object.assign(e, err);
throw e;
}
return data;
}
function toast(msg, kind = "info") {
const el = document.createElement("div");
el.className = "toast " + (kind === "err" ? "toast-err" : "toast-ok");
el.textContent = msg;
$("#toast-root").appendChild(el);
setTimeout(() => el.remove(), 3600);
}
const ok = (m) => toast(m, "ok");
const fail = (e) => toast(e.message || String(e), "err");
function modal(html) {
const root = $("#modal-root");
root.innerHTML = '<div class="modal-mask"><div class="modal-card">' + html + "</div></div>";
root.firstElementChild.addEventListener("click", (e) => { if (e.target === e.currentTarget) closeModal(); });
}
function closeModal() { $("#modal-root").innerHTML = ""; }
/* ---------------- 状态 ---------------- */
let me = { userId: null, isWebsiteAdmin: false };
let current = null;
const expanded = new Set();
/* ---------------- 目录树 ---------------- */
/* 图标统一用固定尺寸的 inline SVG。每行几何一致:[16px 箭头槽][16px 图标槽][名称]。 */
const ICONS = {
caretRight: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M9 6l6 6-6 6z"/></svg>',
caretDown: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M6 9l6 6 6-6z"/></svg>',
folder: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z"/></svg>',
project: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>',
};
async function loadChildren(parentId) {
const q = parentId === null ? "" : "?parentId=" + encodeURIComponent(parentId);
return (await api("/database/api/nodes" + q)).nodes;
}
async function renderTree() {
const tree = $("#tree");
tree.innerHTML = "";
try {
const roots = await loadChildren(null);
if (roots.length === 0) {
tree.innerHTML = '<div class="tree-empty">空文件库' +
(me.isWebsiteAdmin ? " · 点上方「+ 根目录」开始" : "") + "</div>";
return;
}
for (const node of roots) tree.appendChild(await treeItem(node, 0));
} catch (e) { tree.innerHTML = '<div class="tree-empty">' + esc(e.message) + "</div>"; }
}
async function treeItem(node, depth) {
const wrap = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-item" + (current && current.id === node.id ? " selected" : "");
const caret = document.createElement("span");
caret.className = "tree-icon tree-caret";
if (node.kind === "FOLDER") {
caret.innerHTML = expanded.has(node.id) ? ICONS.caretDown : ICONS.caretRight;
caret.onclick = (e) => { e.stopPropagation(); toggleExpand(node.id, wrap, depth); };
}
row.appendChild(caret);
const iconSpan = document.createElement("span");
iconSpan.className = "tree-icon";
iconSpan.style.color = node.kind === "PROJECT" ? "#1A1A18" : "#9C9B96";
iconSpan.innerHTML = node.kind === "PROJECT" ? ICONS.project : ICONS.folder;
row.appendChild(iconSpan);
const nameSpan = document.createElement("span");
nameSpan.className = "truncate";
nameSpan.textContent = node.name;
row.appendChild(nameSpan);
if (node.role !== "MANAGE") {
const badge = document.createElement("span");
badge.className = "tree-badge";
badge.textContent = node.role;
row.appendChild(badge);
}
row.onclick = () => {
if (node.kind === "FOLDER") {
if (expanded.has(node.id)) expanded.delete(node.id); else expanded.add(node.id);
}
selectNode(node.id);
};
wrap.appendChild(row);
if (node.kind === "FOLDER" && expanded.has(node.id)) {
wrap.appendChild(await childrenBlock(node.id, depth));
}
return wrap;
}
async function childrenBlock(parentId, depth) {
const block = document.createElement("div");
block.className = "tree-children";
const kids = await loadChildren(parentId);
for (const kid of kids) block.appendChild(await treeItem(kid, depth + 1));
return block;
}
async function toggleExpand(id, wrap, depth) {
if (expanded.has(id)) expanded.delete(id); else expanded.add(id);
await renderTree();
}
/* ---------------- 节点详情 ---------------- */
async function selectNode(id) {
try {
const [{ node }, { breadcrumb }] = await Promise.all([
api("/database/api/nodes/" + id),
api("/database/api/nodes/" + id + "/breadcrumb"),
]);
current = node; current.breadcrumb = breadcrumb;
await renderTree();
renderMain();
} catch (e) { fail(e); }
}
function crumbsHtml() {
return current.breadcrumb.map((c) =>
c.name === null ? "<span>…</span>" : "<span>" + esc(c.name) + "</span>"
).join("");
}
function renderMain() {
const n = current;
const canManage = n.role === "MANAGE";
const canEdit = canManage || n.role === "EDIT";
const tabs = [];
tabs.push(["detail", "概览"]);
if (n.kind === "PROJECT") tabs.push(["files", "文件"]);
if (canManage) tabs.push(["grants", "授权"]);
current.tab = current.tab && tabs.some(t => t[0] === current.tab) ? current.tab : "detail";
$("#main").innerHTML =
'<div class="main-inner">' +
'<div class="crumbs">' + crumbsHtml() + "</div>" +
'<div class="node-head">' +
'<div class="node-title">' + esc(n.name) +
'<span class="tag">' + n.kind + "</span>" +
'<span class="tag tag-role">' + n.role + "</span>" +
"</div>" +
'<div class="node-actions">' +
(canEdit && n.kind === "FOLDER" ? '<button class="btn" onclick="createChild()">+ 新建子节点</button>' : "") +
(canManage ? '<button class="btn" onclick="renameCurrent()">重命名</button>' : "") +
(canManage ? '<button class="btn btn-danger" onclick="deleteCurrent()">删除</button>' : "") +
"</div>" +
"</div>" +
'<div class="tabs">' +
tabs.map(([id, label]) =>
'<button onclick="switchTab(\\'' + id + '\\')" class="tab' +
(current.tab === id ? " active" : "") + '">' + label + "</button>").join("") +
"</div>" +
'<div id="tab-body"></div>' +
"</div>";
renderTab();
}
function switchTab(id) { current.tab = id; renderMain(); }
async function renderTab() {
const body = $("#tab-body");
if (current.tab === "files") return renderFilesTab(body);
if (current.tab === "grants") return renderGrantsTab(body);
let extra = "";
if (current.kind === "PROJECT") {
extra =
'<div class="divider"></div>' +
'<div class="inline-form" style="justify-content:flex-start">' +
'<span class="quiet">独立权限</span>' +
'<b style="font-size:13px">' + (current.independentPermission ? "开启" : "关闭") + "</b>" +
(current.role === "MANAGE"
? '<button class="btn" onclick="toggleIndependent()">' + (current.independentPermission ? "关闭" : "开启") + "</button>"
: "") +
'<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>' +
"</div>" +
'<div class="divider"></div>' +
'<div class="section-title">导出</div>' +
'<div class="inline-form">' +
'<select id="x-target" class="select" style="width:auto"><option value="manifest">manifest(stub)</option></select>' +
'<button class="btn" onclick="submitExport()">开始导出</button>' +
'<span id="x-status" class="export-status"></span>' +
"</div>";
}
body.innerHTML =
'<div class="panel">' +
'<div class="meta-grid">' +
"<div>类型 <b>" + current.kind + "</b></div>" +
"<div>我的权限 <b>" + current.role + "</b></div>" +
"<div>创建时间 " + new Date(current.createdAt).toLocaleString("zh-CN") + "</div>" +
"<div>更新时间 " + new Date(current.updatedAt).toLocaleString("zh-CN") + "</div>" +
"</div>" + extra +
"</div>";
pollExport();
}
/* ---------------- 节点操作 ---------------- */
async function createChild() {
createDialog(current.id);
}
function createDialog(parentId) {
modal(
'<div class="modal-title">' + (parentId ? "新建子节点" : "新建根目录") + "</div>" +
'<div class="form-row"><label class="form-label">名称</label>' +
'<input id="f-name" class="input" placeholder="例如:物理必修一"/></div>' +
'<div class="form-row"><label class="form-label">类型</label>' +
'<select id="f-kind" class="select">' +
'<option value="FOLDER">文件夹</option><option value="PROJECT">项目(Git 仓库)</option>' +
"</select></div>" +
'<div class="modal-actions">' +
'<button class="btn" onclick="closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="submitCreate(\\'' + (parentId ?? "") + '\\')">创建</button>' +
"</div>"
);
}
async function submitCreate(parentId) {
try {
await api("/database/api/nodes", { method: "POST", body: {
parentId: parentId === "" ? null : parentId,
kind: $("#f-kind").value, name: $("#f-name").value,
}});
closeModal(); ok("已创建");
if (parentId) expanded.add(parentId);
await renderTree();
} catch (e) { fail(e); }
}
async function renameCurrent() {
const name = prompt("新名称", current.name);
if (name === null) return;
try {
await api("/database/api/nodes/" + current.id, { method: "PATCH", body: { name } });
ok("已重命名"); await selectNode(current.id);
} catch (e) { fail(e); }
}
async function deleteCurrent() {
if (!confirm("确认删除「" + current.name + "」?软删除后不可见。")) return;
try {
await api("/database/api/nodes/" + current.id, { method: "DELETE" });
ok("已删除"); current = null; await renderTree();
$("#main").innerHTML = '<div class="empty-hint">从左侧选择一个文件夹或项目</div>';
} catch (e) { fail(e); }
}
async function toggleIndependent() {
try {
await api("/database/api/projects/" + current.id + "/independent-permission", {
method: "PUT", body: { enabled: !current.independentPermission },
});
ok("已切换"); await selectNode(current.id);
} catch (e) { fail(e); }
}
/* ---------------- 授权 ---------------- */
async function renderGrantsTab(body) {
try {
const { grants } = await api("/database/api/nodes/" + current.id + "/grants");
const rows = grants.map((g) =>
"<tr>" +
"<td>" + (g.principalType === "USER" ? "👤" : "👥") + " " + esc(g.principalId) +
(g.isCreatorGrant ? ' <span class="quiet">(创建者)</span>' : "") + "</td>" +
'<td class="file-meta">' + g.role + "</td>" +
"<td style='text-align:right'>" + (g.isCreatorGrant ? "" :
'<button class="link-danger" onclick="revokeGrant(\\'' + g.id + '\\')">收回</button>') + "</td>" +
"</tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<table class="list"><thead><tr><th>主体</th><th>级别</th><th></th></tr></thead>' +
"<tbody>" + rows + "</tbody></table>" +
'<div class="divider"></div>' +
'<div class="section-title">新增授权</div>' +
'<div class="inline-form">' +
'<select id="g-type" class="select" style="width:110px" onchange="gTypeChanged()">' +
'<option value="USER">用户</option><option value="GROUP">Group</option></select>' +
'<input id="g-principal" class="input" placeholder="用户 id"/>' +
'<select id="g-group" class="select hidden"></select>' +
'<select id="g-role" class="select" style="width:110px">' +
'<option value="VIEW">VIEW</option><option value="EDIT">EDIT</option><option value="MANAGE">MANAGE</option></select>' +
'<button class="btn btn-primary" onclick="addGrant()">授予</button>' +
"</div>" +
'<div class="section-note">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>' +
"</div>";
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
async function gTypeChanged() {
const isGroup = $("#g-type").value === "GROUP";
$("#g-principal").classList.toggle("hidden", isGroup);
$("#g-group").classList.toggle("hidden", !isGroup);
if (isGroup && $("#g-group").options.length === 0) {
const { groups } = await api("/database/api/groups/search?q=");
$("#g-group").innerHTML = groups.map((g) => '<option value="' + esc(g.id) + '">' + esc(g.name) + "</option>").join("");
}
}
async function addGrant() {
const type = $("#g-type").value;
const principalId = type === "GROUP" ? $("#g-group").value : $("#g-principal").value.trim();
if (!principalId) return toast("请填写主体", "err");
try {
await api("/database/api/nodes/" + current.id + "/grants", { method: "PUT", body: {
grants: [{ principalType: type, principalId, role: $("#g-role").value }],
}});
ok("已授予"); renderTab();
} catch (e) { fail(e); }
}
async function revokeGrant(grantId) {
try {
await api("/database/api/nodes/" + current.id + "/grants/" + grantId, { method: "DELETE" });
ok("已收回"); renderTab();
} catch (e) { fail(e); }
}
/* ---------------- 文件 ---------------- */
let currentFile = null;
async function renderFilesTab(body) {
try {
const { files } = await api("/database/api/projects/" + current.id + "/files");
const rows = files.map((f) =>
'<tr class="file-row" onclick=\\'openFile("' + esc(f.path) + '")\\'>' +
'<td class="file-path">' + esc(f.path) + "</td>" +
'<td class="file-meta" style="text-align:right">' + f.size + " B</td></tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:6px">' +
'<div class="section-title" style="margin:0">项目文件(' + files.length + ")</div>" +
(current.role !== "VIEW"
? '<div class="inline-form" style="gap:6px">' +
'<button class="btn" onclick="newFileDialog()">+ 新建文件</button>' +
'<button class="btn btn-primary" onclick="document.getElementById(\\'upload-input\\').click()">上传文件</button>' +
'<input id="upload-input" type="file" class="hidden" onchange="doUpload(this)"/>' +
"</div>"
: "") +
"</div>" +
(files.length === 0 ? '<div class="quiet" style="padding:20px 0;text-align:center">空仓库 · 可新建或上传文件</div>'
: '<table class="list">' + rows + "</table>") +
"</div>" +
'<div id="file-editor"></div>';
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
async function newFileDialog() {
modal(
'<div class="modal-title">新建文件</div>' +
'<div class="form-row"><label class="form-label">路径</label>' +
'<input id="nf-path" class="input" style="font-family:var(--mono)" placeholder="docs/intro.md"/></div>' +
'<div class="form-row"><label class="form-label">内容</label>' +
'<textarea id="nf-content" rows="8" class="textarea" placeholder="内容…"></textarea></div>' +
'<div class="modal-actions"><button class="btn" onclick="closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="submitNewFile()">创建</button></div>'
);
}
/* 磁盘文件上传:文本走 utf8,含 NUL 字节判为二进制走 base64(与服务端一致)。 */
function u8ToBase64(bytes) {
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
async function doUpload(input) {
const file = input.files && input.files[0];
input.value = "";
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("文件超过 10MB 上限", "err");
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
if (!targetPath) return;
const bytes = new Uint8Array(await file.arrayBuffer());
const isBinary = bytes.includes(0);
const body = isBinary
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body });
ok("已上传 " + file.name);
renderTab();
} catch (e) { fail(e); }
}
async function submitNewFile() {
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body: {
path: $("#nf-path").value, content: $("#nf-content").value,
}});
closeModal(); ok("已创建"); renderTab();
} catch (e) { fail(e); }
}
async function openFile(path) {
try {
const f = await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(path));
currentFile = f;
const canEdit = current.role !== "VIEW";
$("#file-editor").innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:10px">' +
'<span class="file-meta">' + esc(f.path) + " @ " + esc(f.version) + "</span>" +
'<div class="inline-form" style="gap:6px">' +
'<a class="btn" href="/database/api/projects/' + current.id + '/file/raw?path=' +
encodeURIComponent(f.path) + '" download>下载</a>' +
'<button class="btn" onclick="showHistory()">历史</button>' +
(canEdit ? '<button class="btn btn-danger" onclick="deleteFile()">删除文件</button>' : "") +
"</div>" +
"</div>" +
(f.encoding === "base64"
? '<div class="quiet">二进制文件(' + f.size + " B),不支持在线编辑</div>"
: '<textarea id="ef-content" rows="14" class="textarea" ' + (canEdit ? "" : "readonly") +
">" + esc(f.content) + "</textarea>") +
(canEdit && f.encoding !== "base64"
? '<div class="modal-actions"><button class="btn btn-primary" onclick="saveFile()">提交修改</button></div>'
: "") +
'<div id="conflict-zone"></div>' +
"</div>";
} catch (e) { fail(e); }
}
async function saveFile() {
try {
const r = await api("/database/api/projects/" + current.id + "/file/commits", { method: "POST", body: {
path: currentFile.path, baseVersion: currentFile.version, content: $("#ef-content").value,
}});
ok("已提交 " + r.version); await openFile(currentFile.path);
} catch (e) {
if (e.status === 409 && e.currentVersion) return showConflict(e.currentVersion);
fail(e);
}
}
async function showConflict(currentVersion) {
const { diff } = await api("/database/api/projects/" + current.id + "/file/diff?path=" +
encodeURIComponent(currentFile.path) + "&from=" + encodeURIComponent(currentFile.version) +
"&to=" + encodeURIComponent(currentVersion));
$("#conflict-zone").innerHTML =
'<div class="conflict-panel">' +
'<div class="conflict-title">冲突:他人已提交 ' + esc(currentVersion) + ",差异如下(你的基版 → 最新版)</div>" +
'<pre class="diff">' + esc(diff)
.replace(/^\\+(.*)$/gm, '<span class="add">+$1</span>')
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>') + "</pre>" +
'<div class="conflict-note">请人工合并后,以最新内容为全文重新提交(基版将更新为 ' + esc(currentVersion) + ")</div>" +
'<div class="modal-actions"><button class="btn" onclick="acceptLatest(\\'' + esc(currentVersion) + '\\')">载入最新内容</button></div>' +
"</div>";
currentFile.version = currentVersion;
}
async function acceptLatest() { await openFile(currentFile.path); toast("已载入最新内容,请在此基础上合并", "info"); }
async function deleteFile() {
if (!confirm("删除文件 " + currentFile.path + "?")) return;
try {
await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(currentFile.path),
{ method: "DELETE", body: { baseVersion: currentFile.version } });
ok("已删除"); currentFile = null; renderTab();
} catch (e) { fail(e); }
}
async function showHistory() {
const { history } = await api("/database/api/projects/" + current.id + "/file/history?path=" +
encodeURIComponent(currentFile.path));
modal(
'<div class="modal-title">版本历史</div>' +
'<div style="max-height:320px;overflow-y:auto">' +
history.map((v) =>
'<div style="border-top:1px solid #F5F5F4;padding:8px 0;font-size:12px">' +
'<span class="file-meta" style="color:var(--accent)">' + esc(v.version) + "</span> " + esc(v.message) +
'<div class="quiet">' + new Date(v.committedAt).toLocaleString("zh-CN") +
(v.author ? " · " + esc(v.author) : "") + "</div></div>").join("") +
"</div>" +
'<div class="modal-actions"><button class="btn" onclick="closeModal()">关闭</button></div>'
);
}
/* ---------------- 导出 ---------------- */
let exportJobId = null;
async function submitExport() {
try {
const r = await api("/database/api/projects/" + current.id + "/exports", { method: "POST", body: { target: $("#x-target").value } });
exportJobId = r.jobId;
$("#x-status").textContent = "任务 " + r.jobId.slice(0, 8) + "… 排队中";
pollExport();
} catch (e) { fail(e); }
}
async function pollExport() {
if (!exportJobId) return;
try {
const job = await api("/database/api/exports/" + exportJobId);
const el = $("#x-status");
if (!el) return;
if (job.status === "DONE") {
el.innerHTML = '完成 · <a href="/database/api/exports/' + exportJobId + '/download">下载</a>';
} else if (job.status === "FAILED") {
el.textContent = "失败:" + (job.error || "");
} else {
el.textContent = "状态:" + job.status + "…";
setTimeout(pollExport, 1000);
}
} catch { /* job 可能属于别的项目 */ }
}
/* ---------------- 启动 ---------------- */
(async function init() {
const logoutBtn = $("#btn-logout");
if (logoutBtn) {
logoutBtn.addEventListener("click", async () => {
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch {}
location.href = "/database/admin";
});
}
try {
me = await api("/database/api/me");
if (me.isWebsiteAdmin) {
const btn = $("#btn-new-root");
btn.classList.remove("hidden");
btn.onclick = () => createDialog(null);
}
} catch (e) { /* 401 已由 api 处理跳转 */ }
await renderTree();
})();
</script>
</body>
</html>`;
}
@@ -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);
}
});
}
+5 -28
View File
@@ -1,15 +1,12 @@
/**
* 老师端用户前端托管(标准前后端分离):
* GET /app/* — filelib-web(Svelte SPA)构建产物静态托管 + SPA 回退
* GET /database/api/login-info — 登录页配置(org slug / dev 开关)
* GET /app/dev-login{,-teacher} — DEV ONLY 一键登录(管理员 / 普通老师)
* 老师端后端支撑(标准前后端分离):
* GET /database/api/login-info — 登录页配置(org slug / dev 开关)
* GET /app/dev-login-teacher — DEV ONLY 一键登录(普通老师)
*
* 服务端不渲染老师端页面;页面由独立前端工程 hub/filelib-web 产出。
* 服务端不渲染任何页面。`/app/*` 的静态托管与 SPA 回退在 ../static.ts —— 那里
* 与管理后台 `/database/*` 共用同一份 filelib-web 构建产物,资源路由只注册一次。
*/
import fs from "node:fs";
import path from "node:path";
import fastifyStatic from "@fastify/static";
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
@@ -33,26 +30,6 @@ export async function registerTeacherApp(
devLoginEnabled: config.allowDevLoginBypass,
}));
// 标准分离:静态托管 SPA 构建产物;非文件路径回退 index.html 交给前端。
const distDir = path.resolve(import.meta.dirname, "../../../filelib-web/dist");
if (fs.existsSync(path.join(distDir, "index.html"))) {
await app.register(fastifyStatic, { root: distDir, prefix: "/app/", decorateReply: true });
app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith("/app")) {
return reply.sendFile("index.html", distDir);
}
return reply.status(404).send({ error: { code: "not_found", message: "not found" } });
});
} else {
app.log.warn({ distDir }, "filelib-web dist not found; build it with `npm run build --prefix filelib-web`");
app.get("/app", async (_request, reply) =>
reply
.status(503)
.type("text/plain")
.send("filelib-web 未构建。请先运行 npm run build --prefix hub/filelib-web"),
);
}
if (!config.allowDevLoginBypass) return;
registerDevLogins(app, config);
}
-138
View File
@@ -1,138 +0,0 @@
/**
* 全局共享 UI 主题(老师端 /app、管理员后台 /database、文件页 /database/library)。
*
* 方向:高级简约、零视觉疲劳 —— 暖白底、发丝边框、近黑主按钮(唯一强调色)、
* 无渐变、无彩色、无阴影(弹层仅一丝)、Inter 全界面、克制的动效。
* 所有页面的设计令牌收敛于此,调色只改这里。
*/
export const UI_HEAD_FONTS = `
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
`;
export const UI_THEME_CSS = `
:root {
--bg: #FCFCFB;
--panel: #FFFFFF;
--sidebar: #F7F7F5;
--text: #1A1A18;
--text-2: #6B6A66;
--text-3: #9C9B96;
--border: #ECECE8;
--border-soft: #F1F1EE;
--hover: #F4F4F1;
--selected: #EBEBE7;
--accent: #1A1A18;
--accent-hover: #333330;
--danger: #A13A33;
--guide: #E9E9E5;
--diff-add-bg: #F3F6F2;
--diff-add-text: #4A6741;
--diff-del-bg: #F8F2F1;
--diff-del-text: #A13A33;
--sans: 'Inter', -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--shadow-pop: 0 4px 20px rgba(26,26,24,.07);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: var(--sans);
background: var(--bg); color: var(--text);
font-size: 14px; line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
h1, h2, h3 { margin: 0; }
a { color: var(--text); text-decoration: none; }
a:hover { text-decoration: underline; }
/* 按钮:近黑实心(主)/ 发丝边幽灵(次)/ 文字型(危险) */
.btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 14px; border-radius: 8px;
border: 1px solid var(--border); background: var(--panel);
color: var(--text); font-size: 12.5px; font-weight: 500;
cursor: pointer; transition: all 120ms ease; text-decoration: none;
}
.btn:hover { background: var(--hover); text-decoration: none; }
.btn-primary {
background: var(--accent); border-color: var(--accent); color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger { border-color: transparent; background: transparent; color: var(--danger); }
.btn-danger:hover { background: #A13A3312; }
/* 面板与标签 */
.panel {
background: var(--panel); border: 1px solid var(--border-soft);
border-radius: 10px; padding: 20px 22px;
}
.tag {
font-size: 10.5px; font-weight: 500; font-family: var(--mono);
padding: 2px 8px; border-radius: 999px;
border: 1px solid var(--border-soft); color: var(--text-3); background: var(--panel);
}
.tag-role { color: var(--text-2); border-color: var(--border); }
/* 页签 */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border-soft); margin-bottom: 18px; }
.tab {
padding: 8px 14px; font-size: 13px; color: var(--text-3);
border: none; background: none; cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color 120ms ease;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); }
/* 表单 */
.input, .select, .textarea {
width: 100%; padding: 7px 11px; border-radius: 8px;
border: 1px solid var(--border); background: var(--panel);
font-size: 13px; color: var(--text); font-family: inherit;
outline: none; transition: border-color 120ms ease;
}
.input:focus, .select:focus, .textarea:focus { border-color: var(--accent); }
.textarea { font-family: var(--mono); font-size: 12.5px; line-height: 1.75; resize: vertical; }
.form-label { display: block; font-size: 11.5px; color: var(--text-3); margin-bottom: 4px; }
.form-row { margin-bottom: 12px; }
.inline-form { display: flex; gap: 8px; align-items: center; }
.inline-form .input { flex: 1; }
/* 表格 */
table.list { width: 100%; border-collapse: collapse; font-size: 13px; }
table.list th { text-align: left; font-size: 11.5px; font-weight: 500; color: var(--text-3); padding: 4px 0; }
table.list td { padding: 8px 0; border-top: 1px solid var(--border-soft); }
table.list tr:first-child td { border-top: none; }
/* 弹层 */
.modal-mask {
position: fixed; inset: 0; z-index: 40;
background: rgba(26,26,24,.3);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal-card {
width: 100%; max-width: 430px; background: var(--panel);
border: 1px solid var(--border-soft); border-radius: 14px; padding: 22px;
box-shadow: var(--shadow-pop);
}
.modal-title { font-size: 15px; font-weight: 600; margin-bottom: 14px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.toast {
border-radius: 8px; padding: 8px 16px; font-size: 12.5px; color: #fff;
background: #333230; max-width: 330px;
}
.toast-err { background: #7E2C26; }
#toast-root { position: fixed; bottom: 18px; right: 18px; z-index: 50; display: flex; flex-direction: column; gap: 8px; }
.quiet { color: var(--text-3); font-size: 12.5px; }
.divider { border-top: 1px solid var(--border-soft); margin: 14px 0; }
.section-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
.section-note { font-size: 11.5px; color: var(--text-3); margin-top: 6px; }
.file-path { font-family: var(--mono); font-size: 12.5px; color: var(--text); }
.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; }
`;
+71
View File
@@ -0,0 +1,71 @@
/**
* 前端静态托管:老师端 `/app/*` 与管理后台 `/database/*` 共用 `hub/filelib-web`
* 这**一份** SvelteKit 构建产物(adapter-static + fallback,见 filelib-web/svelte.config.js)。
*
* 标准前后端分离:服务端不渲染任何页面 —— 两个前缀下都只把同一个 index.html
* 原样送出,由 SvelteKit 客户端路由决定显示哪个视图;数据一律走 /database/api/*。
*
* 三类路由:
* /_filelib/* — 构建产物资源(JS/CSS/字体)。SvelteKit 的 appDir 被改名为
* `_filelib`,以避开 admin-web 在根上注册的 /_app/*
* (见 ../admin/static.ts)—— 同名会让 Fastify 启动即抛重复路由。
* /app, /app/* — 老师端 SPA 回退
* /database, /database/* — 管理后台 SPA 回退
*
* 具体路由(/database/api/*、/database/dev-login、/app/dev-login-teacher 等)由
* databaseRoutes.ts / teacherApp.ts 先注册;Fastify 按具体度匹配,通配不遮蔽它们。
*
* Override the UI directory with `CPH_FILELIB_UI_DIR` if needed.
*/
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve as resolvePath } from "node:path";
import fastifyStatic from "@fastify/static";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
function resolveUiDir(): string {
const override = process.env["CPH_FILELIB_UI_DIR"];
if (override && override.trim() !== "") return resolvePath(override);
const here = dirname(fileURLToPath(import.meta.url));
return resolvePath(join(here, "..", "..", "filelib-web", "build"));
}
/** 构建产物根目录下的顶层静态文件(SvelteKit 把 static/ 原样拷到这里)。 */
const TOP_LEVEL_FILES = ["favicon.svg", "favicon.ico", "robots.txt"] as const;
export async function registerDatabaseSpa(app: FastifyInstance): Promise<void> {
const uiDir = resolveUiDir();
if (!existsSync(join(uiDir, "index.html"))) {
app.log.warn(
{ uiDir },
"filelib-web/build not found; /app and /database SPA shells disabled. Run `npm run build --prefix filelib-web` to enable. JSON APIs remain fully functional.",
);
return;
}
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
// 构建资源。@fastify/static 负责 MIME、ETag/Last-Modified 与目录穿越防护。
// 只注册一次 —— /app 与 /database 下的页面引用的都是这同一组绝对路径
// (filelib-web 的 paths.relative=false 保证了这点)。
await app.register(fastifyStatic, {
root: join(uiDir, "_filelib"),
prefix: "/_filelib/",
decorateReply: true,
});
for (const name of TOP_LEVEL_FILES) {
if (!existsSync(join(uiDir, name))) continue;
app.get(`/${name}`, async (_request, reply) => reply.sendFile(name, uiDir));
}
// SPA 回退:两个前缀,同一份 index.html。处理器不读请求、不查库 —— 所以
// 启动时读一次缓存在闭包里是安全的(每个请求发出的字节完全相同)。
const sendIndex = async (_request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply> =>
reply.type("text/html; charset=utf-8").send(indexHtml);
app.get("/app", sendIndex);
app.get("/app/*", sendIndex);
app.get("/database", sendIndex);
app.get("/database/*", sendIndex);
}
+13 -4
View File
@@ -2,8 +2,8 @@
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
*
* Counts dynamic traffic only: APIs, auth, and other application handlers.
* Static SPA assets and the org-admin HTML shell are exempt so a single page
* load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
* Static SPA assets and the admin HTML shells are exempt so a single page load
* (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
*/
/** Paths that must not consume the silo HTTP request-rate budget. */
@@ -12,12 +12,21 @@ export function isSiloHttpRateLimitExempt(url: string): boolean {
if (path === "/api/healthz") return true;
// SvelteKit build output and top-level static files (see admin/static.ts).
// admin-web SvelteKit build output at the root, and top-level static files
// (see admin/static.ts).
if (path === "/_app" || path.startsWith("/_app/")) return true;
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
// SPA index shell for client-side routes (not an API).
// filelib-web SvelteKit build output. appDir 改名为 `_filelib` 以避开根 /_app/*
// (见 database/static.ts);同一份产物服务 /app 与 /database 两个前缀。
if (path === "/_filelib" || path.startsWith("/_filelib/")) return true;
// SPA index shells for client-side routes (not APIs). /admin/*、/database/* 与
// /app/* 整体豁免 —— 客户端路由无法预先枚举;这也覆盖了每次加载一次的
// /database/config bootstrap。
if (path === "/admin" || path.startsWith("/admin/")) return true;
if (path === "/database" || path.startsWith("/database/")) return true;
if (path === "/app" || path.startsWith("/app/")) return true;
return false;
}
+311
View File
@@ -0,0 +1,311 @@
/**
* 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0028。
* 覆盖:嵌套创建 + 闭包维护、解析(直接组 ∪ 活跃祖先)、祖先授权递归传递
* (3.2)、级联软删 + 实时失效、非管理员 403、成员增删幂等/重加、搜索 breadcrumb。
* 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。
*/
import { beforeEach, describe, expect, it } from "vitest";
import { prisma, resetDb, DEFAULT_ORG_ID } from "./helpers.js";
import {
addMember,
createMemberGroup,
deleteMemberGroup,
listMemberGroups,
listMembers,
removeMember,
searchMemberGroups,
searchUsers,
updateMemberGroup,
type MemberGroupServiceDeps,
} from "../../src/database/filelib/memberGroupService.js";
import { createMemberGroupResolver } from "../../src/database/filelib/memberGroupResolver.js";
import {
createNode,
getEffectiveRole,
type FileLibActor,
type TreeServiceDeps,
} from "../../src/database/filelib/treeService.js";
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true };
const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false };
function svc(): MemberGroupServiceDeps {
return { prisma, organizationId: DEFAULT_ORG_ID };
}
/** treeService deps 用真实 MemberGroup 解析器,串起「组授权 → 成员生效」全链路。 */
function treeDeps(): TreeServiceDeps {
return {
prisma,
groupResolver: createMemberGroupResolver(prisma),
versionStore: createInMemoryVersionStore(),
organizationId: DEFAULT_ORG_ID,
storageRoot: "/tmp/member-groups-test",
};
}
async function seedUsers(): Promise<void> {
for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) {
await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } });
}
}
/** 建嵌套链 A>B>C,返回三者 id。 */
async function seedChain(): Promise<{ a: string; b: string; c: string }> {
const a = await createMemberGroup(svc(), ADMIN, { name: "A" });
const b = await createMemberGroup(svc(), ADMIN, { name: "B", parentId: a.id });
const c = await createMemberGroup(svc(), ADMIN, { name: "C", parentId: b.id });
return { a: a.id, b: b.id, c: c.id };
}
beforeEach(async () => {
await resetDb();
await seedUsers();
});
describe("memberGroupService · 创建与闭包", () => {
it("建根 depth0;建子继承祖先闭包,depth 递增", async () => {
const { a, b, c } = await seedChain();
// 闭包不变量:C 有 (A,C,2)/(B,C,1)/(C,C,0) 三行。
const closureC = await prisma.memberGroupClosure.findMany({
where: { descendantId: c },
orderBy: { depth: "asc" },
});
expect(closureC.map((r) => [r.ancestorId, r.depth])).toEqual([
[c, 0], [b, 1], [a, 2],
]);
const groups = await listMemberGroups(svc(), ADMIN);
const byId = new Map(groups.map((g) => [g.id, g]));
expect(byId.get(a)?.depth).toBe(0);
expect(byId.get(b)?.depth).toBe(1);
expect(byId.get(c)?.depth).toBe(2);
expect(byId.get(b)?.parentId).toBe(a);
});
it("父组不存在/已归档 → 404", async () => {
await expect(createMemberGroup(svc(), ADMIN, { name: "X", parentId: "nope" }))
.rejects.toMatchObject({ statusCode: 404 });
});
});
describe("memberGroupResolver · 解析(直接组 活跃祖先)", () => {
it("成员在 C → 解析得 {C,B,A};无所属 → 空", async () => {
const { a, b, c } = await seedChain();
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
const resolver = createMemberGroupResolver(prisma);
const ids = await resolver.resolveMemberGroupIds("u_alice");
expect([...ids].sort()).toEqual([a, b, c].sort());
expect(await resolver.resolveMemberGroupIds("u_bob")).toEqual([]);
});
});
describe("memberGroupService · 3.2 祖先授权递归传递", () => {
it("给祖先组 A 授文件夹权限 → C 的成员经 effectiveRole 拿到该权限", async () => {
const { a, c } = await seedChain();
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
// ADMIN 建根文件夹,授权给"祖先组 A"。ALICE 只在 C,靠祖先方向解析命中 A。
const folder = await createNode(treeDeps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "共享",
grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }],
});
expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT");
});
});
describe("memberGroupService · 改名/改描述(决策6)", () => {
it("改名不动闭包:depth/parentId/子树关系全保持", async () => {
const { a, b, c } = await seedChain();
const before = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
const updated = await updateMemberGroup(svc(), ADMIN, b, { name: "B2", description: "改后" });
expect(updated.name).toBe("B2");
expect(updated.description).toBe("改后");
expect(updated.parentId).toBe(a);
expect(updated.depth).toBe(1);
// 闭包逐行未变 —— rename 不碰层级(ADR-0028 决策6 的核心不变量)。
const after = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
expect(after).toEqual(before);
// C 仍在 B 之下,depth 不变。
const byId = new Map((await listMemberGroups(svc(), ADMIN)).map((g) => [g.id, g]));
expect(byId.get(c)?.depth).toBe(2);
expect(byId.get(c)?.parentId).toBe(b);
});
it("空描述清空;字段缺省则不动;两者皆缺 → 400", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G", description: "原描述" });
expect((await updateMemberGroup(svc(), ADMIN, g.id, { description: "" })).description).toBeNull();
// 只传 name → 描述保持(此时已是 null)。
const renamed = await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
expect(renamed.name).toBe("G2");
expect(renamed.description).toBeNull();
await expect(updateMemberGroup(svc(), ADMIN, g.id, {})).rejects.toMatchObject({ statusCode: 400 });
});
it("空名 → 400;已归档组 → 404;非管理员 → 403", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: " " })).rejects.toMatchObject({ statusCode: 400 });
await expect(updateMemberGroup(svc(), ALICE, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 403 });
await deleteMemberGroup(svc(), ADMIN, g.id);
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 404 });
});
it("改名写 group.update 审计", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
const actions = (await prisma.auditEntry.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual(["group.create", "group.update"]);
});
});
describe("memberGroupService · 级联软删 + 实时失效", () => {
it("软删 B → B、C 从 list/解析消失,经此支的权限立即失效", async () => {
const { a, b, c } = await seedChain();
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
const folder = await createNode(treeDeps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "共享",
grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }],
});
expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT");
const { archivedCount } = await deleteMemberGroup(svc(), ADMIN, b);
expect(archivedCount).toBe(2); // B + C
const remaining = (await listMemberGroups(svc(), ADMIN)).map((g) => g.id);
expect(remaining).toEqual([a]);
// C 已归档 → ALICE 的直接组失效 → 解析空 → 对该文件夹不再可见(D8 → 404)。
expect(await createMemberGroupResolver(prisma).resolveMemberGroupIds("u_alice")).toEqual([]);
await expect(getEffectiveRole(treeDeps(), ALICE, folder.id)).rejects.toMatchObject({ statusCode: 404 });
});
});
describe("memberGroupService · 成员增删", () => {
it("重复添加 → 409;移除后可重新添加", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
await expect(addMember(svc(), ADMIN, g.id, { userId: "u_alice" }))
.rejects.toMatchObject({ statusCode: 409 });
await removeMember(svc(), ADMIN, g.id, "u_alice");
expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(0);
// 重加(revokedAt 软删允许 @@unique([groupId,userId,revokedAt]) 下的新活跃行)。
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(1);
});
it("按飞书 openId 解析成员", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
const m = await addMember(svc(), ADMIN, g.id, { feishuOpenId: "ou_bob" });
expect(m.userId).toBe("u_bob");
});
it("移除不存在成员 → 404", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await expect(removeMember(svc(), ADMIN, g.id, "u_alice"))
.rejects.toMatchObject({ statusCode: 404 });
});
});
describe("memberGroupService · 管理门禁(决策2)", () => {
it("非管理员调 CRUD/成员 → 403;搜索不限管理员", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await expect(createMemberGroup(svc(), ALICE, { name: "X" })).rejects.toMatchObject({ statusCode: 403 });
await expect(deleteMemberGroup(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 });
await expect(listMemberGroups(svc(), ALICE)).rejects.toMatchObject({ statusCode: 403 });
await expect(listMembers(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 });
await expect(addMember(svc(), ALICE, g.id, { userId: "u_bob" })).rejects.toMatchObject({ statusCode: 403 });
await expect(removeMember(svc(), ALICE, g.id, "u_bob")).rejects.toMatchObject({ statusCode: 403 });
// 搜索:非管理员可调(授权选择器)。
const results = await searchMemberGroups(svc(), "G");
expect(results.map((r) => r.id)).toContain(g.id);
});
});
describe("memberGroupService · 成员选择器 searchUsers", () => {
it("按显示名/openId 搜;excludeGroupId 排除已在组成员;仅管理员", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
// 空 q 列出全部(3 个 seed 用户)。
expect((await searchUsers(svc(), ADMIN, "")).length).toBe(3);
// 按 displayName 命中(seed 的 displayName 即 id)。
expect((await searchUsers(svc(), ADMIN, "alice")).map((u) => u.userId)).toEqual(["u_alice"]);
// 按 openId 命中。
expect((await searchUsers(svc(), ADMIN, "ou_bob")).map((u) => u.userId)).toEqual(["u_bob"]);
// 已在组的人被排除 —— 避免选中必然 409 的候选。
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
const ids = (await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId);
expect(ids).not.toContain("u_alice");
expect(ids).toContain("u_bob");
// 移除后重新成为候选(revokedAt 软删)。
await removeMember(svc(), ADMIN, g.id, "u_alice");
expect((await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId)).toContain("u_alice");
await expect(searchUsers(svc(), ALICE, "")).rejects.toMatchObject({ statusCode: 403 });
});
});
describe("memberGroupService · 成员表字段", () => {
it("listMembers 返回 openId/avatar/joinedAt(供成员表列展示)", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
const [m] = await listMembers(svc(), ADMIN, g.id);
expect(m).toMatchObject({
userId: "u_alice",
displayName: "u_alice",
feishuOpenId: "ou_alice",
avatarUrl: null,
});
expect(m?.joinedAt).toBeInstanceOf(Date);
});
});
describe("memberGroupService · 搜索 breadcrumb", () => {
it("breadcrumb 由活跃祖先链按 depth 拼(根在前)", async () => {
const { c } = await seedChain();
const results = await searchMemberGroups(svc(), "C");
const hit = results.find((r) => r.id === c);
expect(hit?.breadcrumb).toBe("A / B / C");
});
it("归档组不出现在搜索(G3)", async () => {
const { a, b } = await seedChain();
await deleteMemberGroup(svc(), ADMIN, b); // 归档 B、C
const ids = (await searchMemberGroups(svc(), "")).map((r) => r.id);
expect(ids).toContain(a);
expect(ids).not.toContain(b);
});
});
describe("memberGroupService · 审计(C3/决策4)", () => {
it("建组/加成员/删组写 AuditEntry(挂 silo org)", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
await removeMember(svc(), ADMIN, g.id, "u_alice");
await deleteMemberGroup(svc(), ADMIN, g.id);
const actions = (await prisma.auditEntry.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual([
"group.create", "group.member_add", "group.member_remove", "group.delete",
]);
});
});
+11
View File
@@ -27,6 +27,17 @@ describe("isSiloHttpRateLimitExempt", () => {
expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true);
});
it("exempts the filelib-web SPA assets and both of its mount prefixes", () => {
// appDir 改名为 `_filelib`(见 src/database/static.ts):同一份产物服务
// 老师端 /app 与管理后台 /database。
expect(isSiloHttpRateLimitExempt("/_filelib/version.json")).toBe(true);
expect(isSiloHttpRateLimitExempt("/_filelib/immutable/chunks/foo.js?v=1")).toBe(true);
expect(isSiloHttpRateLimitExempt("/app")).toBe(true);
expect(isSiloHttpRateLimitExempt("/app/dev-login-teacher")).toBe(true);
expect(isSiloHttpRateLimitExempt("/database")).toBe(true);
expect(isSiloHttpRateLimitExempt("/database/dashboard/users")).toBe(true);
});
it("still rate-limits APIs and auth", () => {
expect(isSiloHttpRateLimitExempt("/api/me")).toBe(false);
expect(isSiloHttpRateLimitExempt("/api/org/x/members")).toBe(false);
@@ -0,0 +1,43 @@
/**
* 两套 SPA 静态托管共存于同一个 Fastify 实例。
*
* 这是 ADR-0029 的承重约束的回归测试:filelib-web 的 appDir 若用 SvelteKit 默认的
* `_app`,就会与 admin-web 在根上注册的 `/_app/*` 撞成重复路由,Fastify 启动即抛错。
* 改名为 `_filelib` 后两者共存 —— 这里把两个注册函数挂到同一实例上验证。
*/
import Fastify from "fastify";
import { describe, expect, it } from "vitest";
import { registerStaticSpa } from "../../src/admin/static.js";
import { registerDatabaseSpa } from "../../src/database/static.js";
describe("SPA static route registration", () => {
it("admin-web and filelib-web shells coexist without duplicate routes", async () => {
const app = Fastify({ logger: false });
await registerStaticSpa(app);
await registerDatabaseSpa(app);
await expect(app.ready()).resolves.toBeDefined();
await app.close();
});
it("serves the same index.html at /app and /database, and assets under /_filelib", async () => {
const app = Fastify({ logger: false });
await registerDatabaseSpa(app);
await app.ready();
const appShell = await app.inject({ method: "GET", url: "/app" });
const dbShell = await app.inject({ method: "GET", url: "/database/dashboard/users" });
// 前置:构建产物必须存在。缺失时 registerDatabaseSpa 只 warn 不注册路由,
// 断言会全部落到 404 —— 那说明该先跑 `npm run build --prefix filelib-web`,
// 不是测试不适用,所以这里不跳过而是直接失败。
expect(appShell.statusCode).toBe(200);
expect(dbShell.statusCode).toBe(200);
// 同一份字节:SPA 回退不读请求(ADR-0029)。
expect(dbShell.body).toBe(appShell.body);
expect(appShell.headers["content-type"]).toContain("text/html");
// 资源用绝对路径引用(paths.relative=false),否则深层 URL 下会解析错。
expect(appShell.body).toContain("/_filelib/");
await app.close();
});
});
-304
View File
@@ -1,304 +0,0 @@
# 文件库系统 · 接口契约与决策(v0.1 我方提案)
> **读者**:版本团队、Group 团队、审计团队、平台方、编辑团队、产品。
> **用法**:本文档是我方(文件库)提出的对齐基线。所有决策带 `D-x` 编号、所有契约带 `C-x` 编号,评审时请按编号引用("D5 我们不同意,建议……")。评审通过后本文件冻结,各方照此实现;任何变更走文档修订,不接受口头对齐。
> **状态**:v0.1 提案,未冻结。
---
## 1. 系统边界与分工
| 子系统 | 负责方 | 与我方关系 |
|---|---|---|
| 文件库(目录树 / 权限引擎 / 内容存储 / 对外 API / 前端) | **我方** | — |
| 版本能力(git 化、commit、diff、历史) | 版本团队 | 交付 **npm 工具包**,我方在自有存储上集成(见 C1) |
| Group 系统(全局嵌套组) | Group 团队 | 我方**实时消费**其查询接口(见 C2) |
| 审计系统(存储 / 查询 / 保留) | 审计团队 | 我方**上报事件**(见 C3);查询界面归审计方 |
| 平台身份(登录 / 角色) | 平台方 | 我方验签消费角色(见 C4) |
| 在线编辑与冲突合并 UI(需求 2.5) | 编辑团队 | **消费我方文件 API**(见第 9 节) |
| 导出工具 | 已有工具 | 我方按需传参调用(参数清单 OPEN-6) |
**内容归属**:文件内容(git 仓库)物理存储在文件库自己的服务器上,由我方管理;版本团队不持有任何数据,只交付代码。
## 2. 总体架构
```
编辑团队(2.5 UI) ──┐
其他消费方 ──┤ HTTP + JWT
┌─────────────────────┐
│ 文件库服务(我方) │
│ 目录树 + 权限引擎 │──── 实时查询 ──▶ Group 系统(C2)
│ 文件 API + 授权 API │──── 验签 JWT ──▶ 平台身份(C4)
└─────────┬───────────┘
│ import(函数调用)
┌─────────────────────┐ outbox 中继(at-least-once)
│ 版本工具包(C1,npm) │ ┌──────────────────────▶ 审计系统(C3)
│ 操作我方磁盘上的 │ │
│ git 仓库(内容归我方)│ ▼
└─────────────────────┘ 我方 DB:业务表 + outbox 表(同事务)
```
## 3. 决策总表
| 编号 | 决策 | 一句话理由 | 影响方 |
|---|---|---|---|
| D1 | 文件库后端 TypeScript + Fastify + Prisma(PostgreSQL);前端 React | 与平台技术心智一致 | 我方 |
| D2 | 文件内容物理存储在文件库服务器,版本能力以 npm 包交付 | 需求 2.1"项目初始化为 Git 仓库",仓库是项目的存储 | 版本团队 |
| D3 | 版本包冲突用**返回值**表达,异常仅用于系统错误 | 冲突是正常业务流,不是故障 | 版本、编辑 |
| D4 | Group 查询**实时调用、不缓存** | 需求 3.3"成员变更实时生效" | Group |
| D5 | 审计走"本地 outbox + 后台中继"满足"同事务或可靠消息" | 跨服务做不到真同事务,outbox 是最简单的可靠方案 | 审计 |
| D6 | 平台身份用 JWT(RS256)验签,平台角色 `platform.admin` 映射网站管理员 | 无状态、无需每次回调平台 | 平台 |
| D7 | 权限管理规则按第 8 节补齐(2.4 空洞的我方版本) | 需求 2.4 为空,必须先有规则才能写码 | 产品确认 |
| D8 | 无 View 权限时 API 返回 **404**(不泄露存在性);有 View 但操作越权返回 403 | 防资源探测 | 所有消费方 |
| D9 | 软删除:删除即打标隐藏,所有 API 默认不可见 | 需求 2.6/3.3 | 我方 |
| D10 | 导出为**异步任务**(提交返回 job,轮询取结果) | 导出耗时不确定,同步会超时 | 编辑/前端 |
---
## 4. C1 · 版本工具包契约(给版本团队)
**交付形态**npm 包(TypeScript,自带类型定义),运行在文件库后端进程内。实现技术(isomorphic-git / nodegit / 其他)由版本团队自选,**本契约只约束接口与语义**。
### 4.1 接口签名
```typescript
export type VersionId = string; // 不透明字符串,调用方不得解析(当前为 git commit hash)
export interface CommitRequest {
/** 编辑的起始版本;null 表示新建文件 */
baseVersion: VersionId | null;
content: string | Buffer; // 文本用 string(UTF-8),二进制用 Buffer
message?: string; // 缺省由包自动生成
author?: string; // 操作者标识,写入版本记录
}
export type CommitResult =
| { status: "ok"; version: VersionId }
| { status: "conflict"; currentVersion: VersionId };
export interface VersionInfo {
version: VersionId;
message: string;
author?: string;
committedAt: string; // ISO 8601
}
export interface FileEntry { path: string; size: number; }
export interface VersionStore {
init(projectDir: string): Promise<void>; // S7: 幂等
list(projectDir: string, prefix?: string): Promise<FileEntry[]>;
head(projectDir: string, filePath: string): Promise<VersionId>; // 文件不存在 → FileNotFoundError
read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>; // 缺省读最新
commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>;
remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise<CommitResult>;
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>; // unified diff
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
}
```
### 4.2 语义规则
| 编号 | 规则 |
|---|---|
| S1 | **冲突不是异常**`baseVersion` 落后于当前版本时返回 `{status:"conflict"}`;异常仅用于 IO 故障、仓库损坏等系统错误 |
| S2 | `baseVersion: null` = 新建;路径已存在时返回 conflict |
| S3 | 二进制文件与文本同样版本化;`diff` 仅保证对文本有意义,二进制可返回占位说明 |
| S4 | 包必须保证**同一 projectDir 的写操作(commit/remove)串行化**,调用方可并发调用 |
| S5 | 存储布局不透明:我方不直接读写仓库目录,一切经包接口 |
| S6 | 在线编辑仅针对文本文件(需求 2.5);二进制材料走上传/下载 |
| S7 | `init` 幂等,重复调用不报错、不重建 |
| S8 | 规模假设:单项目文件数千级、单文件 10MB 以内,超出另行对齐 |
---
## 5. C2 · Group 查询契约(给 Group 团队)
**形态**:HTTP + JSON。我方只读,不写 Group 系统任何数据(需求 3.3)。
### 5.1 接口
```
GET /groups/resolve-member-groups?userId={userId}
→ { "groupIds": ["g1","g2",...] }
# 权限计算专用:用户直接所属的全部 group + 这些 group 的所有祖先 group,去重。
# 方向是"向祖先"收集(需求 3.2:权限沿 group 树向下传递)。
GET /groups/search?q={keyword}&limit={n}
→ [{ "id":"g1","name":"物理教研组","breadcrumb":"总部 / 教研 / 物理" }]
# 前端授权选择器用。
GET /groups/{groupId}
→ { "id":"g1","name":"物理教研组","parentId":"g0","status":"active" }
```
### 5.2 语义规则
| 编号 | 规则 |
|---|---|
| G1 | `groupId` 为不透明、全局唯一字符串 |
| G2 | resolve 只含**祖先方向**,不含子孙;用户无所属时返回空数组 |
| G3 | 软删除的 group 不出现在任何接口结果中 |
| G4 | **实时性**:我方每次权限计算都实时调用 resolve,不缓存;Group 方成员变更须即刻在 resolve 结果中可见(需求 3.3 |
| G5 | 文件夹/项目的授权记录只存在文件库,Group 系统不感知(需求 3.3) |
| G6 | 可用性:resolve 位于我方**每一次受保护请求**的权限计算路径上,其可用性即文件库可用性 → 需要 Group 方给出延迟与可用性承诺(OPEN-1) |
---
## 6. C3 · 审计上报契约(给审计团队)
### 6.1 机制(满足需求 5.1"同事务或可靠消息"
1. 我方在每个关键写操作的**同一数据库事务**内,向本地 `audit_outbox` 表写入事件;
2. 后台中继进程读取 outbox,POST 到审计系统 `POST /audit/events`**at-least-once**,失败重试;
3. 审计系统按 `eventId` **幂等去重**
4. 中继只负责送达,业务操作不因审计系统不可用而失败(但事件绝不丢)。
### 6.2 事件信封(补齐需求 5.3 空缺的字段定义)
```json
{
"eventId": "01J…", // 我方生成的唯一 id,幂等键
"schemaVersion": 1,
"occurredAt": "2026-07-21T10:00:00.000Z",
"sourceService": "filelib",
"actor": { "userId": "u123", "platformRole": "teacher" },
"action": "permission.grant", // 见 6.3 词汇表
"object": { "type": "folder", "id": "n456", "path": "/物理/必修一" },
"result": "success", // success | failure
"errorCode": null,
"detail": { /* action , grant {principalType, principalId, role} */ }
}
```
### 6.3 action 词汇表(文件库范围,对照需求 5.2)
| action | 需求 5.2 对应 |
|---|---|
| `folder.create` / `folder.rename` / `folder.move` / `folder.delete` | 创建/删除/移动/重命名文件夹 |
| `project.create` / `project.rename` / `project.move` / `project.delete` | 同上(项目) |
| `permission.grant` / `permission.update` / `permission.revoke` | 权限变更(detail 含个人/Group、Manage/Edit/View |
| `project.independent_permission.enable` / `.disable` / `.change` | 项目独立权限开启/关闭/变更 |
| `file.upload` / `file.rename` / `file.delete` | 材料文件管理 |
| `file.commit` | 文件编辑提交(含冲突合并后提交;编辑经我方 API 落盘,故由我方上报) |
| `file.conflict_detected` | 冲突检测发生(detail 含起始版本与冲突版本) |
| `export.run` | 导出操作 |
| `admin.force_adjust` | 网站管理员强制权限调整(高危) |
Group 相关动作(创建/删除/成员变更/嵌套变更)由 **Group 系统自行上报**;归档类动作待归档功能定案后补充(OPEN-4)。
### 6.4 边界
- 审计的存储、防篡改、保留策略(需求 5.5 ≥180 天)、查询接口与查询界面,全部归审计系统;
- 文件库前端**不内嵌**审计查询页(若产品要求嵌入,OPEN-8 再议)。
---
## 7. C4 · 平台身份契约(给平台方)
1. 调用方在 `Authorization: Bearer <JWT>` 中携带平台签发的令牌(RS256);平台通过 JWKS endpoint 分发公钥,我方只做验签 + 过期检查,**不回调平台、不建用户表**;
2. claims 约定:`sub`(用户 id,全局唯一)、`roles`(平台角色数组)、`exp``iss`
3. **角色映射**`roles``platform.admin` → 文件库"网站管理员";其余合法令牌 → 普通用户;无令牌/验签失败/过期 → `401`
4. 网站管理员权限范围:创建根目录、强制权限调整(必审计)、以及平台方赋予的其他高危操作;**不隐式穿透**各节点的业务权限(要管理某子树须被显式授权或走强制调整并留痕);
5. 前端登录跳转平台 SSO,具体流程由前端与平台方另行对齐。
---
## 8. 权限规则 · 2.4 补齐版(D7,产品确认后冻结)
### 8.1 创建者与授权矩阵
| 角色 | 能授/改/收的级别 | 限制 |
|---|---|---|
| **节点创建者** | Manage / Edit / View | 自身 Manage 不可被收回(转让 OPEN-3 |
| **Manage 持有者** | Edit / View | **不可**授予 Manage;**不可**修改/收回创建者的任何权限 |
| **Edit / View** | 无授权能力 | — |
| **网站管理员** | 任意(走 `admin.force_adjust`,必审计) | 不属于日常授权路径 |
### 8.2 各级别能力清单
| 能力 | View | Edit | Manage |
|---|:---:|:---:|:---:|
| 浏览树 / 读文件 / 下载 | ✓ | ✓ | ✓ |
| 导出 | ✓ | ✓ | ✓ |
| 创建子文件夹/项目(需求:父级 Edit+) | | ✓ | ✓ |
| 编辑文本文件 / 上传 / 删改项目内材料 | | ✓ | ✓ |
| 重命名 / 移动 / 删除**本节点** | | | ✓ |
| 授权管理(按 8.1 矩阵) | | | ✓ |
| 项目独立权限开关 | | | ✓ |
### 8.3 其余规则
- **P3 根目录**:仅网站管理员可创建;创建时必须指定 ≥1 名 Manage 持有者(可以不是创建者本人),保证每棵子树都有权限链起点;
- **P4 空权限创建**:允许;此时仅创建者可见可用;
- **P5 项目独立权限**:默认关闭(仅继承父链);开启后项目级授权参与 max 计算;关闭时项目级授权**冻结不删除**,重新开启即恢复;
- **P6 计算规则**(与需求 2.3 一致的形式化):
`effective(user, R) = max { grant.role | grant ∈ grants(s, r), s ∈ {user} resolveGroups(user), r ∈ {R} ancestors(R) }`,无 grant → 无权限。个人低权限**不构成降权**(只取最高,不做减法);
- **P7 可见性**:对某节点无任何权限的用户,该节点对其不可见(API 行为见 D8)。
---
## 9. 文件库对外 API(消费方:编辑团队及其他)
**约定**REST + JSON + JWTC4);统一错误信封 `{ "error": { "code", "message", "details?" } }`;D8 可见性语义(无 View → 404;越权操作 → 403)。
### 9.1 树与节点
```
GET /nodes?parentId={id} # 列子节点(缺省列根);仅返回有 View 的
POST /nodes # 创建文件夹/项目 {parentId, type, name, grants?[]}
# parentId=null 仅网站管理员;type=project 时自动调 C1.init
GET /nodes/{id} # 节点详情 + 我的 effective 权限 + breadcrumb
PATCH /nodes/{id} # 重命名/移动 {name?, parentId?} (需本节点 Manage)
DELETE /nodes/{id} # 软删除(需 Manage)
```
### 9.2 授权
```
GET /nodes/{id}/grants # 授权列表(需 Manage)
PUT /nodes/{id}/grants # 批量授予/修改 [{principalType:user|group, principalId, role}]
DELETE /nodes/{id}/grants/{grantId} # 收回
GET /nodes/{id}/effective-permission # 当前用户在此节点的生效权限(自查)
PUT /projects/{id}/independent-permission # {enabled: bool} 独立权限开关
```
### 9.3 文件内容(编辑团队的主接口)
```
GET /projects/{id}/files?prefix= # 文件清单
GET /projects/{id}/files/{path} # → {content, version} 编辑起手式:拿到起始版本
PUT /projects/{id}/files/{path} # 新建/上传 {content} (需 Edit)
POST /projects/{id}/files/{path}/commits # 提交编辑 {baseVersion, content, message?}
# → 200 {version} | 409 {currentVersion}
GET /projects/{id}/files/{path}/diff?from=&to= # 冲突时取差异(unified diff)
GET /projects/{id}/files/{path}/history # 版本历史
DELETE /projects/{id}/files/{path} # {baseVersion} (需 Edit)
```
**冲突合并流程(配合编辑团队 2.5)**:编辑 UI 用 `GET files/{path}` 记录 `version` → 用户编辑 → `POST commits``baseVersion` → 若 `409`UI 用 `diff?from=base&to=current` 拉取差异,展示三方合并区 → 用户写出最终内容后再次 `POST commits`baseVersion 换为 currentVersion)。
### 9.4 导出
```
POST /projects/{id}/exports {target, params} → {jobId} # D10 异步
GET /exports/{jobId} → {status, downloadUrl?}
```
---
## 10. OPEN 清单(需对方/产品确认,不阻塞我方开工)
| 编号 | 事项 | 等谁 |
|---|---|---|
| OPEN-1 | Group resolve 的延迟/可用性 SLA 数值 | Group 团队 |
| OPEN-2 | 审计事件投递的 endpoint、鉴权方式、保留期确认(需求建议 ≥180 天) | 审计团队 |
| OPEN-3 | 创建者离职/转让后 Manage 链如何处理 | 产品 |
| OPEN-4 | 归档功能(需求已划线"暂时未定") | 产品 |
| OPEN-5 | 二进制材料的大小上限与在线预览诉求 | 产品 |
| OPEN-6 | 导出工具的参数清单(需求原文"待对接时确认") | 导出工具方 |
| OPEN-7 | 软删除的恢复入口、"后台标签"管理界面归属 | 产品 |
| OPEN-8 | 网站管理员强制调整的前端入口(我方做还是平台做)、审计查询页是否嵌入文件库前端 | 产品 |