Compare commits

...

79 Commits

Author SHA1 Message Date
ymy f99c8ea4d8 Merge branch 'chore/remove-recent-module' 2026-07-31 13:39:19 +08:00
ymy 83a6b012b7 feat(filelib): 移除最近打开模块(ADR-0032,supersede ADR-0031 对应半部)
- FileLibRecentVisit 删表(手写迁移;表当日新建无生产数据)
- recentService/recentRoutes/RecentView 删除;GridLibraryView 埋点与
  navTarget 跳转一并移除;types 清 RecentEntry
- 左栏保留 文件库/回收站(ADR-0031 回收站半部不受影响)
- breadcrumb 的 role 字段保留(独立可用的增量字段)
2026-07-31 13:39:18 +08:00
ymy 475b397ca8 Merge branch 'feat/filelib-nav-rail' 2026-07-31 13:27:04 +08:00
ymy d072e9ec1e feat(filelib): 老师端左栏导航:回收站 + 最近打开(ADR-0031)
- 回收站:listBin(祖先全活跃的已删顶点;管理员/直连 MANAGE 可见)、
  restore(与 D15 对称只清本节点,落审计)、purge(仅管理员,pathIds 枚举
  子树按深度降序分批硬删,绕过 self-FK RESTRICT)
- 最近打开:FileLibRecentVisit 表(filePath='' 兜底 PG 唯一索引),客户端
  成功打开后上报(VIEW 门禁,upsert 刷新),列表 20 条,D8/D15 可见性过滤
- 前端:/app 左栏(文件库/最近打开/回收站);RecentView/BinView;
  GridLibraryView 埋点 + navTarget 跳转(breadcrumb 建栈,role 已捎带)
- 测试:filelib-nav 集成 4 例;全套 79 例绿
2026-07-31 13:27:04 +08:00
ymy 04fa383286 Merge branch 'feat/filelib-grid-browser' 2026-07-31 12:02:24 +08:00
ymy 0fd21e51f7 feat(filelib-web): 老师端文件库改网盘式网格浏览(下钻导航+右键菜单)
- 新 GridLibraryView:双击下钻文件夹/项目,面包屑+返回跳级,项目内文件同网格,
  双击进 FileEditor 预览;工具条按上下文出新建文件夹/项目/文件
- 右键菜单(ContextMenu):打开/新建子文件夹/重命名/授权管理/详情/删除,
  按节点 role 动态显隐;文件菜单:打开预览/下载/删除;空白处右键出新建+刷新
- GridCard 大图标卡片(琥珀文件夹/立方体项目/文档文件);授权与详情复用
  GrantsPanel/OverviewPanel(Modal 加 maxW prop);Icon 补 5 个图标
- 管理后台 /database 保留树状 LibraryView 不动;后端零改动
2026-07-31 12:02:23 +08:00
ymy ab5e03823c chore: 误提交 .omo 会话文件,移出跟踪 2026-07-30 22:32:07 +08:00
ymy 02b46e2dcf Merge branch 'feat/filelib-always-independent' 2026-07-30 22:32:07 +08:00
ymy 834f4c380c chore: 误提交 .omo 会话文件,移出跟踪并加入 .gitignore 2026-07-30 22:31:08 +08:00
ymy 4c68c7db0b Merge branch 'feat/filelib-always-independent' 2026-07-30 22:27:46 +08:00
ymy 39a2be6347 feat(filelib): 项目级授权恒生效,移除独立权限开关(ADR-0030)
- permission.ts: effectiveRole 删除 D11 冻结分支,输入不再含开关字段
- treeService: 停止读 FileLibProjectSettings;建项目不再写默认行
- grantService/routes: 删 setIndependentPermission 与 PUT 路由;节点详情 DTO 去掉 independentPermission
- filelib-web: 概览 tab 移除开关;NodeDetail 类型同步
- 测试: 单测/集成改为断言恒生效语义;ADR-0030 废除契约 D11/P5
- FileLibProjectSettings 表保留(存量行忽略,不再读写),审计词表保留历史读取
2026-07-30 22:27:45 +08:00
ymy c72f8c7050 Merge branch 'chore/grants-panel-wider' 2026-07-27 17:00:42 +08:00
ymy 947f969967 chore(filelib-web): 详情容器加宽到 1400px,宽屏下授权表格全列无遮挡;仅文件预览挤压时出横向滚动条 2026-07-27 17:00:41 +08:00
ymy 60856d7cc1 Merge branch 'chore/grants-table-overflow' 2026-07-27 16:54:07 +08:00
ymy 2e08c0a734 chore(filelib-web): 授权表格外套横向滚动容器,修删除列贴边与文件预览打开时表格挤压 2026-07-27 16:54:07 +08:00
ymy 2225c6d43b Merge branch 'chore/grants-table-width' 2026-07-27 16:47:05 +08:00
ymy fc908eaf3b chore(filelib-web): 拉宽授权表格:容器 880→1120px,单元格留白/禁换行,长 id 截断+悬停全文 2026-07-27 16:47:05 +08:00
ymy 2395671693 Merge branch 'chore/grant-id-columns' 2026-07-27 16:40:26 +08:00
ymy cc4d9d907c chore(filelib-web): 授权成员单元格只留头像+名称,userId 与飞书 ID 拆为独立两列
GrantDto 增加 principalOpenId(USER 主体的 feishuOpenId),GROUP 行两列显示 —;
搜索过滤同步覆盖 openId。
2026-07-27 16:40:25 +08:00
ymy ef02428bb6 Merge branch 'chore/grant-avatar' 2026-07-27 16:22:03 +08:00
ymy ad1a464f22 chore(filelib-web): 授权成员单元格改用首字母圆形头像(复用 Avatar 组件) 2026-07-27 16:22:01 +08:00
ymy 2dfe72cd5e Merge branch 'chore/grant-modal-copy' 2026-07-27 16:16:17 +08:00
ymy 12a1246a7a chore(filelib-web): 移除添加授权弹窗底部的 8.1 规则提示语 2026-07-27 16:16:16 +08:00
ymy 405312b36b Merge branch 'chore/role-label-zh' 2026-07-27 15:57:42 +08:00
ymy be17f74fc2 chore(filelib-web): 文件库权限名称汉化(VIEW/EDIT/MANAGE → 只读/可编辑/可管理)
统一走新增共享常量 labels.ts ROLE_LABEL(与 OverviewPanel 既有文案一致);
覆盖授权表格与弹窗下拉、详情头 tag、树节点角标;API 传参仍用英文枚举。
2026-07-27 15:57:41 +08:00
ymy fccae5dacb Merge branch 'feat/grants-table' 2026-07-27 15:16:30 +08:00
ymy 0dd2ae347e feat(filelib-web): 授权面板表格化:搜索、添加弹窗、权限下拉与成员跳转
- GrantsPanel 重写为表格:顶部左侧授权成员搜索框(名称/id/类型过滤),
  右侧「添加授权」弹窗(类型 + 主体搜索选择 + 权限);行内权限下拉
  直接改级(复用 PUT upsert),操作列删除;成员单元格跳转用户管理
  (?q= 过滤)或 Group 管理(?select= 选中)。
- grantService: GrantDto 增加 principalName,list/put/force 三处统一
  批量回填(用户 displayName / 组 name),前端不再只显示裸 id。
- 用户管理页加过滤框并从 ?q= 初始化;GroupAdmin 支持 ?select= 直达。
- 测试:resetDb 补 MemberGroup 三表清理(全局表不被 org/user 级联清到,
  此前跨用例污染导致级联软删用例断言失败);cph_hub_test 补 migrate。
- 顺带合并 types.ts 里重复的 Grant 声明(interface 合并残留)。
2026-07-27 15:13:57 +08:00
ymy a4c07d1a5d Merge branch 'fix/filelib-tree-loading' 2026-07-27 14:09:48 +08:00
ymy 91afd3c1b1 fix(filelib-web): 文件库树加载失败时显示错误而非永久加载中 2026-07-27 14:09:35 +08:00
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: #1
2026-07-23 23:44:17 +08:00
ymy 4021e58d5d feat(database): init database folder frontend and permission 2026-07-23 23:41:11 +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
5df1900ca8 docs(hub): document HUB_DEV_LOGIN_BYPASS dev login in database README
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:09:14 +08:00
df66691d24 feat(hub): add /database admin surface with Feishu login
Adds a self-contained `/database/*` HTTP surface under hub/src/database:
- /database/admin: Feishu-only login page (Tailwind, light theme)
- /database/dashboard: session-gated sidebar + content shell
- /database/dev-login: DEV ONLY session bypass, double-gated by
  NODE_ENV != production AND HUB_DEV_LOGIN_BYPASS; never active in prod

hub.ts mounts the plugin after the admin plugin so the cookie parser and
/auth/feishu/* routes are available. The dev bypass logic is fully contained
in the database module; admin auth routes are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 21:02:04 +08:00
hongjr03 73cb0e5b47 feat(hub): embed agent images via Feishu upload + release v0.0.36 (#14)
feat(hub): embed agent images via Feishu upload + release v0.0.36

Merge pull request #14
2026-07-20 18:42:38 +08:00
hongjr03 e21096c642 feat(hub): embed agent images via Feishu upload + release v0.0.36
Materialize markdown image refs on agent finish: fetch/read bytes, upload
im.v1.image, and render native card img elements so remote image URLs no
longer trip Feishu content-security. Stream masks image URLs mid-run;
card failure falls back to plain text plus standalone image messages.

Docs: clarify im:resource covers outbound Agent image send.
2026-07-20 10:40:03 +00:00
hongjr03 dc2d1c2f9e Merge pull request 'chore: remove Lean spec; ADRs are the single source of truth' (#13) from chore/remove-lean-spec into main
Reviewed-on: EduCraft/curriculum-project-hub#13
2026-07-20 17:21:14 +08:00
hongjr03 3f9b60f692 chore: remove Lean spec; ADRs are the single source of truth
The spec/ Lean semantic master had no conformance gate, no codegen, and
no CI tie to implementations — alignment was carried entirely by human
review, the same mechanism that carries the ADRs. In practice the ADRs
plus greppable code comments were already the load-bearing artifacts,
so spec/ was the most expensive kind of stale documentation.

- delete spec/ and the spec-check CI workflow
- README: constitution rewritten around ADRs as decision truth
- AGENTS.md/CLAUDE.md: discipline re-anchored (new decisions -> new ADR,
  never rewrite ADR history; supersede instead)
- code comments: re-anchor 'Mirrors Spec.X' invariants to ADR numbers
  (cph-diag, cph-check, cph-model, hub runner/capacity/org, prisma)
- leave ADR bodies and .scratch audit snapshots untouched (history);
  fix live references in open readiness tickets
2026-07-20 09:07:26 +00:00
hongjr03 4234ba4c96 Merge pull request 'fix(hub): mark bootstrap Inbox as SYSTEM_INBOX' (#12) from fix/hub-bootstrap-system-inbox into main 2026-07-19 20:16:09 +08:00
hongjr03 15f9443d3d fix(hub): mark bootstrap Inbox as SYSTEM_INBOX
Alpha silo bootstrap created the root Inbox without kind=SYSTEM_INBOX, so
Feishu card project creation tried to insert a second Inbox and hit the
sibling-name unique index. Tag the bootstrap folder correctly and promote
any legacy root Inbox on ensure.
2026-07-19 20:08:44 +08:00
hongjr03 7f09fb1f13 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35 (#11)
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:36:10 +08:00
hongjr03 eb0be43eac feat(hub): usage fact breakdown API + admin usage/session UI + release v0.0.34 (#10)
Expose UsageFact kind/capability rollups on org and project usage reports, and add admin pages that separate model tokens from external-capability meters.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:19:59 +08:00
hongjr03 ce18740870 feat(hub): expose pdf_to_md_bundle as MCP tool to agent + skill (ADR-0027) (#9)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 21:43:00 +08:00
hongjr03 ef96f8d33d feat(hub): capability connection admin API + UI + release v0.0.32 (#8)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:57:04 +08:00
hongjr03 5e10419fc8 fix(hub): docmind client stream upload + correct API response parsing (#7)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 17:26:11 +08:00
hongjr03 64b3d1fc64 feat(hub): switch capability provider to Aliyun Doc Mind (ADR-0027) (#6)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 16:42:42 +08:00
hongjr03 b673dd1fe9 feat(hub): external capability registry for PDF/ASR transforms (ADR-0027) (#5)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 15:55:02 +08:00
hongjr03 aaa098bb8b feat(hub): usage fact ledger for run-scoped cost attribution (ADR-0026) (#4)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 14:46:41 +08:00
hongjr03 97f7972cc5 chore(hub): remove markdown_to_pdf tool (#3)
Drop markdown_to_pdf MCP surface, implementation, tests, and md-to-pdf dependency.

Roles that still list markdown_to_pdf must be cleaned before startup.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 13:57:27 +08:00
hongjr03 cc42e6a7c6 chore: release v0.0.31 2026-07-18 13:38:07 +08:00
hongjr03 4e01c18cac feat(hub): add markdown_to_pdf tool and default web tools
Teachers need ad-hoc Markdown → PDF. Ship an MCP tool powered by
md-to-pdf (Marked + headless Chrome) so remote images/CSS work, with
workspace-scoped basedir, front-matter stripped so untrusted markdown
cannot override dest/basedir/launch options, and MathJax for $/$ math.

Also include WebFetch and WebSearch in the unrestricted role tool
surface by default. Deploy skips Puppeteer's browser download and
expects a host Chrome/Chromium (PUPPETEER_EXECUTABLE_PATH / CHROME_PATH).
2026-07-18 13:38:07 +08:00
ChickenPige0n a12984d174 feat(admin-web): add folder and project creation functionality in FolderNode and FolderTree components 2026-07-18 12:32:45 +08:00
hongjr03 b93acd8e8c chore: release v0.0.30 2026-07-16 01:26:28 +08:00
hongjr03 35251986af feat(hub): derive admin model picker from org provider connection via OpenRouter API
The admin role model picker was hardcoded to the env-default model registry
(createDefaultModelRegistry), which only ever returned a single Sonnet model.
Roles could not select any other model regardless of what the org's provider
connection supported.

Replace the env-only model list with a ProviderModelCatalog that:
- Resolves the org's ACTIVE provider connection credential (BYOK or
  platform-managed, encrypted via ADR-0024 envelope)
- Calls OpenRouter GET /v1/models?supported_parameters=tools to list
  tool-capable models available to that org
- Caches results in-memory with a 5-minute TTL per organization
- Falls back to the env-default registry when no ACTIVE provider exists

The runtime modelRegistry no longer validates role.defaultModel against the
env model list — the admin already validated by selection from the provider
catalog. The env list remains as the fallback for roles with null defaultModel.

The admin roles page loads models independently (non-blocking) so roles
remain editable even if the provider API is slow or unreachable.
2026-07-16 01:24:00 +08:00
hongjr03 3ee6da7ceb chore: release v0.0.29
Web-based skill management: create, read, edit, disable skills from the
org admin surface. File tree editor with SKILL.md manifest support.
2026-07-16 01:12:51 +08:00
hongjr03 79f72ecca8 feat(admin): web-based skill management with file editor
Add full skill lifecycle to the org-admin web surface: create, read,
edit, disable. Skills are directories (SKILL.md manifest + supporting
files), content-addressed by SHA-256 in an immutable store.

Backend:
- skillStore: extract commitSkillContent (shared populate→inspect→
  dedup→atomic rename); add importSkillFromFiles (in-memory file list
  ingestion) and readSkillFiles (read stored version back as UTF-8)
- configuration: add installSkillFromFiles, readSkillFiles, disableSkill
  (soft-delete + archive bound role sessions), updateSkillDescription
  (label-only, no archival); refactor installSkill to share
  commitInstalledSkill
- agentConfigRoutes: wire skillStoreRoot; add GET
  /agent-skills/:name/files, PUT /agent-skills/:name (create/replace),
  PATCH /agent-skills/:name (description/disable)
- orgRoutes: pass readSkillStoreRoot() to agent config routes

Frontend:
- api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods
- SkillEditor.svelte: file tree + text editor + version/description form
- skills/+page.svelte: skill list, create form (generates SKILL.md
  template), per-skill editor
- layout: add 技能 nav item

ADR-0018: update Decision to reflect web surface joining host-console
CLI in the shared content-addressed ingestion pipeline.

Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web
installation is one implementation of it.
2026-07-16 01:11:49 +08:00
hongjr03 ae5f78f036 chore: release v0.0.28
Exempt SPA static assets and admin HTML shell from silo HTTP rate limit so
page loads no longer exhaust HUB_HTTP_REQUESTS_PER_MINUTE.
2026-07-15 22:33:07 +08:00
hongjr03 0782e155f6 chore: release v0.0.27
Agent role/skill admin restore, OAuth login URL fixes, project create 404 fix.
2026-07-15 21:35:26 +08:00
ChickenPige0n 0726dc13c8 feat(admin): restore org Agent role/skill management and fix 404 after project create
- explorer POST /projects now returns {id,name} matching the SPA contract
  (previously returned ProjectOnboardingResult.projectId, so res.id was
  undefined and the redirect to /projects/undefined 404'd)
- add OrganizationAgentConfiguration.listRoles/listSkills + AgentRoleRow/
  AgentSkillRow exports; upsertRole now returns the full row
- new agentConfigRoutes: GET/PUT /agent-roles, PUT /agent-roles/:id/skills,
  GET /agent-skills, GET /agent-models (env-default picker)
- restore admin-web roles page + RoleCard rewired to ADR-0017/0018 backend
  (label, defaultModel, tools whitelist, skill binding, systemPrompt,
  sortOrder, default toggle); add 角色 nav item + roles icon
- skill installation stays out-of-band (CLI/seed) per spec; the surface only
  lists installed skills and binds them to roles
2026-07-15 18:19:47 +08:00
ChickenPige0n fb66614e38 fix(admin-web): break /admin/login returnTo redirect loop in dev
vite dev only proxied /api and /auth, so /admin/login hit the SPA root
layout, re-ran loadSession, got 401, and redirected back to /admin/login
with an ever-nesting returnTo. Proxy /admin/login to the backend (which
owns it before the SPA fallback) and guard redirectToLogin against
re-entering /admin/login.

Replace the only emoji-as-icon (the back-arrow on project detail) with
the Icon component (new arrow-left glyph).

Add scripts/dev-bootstrap.ts for seeding a local Silo (stub probes) so
npm run dev can start on a fresh dev DB.
2026-07-15 18:19:47 +08:00
hongjr03 11de9e81db fix(hub): redirect Feishu OAuth default login to org admin SPA
Default returnTo=/admin previously landed on the static complete page
meant for chat onboarding; send users to /admin/org/:slug instead.
2026-07-15 14:29:42 +08:00
hongjr03 46ce942aec fix(admin-web): use org-scoped Feishu OAuth login URL
Unscoped GET /auth/feishu is disabled by default. Derive org slug from
/admin/org/:slug, ?org=, or the Alpha Silo hostname and redirect to
/auth/feishu/:orgSlug so the login button works on tenant domains.
2026-07-15 00:52:58 +08:00
hongjr03 8990277916 chore: release v0.0.26
Org admin SPA, capacity policy admin, fleet deploy CI for educraft/educraft-dev.
2026-07-15 00:29:31 +08:00
hongjr03 b217c16c1b Merge branch 'admin-panel': org admin SPA and fleet deploy CI
Bring in the org-admin Svelte SPA, capacity policy surface, production SPA
serving, project MANAGE fixes, and Gitea deploy of educraft/educraft-dev fleets.
2026-07-15 00:29:30 +08:00
235 changed files with 19882 additions and 2387 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
name: checker check name: checker check
# Builds and lints the Rust implementation crates under crates/ (the rule-based # Builds and lints the Rust implementation crates under crates/ (the rule-based
# checker that "stands in Lean's position" at product runtime). # lesson checker).
# #
# Like spec-check, this is an INTERNAL gate on the implementation's own health # This is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a # (does it build, pass its tests, satisfy clippy + rustfmt?). There is no
# spec-to-implementation conformance gate — implementations align to the Lean # decision-to-implementation conformance gate — implementations align to the
# contract by human review, not by CI. See the repo README. # ADRs by human review, not by CI. See the repo README.
on: on:
push: push:
+2 -2
View File
@@ -1,8 +1,8 @@
name: hub check name: hub check
# Builds, type-checks, and tests the Hub TS package under hub/. # Builds, type-checks, and tests the Hub TS package under hub/.
# The Hub is the Feishu-group collaboration + agent runtime half # The Hub is the Feishu-group collaboration + agent runtime half.
# (spec/System implementation). This is an INTERNAL gate on the Hub's own # This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half. # health, like checker-check is for the Rust half.
on: on:
-20
View File
@@ -1,20 +0,0 @@
name: spec check
# Builds the Lean semantic master spec under spec/.
# This is an INTERNAL well-formedness gate (does the contract type-check?),
# NOT a spec-to-implementation conformance gate — implementations align to the
# contract by human review, not by CI. See repo README.
on:
push:
pull_request:
workflow_dispatch:
jobs:
spec-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: leanprover/lean-action@v1
with:
lake-package-directory: spec
+2 -4
View File
@@ -1,7 +1,3 @@
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root) # Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target /target
**/*.pdf **/*.pdf
@@ -19,3 +15,5 @@ node_modules/
# OS / editor # OS / editor
.DS_Store .DS_Store
.omo/
@@ -29,7 +29,7 @@ workload brakes.
The full current-state inventory, accepted behavior, and release evidence are The full current-state inventory, accepted behavior, and release evidence are
recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md), recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md),
with the durable decision in ADR-0022 and `Spec.System.Capacity`. Numerical with the durable decision in ADR-0022. Numerical
ceilings remain open until production-like calibration. ceilings remain open until production-like calibration.
The implementation frontier is: The implementation frontier is:
@@ -7,6 +7,6 @@ Blocked by: 01, 02, 03, 04, 05, 06, 07, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18,
## Question ## Question
After the readiness investigations and resulting fixes are resolved, can one After the readiness investigations and resulting fixes are resolved, can one
repeatable release procedure prove build/test/spec health, deploy a clean repeatable release procedure prove build/test health, deploy a clean
production-like environment, exercise critical tenant and agent journeys, production-like environment, exercise critical tenant and agent journeys,
verify observability and recovery, and either roll forward or roll back safely? verify observability and recovery, and either roll forward or roll back safely?
@@ -8,7 +8,7 @@ Blocked by: 04
Separate or unify run-bound audit entries, pre-run security/permission events, Separate or unify run-bound audit entries, pre-run security/permission events,
structured messages, and operational recovery events without weakening structured messages, and operational recovery events without weakening
`Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability, the pinned AuditEntry-to-run relation. Decide durability,
failure, retention, and query semantics; then enforce referential integrity and failure, retention, and query semantics; then enforce referential integrity and
observable/recoverable writes instead of silently swallowing lost evidence. observable/recoverable writes instead of silently swallowing lost evidence.
Do not merge these customer Project/Run records with ADR-0023's already-decided Do not merge these customer Project/Run records with ADR-0023's already-decided
@@ -40,9 +40,7 @@ an off-host recovery key, an incident and reason, and issues only an expiring
Emergency Platform Grant. Emergency Platform Grant.
The complete accepted decision and implementation divergences are in The complete accepted decision and implementation divergences are in
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md). [ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md),
The pinned semantic invariants are in
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md). and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
Exact numeric session/invitation/step-up limits and browser mechanics remain Exact numeric session/invitation/step-up limits and browser mechanics remain
+12 -16
View File
@@ -1,29 +1,25 @@
# AGENTS.md —— agent 操作手册(全 repo) # AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么 ## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照 - `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 - org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 / 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
`Spec.System.ProjectWorkspace`)。
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org - 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 / 独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。
`Spec.System.Organization`)。
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd - Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退 credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
process-global credential;Agent child 只接收 run-scoped loopback proxy capability, process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。 不接收 org provider credential(见 ADR-0024)。
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。 - 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 / Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。
`Spec.System.Capacity`)。
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用 - 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走 客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。 双因子的离线恢复流程(见 ADR-0023)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、 - 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由 service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS `HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
@@ -44,12 +40,12 @@
## 纪律 ## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。 1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿 3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。 4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+8 -10
View File
@@ -1,25 +1,23 @@
# CLAUDE.md —— agent 操作手册(全 repo) # CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么 ## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照 - `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 - org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 / 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
`Spec.System.ProjectWorkspace`)。
## 纪律 ## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。 1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿 3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。 4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+4
View File
@@ -99,3 +99,7 @@ _Avoid_: Cost budget, unlimited run
**Emergency Workload Brake**: **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. 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 _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
+16 -23
View File
@@ -2,7 +2,7 @@
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。 教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。** 这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。**
## 安装 `cph` 命令行 ## 安装 `cph` 命令行
@@ -33,47 +33,40 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
``` ```
README.md ← 本文件:总览 + 宪法(下面 5 条) README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo) CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用) docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源)
spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md CONTEXT.md ← 平台语言词汇表(术语与禁用说法)
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate) Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎) cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行 cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005) render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入 examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署 hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 spec/ (exporter/ …) ← 将来的其他部件,平级于 crates/
``` ```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被 实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。 未来部件(如 exporter)复用,而非各自重造。
## 宪法 ## 宪法
5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。 4 条是本仓库的协作约定,是一切工作的前提。
1. **角色 —— Lean 是研发侧的上游参照** 1. **角色 —— ADR 是决策真相**
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来 跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档
2. **对齐机制 —— Lean 只做上游参照** 2. **对齐机制 —— 人肉承载,无机器兜底**
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。 CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
3. **资产性 —— 由 review 纪律承载,无机器兜底** 3. **形态 —— 自包含**
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档 凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定
4. **形态 —— 它是人机共识的契约** 4. **深度判据 —— 只收录分歧点**
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞 一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
## CI ## CI
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml` Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace 必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。 全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
+3 -2
View File
@@ -1,7 +1,8 @@
# crates/ # crates/
These crates implement the rule-based lesson checker that aligns to the These crates implement the rule-based lesson checker whose semantics are
semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005) pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson,
ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element laid out per ADR-0008 (declarative `manifest.toml` + per-element
`element.toml`), validates structure and content, and emits diagnostics. `element.toml`), validates structure and content, and emits diagnostics.
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), `cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
+9 -11
View File
@@ -19,13 +19,11 @@ const DEFAULT_TARGET: &str = "student";
/// Severity of the render-coverage ("element ignored under a target") diagnostic. /// Severity of the render-coverage ("element ignored under a target") diagnostic.
/// ///
/// **PINNED to `warning` by the contract.** Mirrors the Lean master's /// **PINNED to `warning` by ADR-0005:** when a
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
/// `(kind, target)` pair has no render rule the checker reports that the element /// `(kind, target)` pair has no render rule the checker reports that the element
/// is ignored under that target and **does not block the export**. Naming the /// is ignored under that target and **does not block the export**. Naming the
/// severity as a const makes "it is a warning, not an error" a greppable, /// severity as a const makes "it is a warning, not an error" a greppable
/// alignable fact rather than an inline literal. /// fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning; const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]). /// The result of running [`check`] (or the check phases of [`build`]).
@@ -57,12 +55,12 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity. /// Whether any collected diagnostic is `Error`-severity.
/// ///
/// **Legality decision (spec alignment).** `!has_errors()` is the /// **Legality decision (ADR-0010).** `!has_errors()` decides lesson
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`): /// legality: a lesson is *legal* iff its diagnostics contain no error-level
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic /// diagnostic (warnings are non-blocking — see `Severity`). There is no CI
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI /// gate enforcing ADR↔implementation alignment (repo constitution); it is
/// gate enforcing this alignment (repo constitution); it is kept greppable /// kept greppable here so a reviewer can tie the orchestrator's gate to
/// here so a reviewer can tie the orchestrator's gate to the Lean master. /// the ADR.
pub fn has_errors(&self) -> bool { pub fn has_errors(&self) -> bool {
self.diagnostics self.diagnostics
.iter() .iter()
+10 -15
View File
@@ -2,7 +2,7 @@
//! //!
//! Every other crate in the workspace depends on these types to report //! Every other crate in the workspace depends on these types to report
//! problems. The vocabulary is intentionally small and stable: a [`Severity`] //! problems. The vocabulary is intentionally small and stable: a [`Severity`]
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an //! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a //! optional [`SourceSpan`] pointing back at the offending source, and a
//! [`Diagnostic`] tying them together with a human message and a fix hint. //! [`Diagnostic`] tying them together with a human message and a fix hint.
//! //!
@@ -17,32 +17,27 @@ use serde::Serialize;
/// Severity of a diagnostic. /// Severity of a diagnostic.
/// ///
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic /// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
/// ///
/// ```text /// ```text
/// inductive Severity where /// warning | error
/// | warning
/// | error
/// ``` /// ```
/// ///
/// This two-valued shape is a **contract decision**, not an accident: the Lean /// This two-valued shape is a **contract decision**, not an accident: the
/// module pins `Severity` to exactly `warning | error` and states the finer /// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
/// do **not** add an info/note level here. `error` blocks (the artifact is /// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with /// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning"). /// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
/// ///
/// There is no CI gate enforcing this alignment (see the repo constitution); /// There is no CI gate enforcing ADR↔implementation alignment (see the repo
/// it is maintained by review, which is why this correspondence is documented /// constitution); it is maintained by review, which is why the decision is
/// here rather than only in the spec. /// documented here rather than only in the ADR.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity { pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored /// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. Mirrors Lean `Severity.warning`. /// element. ADR-0010 `warning`.
Warning, Warning,
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`. /// Blocking: the artifact is invalid. ADR-0010 `error`.
Error, Error,
} }
+25 -38
View File
@@ -3,9 +3,7 @@
//! This crate is the **loader**, not the full checker. It reads //! This crate is the **loader**, not the full checker. It reads
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared //! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an //! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
//! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)` //! ordered [`Lesson`], where the order of `parts` carries teaching semantics.
//! (`spec/Spec/Courseware/Model/Lesson.lean`), where the order of `parts` carries
//! teaching semantics.
//! //!
//! Scope boundaries (deliberately staying in lane): //! Scope boundaries (deliberately staying in lane):
//! - It validates **structure** only: manifest shape, element.toml shape, and //! - It validates **structure** only: manifest shape, element.toml shape, and
@@ -23,7 +21,7 @@ use serde::{Deserialize, Serialize};
/// An ordered, in-memory lesson loaded from an engineering file. /// An ordered, in-memory lesson loaded from an engineering file.
/// ///
/// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered /// `parts` is an ordered
/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest /// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest
/// is declarative" — the `[[parts]]` array order is the single source of truth). /// is declarative" — the `[[parts]]` array order is the single source of truth).
#[derive(Debug, Clone, PartialEq, Serialize)] #[derive(Debug, Clone, PartialEq, Serialize)]
@@ -86,9 +84,8 @@ pub struct TargetConfig {
/// with template `exports/<name>.typ` when no `[[steps]]` are given. /// with template `exports/<name>.typ` when no `[[steps]]` are given.
pub steps: Vec<Step>, pub steps: Vec<Step>,
/// The **render-coverage declaration**: which element kinds this target /// The **render-coverage declaration**: which element kinds this target
/// renders. Realizes `Spec.Courseware.TargetSpec.covers : KindId → Prop` /// renders. Realizes ADR-0011's "render
/// (`spec/Spec/Courseware/Export/Render.lean`) and ADR-0011's "render /// coverage is a declaration, not a payload": the declaration keeps *which
/// coverage is a declaration, not a payload": the contract keeps *which
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic), /// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
/// while the rendering "how" lives in the template/steps. /// while the rendering "how" lives in the template/steps.
/// ///
@@ -102,13 +99,10 @@ pub struct TargetConfig {
/// The artifact an export target produces (ADR-0009/0011). /// The artifact an export target produces (ADR-0009/0011).
/// ///
/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master /// **Pinned by ADR-0011** as an ADT with fields:
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
/// ///
/// ```text /// ```text
/// inductive Artifact where /// Artifact = singleFile (filepath) | fileTree (root, outputs)
/// | singleFile (filepath : String)
/// | fileTree (root : String) (outputs : String)
/// ``` /// ```
/// ///
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product /// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
@@ -120,20 +114,20 @@ pub struct TargetConfig {
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` → /// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
/// [`Artifact::FileTree`]. /// [`Artifact::FileTree`].
/// ///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this /// As with `cph-diag`'s `Severity`, there is no CI gate enforcing ADR↔
/// alignment (see the repo constitution) — it is maintained by review, which is /// implementation alignment (see the repo constitution) — it is maintained by
/// why the correspondence is documented here. /// review, which is why the decision is documented here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Artifact { pub enum Artifact {
/// One bundled document landing at `filepath` (relative to the engineering /// One bundled document landing at `filepath` (relative to the engineering
/// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape. /// root). ADR-0011 `singleFile`. The default artifact shape.
SingleFile { SingleFile {
/// Where the single product is written (relative to the engineering /// Where the single product is written (relative to the engineering
/// root), e.g. `build/student.pdf`. /// root), e.g. `build/student.pdf`.
filepath: PathBuf, filepath: PathBuf,
}, },
/// A set of files under `root` matching the `outputs` glob. Mirrors Lean /// A set of files under `root` matching the `outputs` glob. ADR-0011
/// `Artifact.fileTree`. /// `fileTree`.
FileTree { FileTree {
/// The output directory (relative to the engineering root). /// The output directory (relative to the engineering root).
root: PathBuf, root: PathBuf,
@@ -156,14 +150,10 @@ impl Artifact {
/// One typed build step (ADR-0011). /// One typed build step (ADR-0011).
/// ///
/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master /// **Pinned by ADR-0011** as an ADT:
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
/// ///
/// ```text /// ```text
/// inductive Step where /// Step = typstCompile (template) | shell (run) | assembleMarkdown (field)
/// | typstCompile (template : String)
/// | shell (run : String)
/// | assembleMarkdown (field : String)
/// ``` /// ```
/// ///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a /// A step is a *typed* operation (extensible): `TypstCompile` compiles a
@@ -183,20 +173,20 @@ impl Artifact {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Step { pub enum Step {
/// Compile a template file (relative to the engineering root) into the /// Compile a template file (relative to the engineering root) into the
/// artifact; the framework injects the manifest. Mirrors Lean /// artifact; the framework injects the manifest. ADR-0011
/// `Step.typstCompile`. /// `typstCompile`.
TypstCompile { TypstCompile {
/// The template file to compile as main, e.g. `exports/student.typ`. /// The template file to compile as main, e.g. `exports/student.typ`.
template: PathBuf, template: PathBuf,
}, },
/// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`. /// Run a shell command — the escape hatch. ADR-0011 `shell`.
Shell { Shell {
/// The command line to run. /// The command line to run.
run: String, run: String,
}, },
/// Assemble a single-file markdown deliverable by concatenating each /// Assemble a single-file markdown deliverable by concatenating each
/// element's `field` markdown content file in `[[parts]]` order. Mirrors /// element's `field` markdown content file in `[[parts]]` order. ADR-0011
/// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the /// `assembleMarkdown` (ADR-0015). Not a typst build — the
/// framework owns the read/concatenate/write itself. /// framework owns the read/concatenate/write itself.
AssembleMarkdown { AssembleMarkdown {
/// The per-element markdown content field to assemble (e.g. `slides`, /// The per-element markdown content field to assemble (e.g. `slides`,
@@ -226,12 +216,11 @@ pub struct Project {
/// `[info]` table (passed through to render targets verbatim). /// `[info]` table (passed through to render targets verbatim).
/// ///
/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master /// The *canonical* model whose
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
/// `authors` is always a list. The authoring-surface form (string-or-array /// `authors` is always a list. The authoring-surface form (string-or-array
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this /// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
/// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No /// at the load boundary. No
/// CI gate enforces this alignment (repo constitution); it is kept greppable. /// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Info { pub struct Info {
/// Lesson title. /// Lesson title.
@@ -240,7 +229,6 @@ pub struct Info {
/// so this is a list, not a single name. Empty when `[info]` declares no /// so this is a list, not a single name. Empty when `[info]` declares no
/// `author`. The on-disk `author` accepts either a bare string (one author) /// `author`. The on-disk `author` accepts either a bare string (one author)
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`. /// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
/// Mirrors Lean `Info.authors : List String`.
pub authors: Vec<String>, pub authors: Vec<String>,
} }
@@ -290,8 +278,7 @@ struct RawProject {
name: String, name: String,
} }
/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in /// The authoring-surface `[info]`: the raw form that exists for
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
/// fill-in convenience, normalized into the canonical [`Info`] at the load /// fill-in convenience, normalized into the canonical [`Info`] at the load
/// boundary. Not the form the rest of the model traffics in. /// boundary. Not the form the rest of the model traffics in.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -301,7 +288,7 @@ struct RawInfo {
} }
/// On-disk `author`: either a single name (`author = "…"`) or a list /// On-disk `author`: either a single name (`author = "…"`) or a list
/// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose /// (`author = ["…", "…"]`). A fill-in convenience whose
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`] /// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`]
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears. /// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -312,7 +299,7 @@ enum RawAuthor {
} }
impl RawAuthor { impl RawAuthor {
/// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single /// Flatten to the ordered author list: a single
/// name becomes a one-element list; a list passes through verbatim. /// name becomes a one-element list; a list passes through verbatim.
fn into_vec(self) -> Vec<String> { fn into_vec(self) -> Vec<String> {
match self { match self {
@@ -123,15 +123,23 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
workspace or service-user config from widening tools, hooks, MCP servers, or workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths. sandbox paths.
- Agent skills are Organization-scoped runtime configuration, not Hub release - Agent skills are Organization-scoped runtime configuration, not Hub release
assets. A controlled host-console installer imports each version into a assets. Skill content is imported into a content-addressed persistent store
content-addressed persistent store and records its digest in PostgreSQL. A through a shared ingestion pipeline (`importSkillDirectory` for host-console
role selects enabled Organization skills alongside its model, system prompt CLI, `importSkillFromFiles` for org-admin web surface) that enforces the same
and tool allowlist. Each run copies only those selected immutable versions safety checks: `SKILL.md` manifest required, 512-file / 16-byte limits,
into a run-scoped plugin outside the project workspace; the sandbox exposes symlink rejection, SHA-256 content addressing. The web surface
that snapshot read-only and deletes it after the run. SDK-bundled skills and (`OrganizationAgentConfiguration.installSkillFromFiles`) writes to the same
filesystem setting sources remain disabled, so project `.claude` content store as the host-console CLI (`installSkill`); both flow through
cannot register skills or widen tools. Requested skill versions are recorded `commitSkillContent` for deduplication and atomic rename into
on `run.created`; SDK initialization remains authoritative loading evidence. `versions/<digest>/`. Org-admin authentication gates the web surface; the
content-addressed store remains platform-controlled. A role selects enabled
Organization skills alongside its model, system prompt and tool allowlist.
Each run copies only those selected immutable versions into a run-scoped
plugin outside the project workspace; the sandbox exposes that snapshot
read-only and deletes it after the run. SDK-bundled skills and filesystem
setting sources remain disabled, so project `.claude` content cannot register
skills or widen tools. Requested skill versions are recorded on
`run.created`; SDK initialization remains authoritative loading evidence.
- Network: open (see Open Questions). - Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the `bypassPermissions` is kept (headless server — no interactive prompts); the
+146
View File
@@ -0,0 +1,146 @@
# ADR 0026: Usage Fact Ledger
## Status
Accepted.
## Context
ADR-0022 pins the cost-attribution contract for the platform: token,
provider-reported cost, run count, and duration are attributed by Organization,
Project, Run, model, and Provider Connection; "missing provider cost remains
unknown rather than zero." ADR-0021 scopes usage accounting as operational
reporting, not payment collection (commercial billing stays deferred).
The implementation today stores this as **one scalar per `AgentRun`**:
`inputTokens`, `outputTokens`, `costUsd?`, `costSource?`, written once from the
Claude Agent SDK's `result.total_cost_usd` when the run finishes. This is
adequate for a single provider-reported model loop, but it cannot represent:
- **External capability consumption** inside a run. PDF→Markdown bundle
conversion, audio/video transcription, OCR and similar media transforms are
not the main agent loop. They run as side effects of a run, may use a
different provider/model, may bill in non-token units (pages, seconds,
invocations), and may report cost through a different channel than the
OpenRouter gateway. Today there is no row to write that cost to — it would
either disappear or silently corrupt the run's single scalar.
- **Multiple model calls within one run** (e.g. a sub-model invoked by a
tool, a gateway-side reroute). The scalar collapses them into one number.
- **Pricebook derivation** after the fact. With only a final USD figure and no
`(provider, model, occurredAt, tokens)` fact, an operator cannot re-derive
cost from a price table when the provider did not report it.
Treating each external call as a nested `AgentRun` was considered and
rejected: `AgentRun` carries lock ownership (ADR-0002), session/provider/role
binding (ADR-0017), admission/capacity semantics (ADR-0022), and the
user-visible task boundary. External calls hold none of those. Making them
`AgentRun`s would pollute run counts, admission, lock semantics, and session
continuity, and would still not solve non-token metering.
## Decision
Introduce **`UsageFact`** as the single source of truth for billable
consumption inside an `AgentRun`. An `AgentRun` owns zero or more
append-only `UsageFact` rows; each row records one billable consumption event:
- `kind``model_completion` (the main agent loop) | `external_capability`
| `tool_proxy`. The kind set is `OPEN`; new kinds must be surfaced, not
silently folded into an existing one.
- `provider` — e.g. `openrouter`, `mineru`, `openai_whisper`.
- `model?`, `inputTokens?`, `outputTokens?` — token metering, optional
because non-token capabilities have none.
- `quantity?` + `unit?` — non-token metering (pages, audio_seconds,
invocations), coexisting with tokens rather than replacing them.
- `costUsd?` + `costSource``provider_reported` | `pricebook_derived` |
`unknown`. `costUsd = null` means **unknown, not zero** (ADR-0022). When the
provider reported a cost, `costSource = provider_reported` and that value
wins. When only tokens are known, a later pricebook pass may derive
`costUsd` with `costSource = pricebook_derived`. When neither is possible,
`costSource = unknown` and `costUsd` stays null.
- `occurredAt` — when the consumption happened; the pricebook derivation
depends on this, not on `AgentRun.finishedAt`, because an external
capability may complete before the run finishes.
- `capabilityId?` — for `external_capability` facts, the registered capability
id (e.g. `pdf_to_md_bundle`, `audio_video_to_text`).
- `correlationId?` — external request id for reconciliation / idempotency;
not part of the aggregation key.
### Invariants
1. **Append-only.** A `UsageFact` row is never updated or deleted. A cost
correction is a new row; the old row stays. `onDelete: Cascade` exists only
so a hard run delete (itself not a normal path) cleans up its facts.
2. **Belongs to exactly one Run; never holds a lock.** A fact is a side-effect
ledger of one run, not a sub-run. External capability calls obey the same
boundary: their identity is `capabilityId + correlationId`, not `RunId`.
3. **Missing cost ≠ zero.** Aggregation MUST NOT sum `null` `costUsd` as 0.
A run whose facts all have `costUsd = null` is "cost unknown" — reported as
`runsWithoutCost`, exactly as the pre-migration `costUsd = null` runs are
today. A run with at least one `costUsd`-bearing fact contributes its sum.
### Rollup cache
`AgentRun.costUsd / inputTokens / outputTokens / costSource` columns are kept
as a **derived rollup cache**, not dropped:
- Existing non-`/usage` readers (slash `/usage`, session detail, integration
tests asserting `run.costUsd`) continue to work without code changes for
historical runs.
- On run finish, the writer writes the `UsageFact` row first and then mirrors
it onto `AgentRun` as two separate statements, not one transaction. The
fact is the truth, so it is written first; the cache is derived, so it is
written second. A crash between the two leaves the cache stale, but the
usage service re-reads `UsageFact` directly, so staleness is recoverable
(and the reverse order would lose the truth, which is not). Keeping the
fact insert and the run update in separate statements also avoids holding
the `AgentRun` row lock across the FK ShareLock taken by the insert, which
deadlocks against concurrent workspace teardown under the cascade path
`Organization → Project → AgentRun → UsageFact`.
- The org/project usage service and the slash `/usage` command read from
`UsageFact` directly — they are the canonical aggregation path.
### Migration
A new `UsageFact` table is added. For every existing `AgentRun` with a
non-null `costUsd` or non-null `inputTokens`/`outputTokens`, the migration
inserts **one synthetic `model_completion` fact** carrying the run's
provider, model, tokens, cost, and `costSource`. Its `correlationId` is the
run id, so the row is identifiable as a backfill artefact. This keeps
historical reporting correct under the new aggregation path. Pre-migration
runs with no recorded cost remain `runsWithoutCost` by design, matching
ADR-0022's "missing cost ≠ zero" rule and the existing migration
`20260709143000_agent_run_cost_tracking`'s "no backfill" stance for the
truly unrecorded.
## Consequences
- The billing model now supports external capabilities (PDF→MD, ASR, OCR, …)
without per-capability schema changes: a new capability is one new
`capabilityId` value and one or more `external_capability` facts.
- `AgentRun.costUsd` is no longer the truth; it is a convenience cache.
Readers that need the truth (cost breakdown, per-capability attribution)
must read `UsageFact`. The cache MUST be kept consistent on the write path.
- The capability registry, pricebook, and any commercial settlement remain
`OPEN` and out of pilot scope (ADR-0021). This ADR only pins the ledger
shape and invariants.
- `usage.ts` and `/usage` now perform a join against `UsageFact` rather than a
single-table scan of `AgentRun`; the index on `(runId, occurredAt)` and
`(provider, model, occurredAt)` keeps the existing org/project/report
queries bounded.
- Cost corrections (e.g. a provider rebills a run) produce a new fact row; the
rollup cache must be recomputed. The initial release writes facts once at
run finish and does not support post-hoc correction flows — that remains
`OPEN`.
## Deferred
- **Capability registry** as a first-class spec entity (`Spec.System.Capability`)
with org-scoped enable/disable, input/output contracts, metering schemas,
and org-exclusive credentials (ADR-0024 alignment). This ADR only reserves
the `capabilityId` field and the `external_capability` fact kind.
- **Pricebook** — a versioned price table keyed by `(provider, model, unit)`
with time validity. Required to actually produce `pricebook_derived` costs;
until then, facts without provider-reported cost stay `unknown`.
- **Post-hoc cost correction flow** — append-only today; correction UI and
rollup recompute are `OPEN`.
- **Commercial billing, invoicing, settlement** — still deferred per ADR-0021.
@@ -0,0 +1,152 @@
# ADR 0027: External Capability Registry
## Status
Accepted.
## Context
ADR-0026 introduced the `UsageFact` ledger with a `kind = external_capability`
fact and a `capabilityId` field, but deferred the capability registry itself.
Two concrete needs now force the issue:
- **PDF→Markdown bundle** conversion (and, imminently, audio/video→text)
must run as a side effect of an `AgentRun`, bill in non-token units
(pages, seconds), use a different provider than the model loop, and report
cost through a different channel. It is not a sub-run (ADR-0026 rejected
that) and not a model-provider call (it does not speak the Anthropic/
OpenRouter protocol).
- The Agent already has a Bash tool. Without a first-class capability seam,
the path of least resistance is for the agent to shell out to ad-hoc
scripts that embed API keys, write to arbitrary paths, and report nothing
to the ledger. That is exactly the unattributed, uncontained external
consumption ADR-0022/0026 exist to prevent.
The model-provider connection (`OrganizationProviderConnection`, ADR-0024)
is the wrong seam for these services:
- Its payload schema (`baseUrl` + `authToken` + `anthropicApiKey`) and
readiness probe (`/v1/models?supported_parameters=tools`) are specific to
OpenRouter/Anthropic. MinerU, Whisper, and future OCR/ASR services have
different auth shapes (an API token, optionally a project id) and no
`/v1/models` endpoint.
- Its uniqueness key is `(organizationId, providerId)` where `providerId`
is an OpenRouter-style model-routing id. A capability provider id
(`mineru`) names a *service*, not a model.
- Coupling capability credentials into the model-provider table would force
every capability's auth shape through `ProviderSecretPayloadV1` and every
readiness probe through `probeOpenRouterCredential`.
The Feishu Application Connection (`OrganizationFeishuApplicationConnection`)
is the right structural precedent: it reuses the ADR-0024 envelope (KEK →
DEK → AES-256-GCM, AAD-bound to purpose/org/connection/version) but has its
own connection table, its own payload schema, its own readiness probe, and
its own per-org uniqueness. Capability connections follow the same pattern.
## Decision
### External Capability
An **External Capability** is a platform-registered, org-enabled document or
media transform service invoked as a side effect of an `AgentRun`. It is
identified by a stable `capabilityId` (e.g. `pdf_to_md_bundle`,
`audio_video_to_text`). A capability:
- Has an **input kind** (PDF, image, audio, video, …) and an **output
contract** (markdown bundle with extracted images, transcript text, …).
- Bills in **non-token units** (pages, audio-seconds) recorded on a
`UsageFact` with `kind = external_capability`, or in tokens when the
backing service reports them.
- Writes its output **into the invoking run's workspace** (ADR-0018
`AgentSurface` — no escapes).
- Is invoked through a **capability adapter** in Hub, never by the Agent
shelling out with embedded credentials.
### Capability Connection
Credentials for a capability live in an **`OrganizationCapabilityConnection`**,
structurally identical to the Feishu Application Connection:
- Belongs to exactly one Organization.
- Unique by `(organizationId, capabilityId)`.
- `DRAFT` / `ACTIVE` / `DISABLED`; resolution accepts only `ACTIVE` with a
valid active secret version.
- Secret material is an immutable, AAD-bound, KEK-wrapped envelope version
(`CapabilityCredentialVersion`), reusing the ADR-0024 encryption
machinery with `purpose = "capability"`.
- Its payload schema is capability-specific (`CapabilitySecretPayloadV1`:
`baseUrl`, `apiToken`, optional `projectId`). New capability types extend
the payload, not the connection table.
- A capability-specific **readiness probe** validates the credential before
activation (e.g. MinerU: a trivial authenticated GET). The probe is
injectable, matching the Feishu/provider pattern, so tests never hit the
network.
### Capability Adapter
The adapter is the seam between the Agent and the external service. It:
- Resolves the org's active capability connection (fail-closed, no
process-global fallback — ADR-0024).
- Accepts a workspace-relative input path and an output directory.
- Calls the backing service (MinerU, Whisper, …) via an injectable
`Client` interface so the real HTTP client is swappable and mockable.
- Writes the produced markdown + image assets into the run's workspace.
- Writes one `UsageFact` (or more, if the service reports per-stage
consumption) with `kind = external_capability`, `capabilityId`,
`provider` (the service id), `quantity + unit` (pages / seconds), and
`costUsd + costSource = provider_reported` when the service reports cost.
### Registry
The platform maintains a **registry** of known capabilities: their id,
input kind, output contract, metering unit, and adapter. This is
code-level registration (like `ToolRegistry`), not a database table — a
capability is available to an Organization only when (a) the platform
knows the adapter and (b) the Organization has an `ACTIVE` connection for
it. Both gates are required.
### What is NOT in this ADR
- The capability invocation is **not** a first-class persisted record
(`CapabilityInvocation` table) in this ADR. The `UsageFact` row with
`capabilityId` + `correlationId` is the durable trace. If we later need
a richer invocation log (retries, partial output, multi-stage status),
that is a follow-up; for now the fact is enough.
- **Pricebook** remains deferred (ADR-0026). Capability facts use
`provider_reported` when the service returns cost; otherwise `unknown`.
- **Org-scoped enable/disable policy** beyond connection status is
deferred. An org with an `ACTIVE` connection has the capability; one
without does not. A finer "enabled but no credential" toggle is not
needed yet.
- **Agent-facing tool exposure** (how the Agent discovers and calls the
capability — MCP tool, Bash wrapper, or built-in) is an implementation
detail of the adapter wiring, not a contract concern. The contract pins
that the Agent never receives the capability credential.
## Consequences
- Adding a new external capability (e.g. `image_ocr`) is: register an
adapter, add a `capabilityId` constant, optionally extend the secret
payload — no schema change to `UsageFact` or `AgentRun`.
- The model-provider connection table stays focused on model routing;
capability credentials do not pollute its payload or readiness probe.
- Three connection types now share the ADR-0024 envelope: model-provider,
Feishu application, and capability. Each has its own table, payload
schema, and probe, but the same encryption, rotation, and resolver
boundary.
- The Agent's Bash tool remains available, but the intended path for
document/media transforms is the capability adapter. Whether to narrow
Bash for capability-shaped tasks is an operational policy decision,
not a contract one.
- Tests prove: workspace containment of capability output, fail-closed
credential resolution, `UsageFact` attribution with non-token metering,
and that the Agent process never receives the capability credential.
## Deferred
- `CapabilityInvocation` as a first-class durable record (status, retries,
partial output) — currently the `UsageFact` row is the only trace.
- Pricebook derivation for capability costs (ADR-0026 deferred).
- Org-scoped capability enable/disable policy finer than connection status.
- Agent-facing tool discovery (MCP vs built-in) for capabilities.
@@ -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.
@@ -0,0 +1,43 @@
# ADR 0030: Project Grants Are Always Live; The Independent-Permission Toggle Is Removed
## Status
Accepted. Supersedes the file-library contract rule **D11 / P5** (《文件库-接口契约.md》,
since deleted; recoverable from git history) which introduced the per-project
"独立权限" (independent permission) switch.
## Context
D11 gave each PROJECT a toggle (`FileLibProjectSettings.independentPermissionsEnabled`,
default off). While off, project-level non-creator grants were **frozen** — present in
`FileLibGrant` but excluded from `effectiveRole`; ancestor-chain grants and the creator's
auto-grant were unaffected. The intent was to support two workflows: "project follows the
folder's ACL" (off) vs "project has its own ACL" (on).
In practice the toggle surprised operators twice: grants appeared to "not work" until
someone found and flipped a per-project switch buried in the 概览 tab, and the frozen state
was indistinguishable from missing grants in the UI. The product decision is that
project-level grants should simply always be live.
## Decision
- **Project-level grants always participate in `effectiveRole`.** The freeze branch in
`hub/src/database/filelib/permission.ts` is deleted; `EffectiveRoleInput` no longer
carries `independentPermissionsEnabled`.
- **The toggle surface is removed end-to-end**: `PUT /database/api/projects/:id/independent-permission`,
`grantService.setIndependentPermission`, the `independentPermission` field in the node
detail DTO, and the 概览 tab switch in `filelib-web`.
- **`FileLibProjectSettings` becomes vestigial.** The table stays (existing rows are
ignored, no data migration); new projects no longer get a default row. It may be dropped
in a future migration once nothing references it.
- Audit action vocabulary `independent_enable` / `independent_disable` is retained for
reading historical audit entries; no new entries are produced.
Behavior change for existing deployments: projects whose toggle was off now have their
project-level grants effective immediately — this is the intended effect of the decision.
## Consequences
- Permission semantics shrink to the single P6 rule: `effective = max(grants on self
ancestors for user resolved groups)`, no exceptions by node kind.
- One less state dimension in tests and in the admin UI.
@@ -0,0 +1,58 @@
# ADR 0031: File Library Recycle Bin And Recent-Visit Tracking
## Status
Accepted.
## Context
The teacher app (`/app`) gains a left navigation rail with three entries: 文件库 /
最近打开 / 回收站. Two of them need semantics that no prior decision covers:
- **回收站 (recycle bin)**: D15 defined soft delete (mark `deletedAt` on the node only;
a node is invisible when any ancestor is deleted) but never defined listing, restore,
or permanent deletion.
- **最近打开 (recent visits)**: nothing tracks opens.
## Decision
### Recycle bin
- **List**: shows nodes with `deletedAt != null` whose **ancestors are all active**
(the topmost deleted node per branch; descendants of a deleted node are represented
by it and not listed separately).
- **Visibility/auth**: a bin entry is visible to (a) the website administrator, or
(b) any actor holding an active MANAGE grant **on the deleted node itself**
(grants stay live through soft delete, so this is a plain grant query — no chain
walk, no inheritance; the bin is a management surface, not a browsing surface).
- **Restore** clears `deletedAt` on that node only (D15 symmetry: delete marks one
node, restore unmarks one node). The subtree becomes visible again immediately.
Same auth as the list entry. Audited (`folder_restore` / `project_restore`).
- **Permanent delete (彻底删除)** is **website-administrator only**: hard-deletes the
node **and its whole subtree** (descendants enumerated via the `pathIds` materialized
path, deleted deepest-first because the self-FK is `ON DELETE RESTRICT`), in one
transaction, with one audit entry (`node_purge`, detail carries removed count).
Grants/settings/export-jobs cascade. There is no recovery; the UI must confirm
explicitly.
### Recent visits
- **Model**: `FileLibRecentVisit(organizationId, userId, nodeId, filePath, openedAt)`,
unique on `(organizationId, userId, nodeId, filePath)` with `filePath` defaulting to
`""` (Postgres unique indexes treat NULLs as distinct). `filePath = ""` means the
visit is the node itself (drill into folder/project); non-empty means a file preview
inside that project.
- **Recording is client-driven**: the teacher app POSTs after a successful open
(folder drill, project open, file preview). The endpoint requires VIEW on the node
(D8: no VIEW → 404, leaking nothing). Upsert semantics: re-opening refreshes
`openedAt`. No audit entries — this is a per-user read model, not a权限-sensitive
mutation.
- **List**: the actor's own most recent 20, `openedAt` desc. Entries whose node is
deleted **or has any deleted ancestor** are filtered out (D8/D15 visibility holds
on every surface). Names are read live from `FileLibNode` (no denormalization).
## Consequences
- No change to existing permission algebra; both features are additive surfaces.
- The bin deliberately does not offer per-owner bins or inherited-MANAGE visibility —
if real usage demands it, that is a new decision.
@@ -0,0 +1,30 @@
# ADR 0032: Remove The Recent-Visit Module
## Status
Accepted. **Supersedes the "Recent visits" half of ADR-0031** (the recycle-bin half
is unaffected and remains in force).
## Context
ADR-0031 (same day) introduced 最近打开: a `FileLibRecentVisit` table, client-driven
visit recording, and a rail entry in the teacher app. After seeing it live, the product
call is that the module is not wanted — it adds a tracking surface, a table, and rail
noise without a compelling teacher workflow behind it.
## Decision
The recent-visit module is removed end-to-end:
- `FileLibRecentVisit` is dropped (hand-written migration
`20260731090000_drop_filelib_recent_visit`; the table was created the same day and
held no production data).
- `recentService` / `recentRoutes` (`/database/api/recent`) and the `RecentView`
component are deleted; the rail in `/app` keeps only 文件库 / 回收站.
- `GridLibraryView` visit recording and the `navTarget` navigation entry go with it.
- The `role` field added to breadcrumb entries for ADR-0031 is **kept** — it is a
cheap, additive field on an existing API and independent of the removed module.
If recent-visit tracking comes back as a requirement, it is a new decision (and
should then define why client-driven tracking is worth its surface) rather than a
revival of this one.
+4 -1
View File
@@ -32,7 +32,7 @@ App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` | | 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
| 以应用身份发送消息 | `im:message:send_as_bot` | | 以应用身份发送消息 | `im:message:send_as_bot` |
| 读取触发消息和线程上下文 | `im:message:readonly` | | 读取触发消息和线程上下文 | `im:message:readonly` |
| 获取与上传图片或文件 | `im:resource` | | 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` | | 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` | | 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` | | 获取用户基本资料 | `contact:user.basic_profile:readonly` |
@@ -66,6 +66,9 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。 如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
## 4. 配置事件与卡片回调 ## 4. 配置事件与卡片回调
进入“事件与回调”。 进入“事件与回调”。
+4
View File
@@ -34,6 +34,10 @@ HUB_FEISHU_EVENTS_PER_MINUTE="120"
# to this path and rejects any overlap before installing the unit. # to this path and rejects any overlap before installing the unit.
HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces" HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# Persistent root for the content-addressed agent skill store. Required at hub
# startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills).
HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills"
# This process is pinned to exactly one Organization. Feishu credentials are # This process is pinned to exactly one Organization. Feishu credentials are
# resolved from that Organization's encrypted ACTIVE connection. # resolved from that Organization's encrypted ACTIVE connection.
HUB_SILO_ORGANIZATION_ID="" HUB_SILO_ORGANIZATION_ID=""
+7
View File
@@ -5,6 +5,13 @@ dist/
.env.* .env.*
!.env.example !.env.example
.secrets/ .secrets/
.dev-keyring.json
.dev-workspaces/
.dev-skills/
.filelib-repos/
admin-web/node_modules/ admin-web/node_modules/
admin-web/build/ admin-web/build/
admin-web/.svelte-kit/ admin-web/.svelte-kit/
filelib-web/node_modules/
filelib-web/build/
filelib-web/.svelte-kit/
+177 -4
View File
@@ -168,6 +168,16 @@ export interface FeishuApplicationConnection {
updatedAt: string; updatedAt: string;
} }
export interface CapabilityConnection {
id: string;
capabilityId: string;
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface UsageTotals { export interface UsageTotals {
runCount: number; runCount: number;
runsWithCost: number; runsWithCost: number;
@@ -183,13 +193,81 @@ export interface ProjectUsageRow extends UsageTotals {
folderId: string | null; folderId: string | null;
} }
/** Ledger slice from UsageFact (ADR-0026): separates model tokens vs external meters. */
export interface UsageBreakdownRow {
kind: string;
provider: string;
model: string | null;
capabilityId: string | null;
unit: string | null;
factCount: number;
factsWithCost: number;
factsWithoutCost: number;
inputTokens: number;
outputTokens: number;
quantity: number | null;
costUsd: number | null;
}
export interface UsageReport { export interface UsageReport {
from: string | null; from: string | null;
to: string | null; to: string | null;
projects: ProjectUsageRow[]; projects: ProjectUsageRow[];
totals: UsageTotals; totals: UsageTotals;
breakdown: UsageBreakdownRow[];
} }
export interface ProjectUsageReport extends ProjectUsageRow {
from: string | null;
to: string | null;
breakdown: UsageBreakdownRow[];
}
export interface UsageFactRow {
id: string;
occurredAt: string;
kind: string;
provider: string;
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
quantity: number | null;
unit: string | null;
costUsd: number | null;
costSource: string;
capabilityId: string | null;
correlationId: string | null;
}
export interface SessionRunRow {
id: string;
status: string;
model: string;
provider: string;
inputTokens: number | null;
outputTokens: number | null;
costUsd: number | null;
costSource: string | null;
startedAt: string;
finishedAt: string | null;
error: string | null;
usageFacts: UsageFactRow[];
}
export interface SessionDetail {
id: string;
provider: string;
roleId: string;
model: string;
title: string | null;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
project: { id: string; name: string };
runs: SessionRunRow[];
}
export type CapacityDimension = export type CapacityDimension =
| 'requestRate' | 'requestRate'
| 'requestBodySize' | 'requestBodySize'
@@ -226,6 +304,50 @@ export interface CapacityPolicyView {
dimensions: CapacityDimensionRow[]; dimensions: CapacityDimensionRow[];
} }
export interface AgentRoleRow {
id: string;
roleId: string;
label: string;
defaultModel: string | null;
systemPrompt: string | null;
tools: readonly string[] | null;
sortOrder: number;
isDefault: boolean;
disabledAt: string | null;
createdAt: string;
updatedAt: string;
skillNames: readonly string[];
}
export interface AgentSkillRow {
id: string;
name: string;
version: string;
description: string | null;
contentDigest: string;
disabledAt: string | null;
createdAt: string;
updatedAt: string;
boundRoleIds: readonly string[];
}
export interface SkillFileEntry {
path: string;
content: string;
}
export interface InstalledSkillResult {
id: string;
name: string;
contentDigest: string;
}
export interface AgentModelRow {
id: string;
label: string;
toolCapable: boolean;
}
// --- API --- // --- API ---
export const api = { export const api = {
@@ -261,8 +383,7 @@ export const api = {
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`), post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>, explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
myProjects: (slug: string) => myProjects: (slug: string) => get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) => createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
post(`${orgBase(slug)}/folders`, body) as Promise<{ post(`${orgBase(slug)}/folders`, body) as Promise<{
id: string; id: string;
@@ -300,6 +421,8 @@ export const api = {
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{ get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{
sessions: SessionSummary[]; sessions: SessionSummary[];
}>, }>,
session: (slug: string, sessionId: string) =>
get(`${orgBase(slug)}/sessions/${encodeURIComponent(sessionId)}`) as Promise<SessionDetail>,
usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => { usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params?.from) q.set('from', params.from); if (params?.from) q.set('from', params.from);
@@ -308,6 +431,16 @@ export const api = {
const qs = q.toString(); const qs = q.toString();
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>; return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
}, },
projectUsage: (slug: string, projectId: string, params?: { from?: string; to?: string }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
const qs = q.toString();
return get(
`${orgBase(slug)}/projects/${encodeURIComponent(projectId)}/usage${qs ? `?${qs}` : ''}`,
) as Promise<ProjectUsageReport>;
},
providerConnections: (slug: string) => providerConnections: (slug: string) =>
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>, get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
@@ -338,8 +471,48 @@ export const api = {
disableFeishuApplication: (slug: string) => disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>, del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
capacityPolicy: (slug: string) => capabilityConnections: (slug: string) =>
get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>, get(`${orgBase(slug)}/capability-connections`) as Promise<{ connections: CapabilityConnection[] }>,
capabilityConnection: (slug: string, capabilityId: string) =>
get(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<{
connection: CapabilityConnection | null;
}>,
rotateCapabilityConnection: (
slug: string,
capabilityId: string,
body: { accessKeyId: string; accessKeySecret: string; endpoint: string },
) =>
put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>,
disableCapabilityConnection: (slug: string, capabilityId: string) =>
del(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`) as Promise<CapabilityConnection>,
capacityPolicy: (slug: string) => get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) => setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>, put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
agentRoles: (slug: string) => get(`${orgBase(slug)}/agent-roles`) as Promise<{ roles: AgentRoleRow[] }>,
upsertAgentRole: (
slug: string,
roleId: string,
body: {
label: string;
defaultModel?: string | null;
systemPrompt?: string | null;
tools?: readonly string[] | null;
sortOrder?: number;
isDefault?: boolean;
},
) => put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}`, body) as Promise<AgentRoleRow>,
setAgentRoleSkills: (slug: string, roleId: string, skillNames: readonly string[]) =>
put(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/skills`, { skillNames }) as Promise<{
skillNames: string[];
}>,
agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>,
agentSkillFiles: (slug: string, name: string) =>
get(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/files`) as Promise<{ files: SkillFileEntry[] }>,
installAgentSkill: (slug: string, name: string, body: { version: string; files: readonly SkillFileEntry[] }) =>
put(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<InstalledSkillResult>,
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>,
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
}; };
@@ -8,6 +8,8 @@
folders, folders,
projects, projects,
slug, slug,
onCreateFolder,
onCreateProject,
}: { }: {
folder: ExplorerFolder; folder: ExplorerFolder;
folders: ExplorerFolder[]; folders: ExplorerFolder[];
@@ -19,30 +21,58 @@
binding: { chatId: string } | null; binding: { chatId: string } | null;
}[]; }[];
slug: string; slug: string;
onCreateFolder?: (parentId: string) => void;
onCreateProject?: (folderId: string) => void;
} = $props(); } = $props();
let open = $state(true); let open = $state(true);
</script> </script>
<div> <div>
<button <div class="group flex w-full items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100">
type="button" <button type="button" class="flex min-w-0 flex-1 items-center gap-2.5 text-left" onclick={() => (open = !open)}>
class="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm transition hover:bg-surface-100" <span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span>
onclick={() => (open = !open)} <span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800">
> <Icon name="folder" class="h-4 w-4" />
<span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span> </span>
<span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800"> <span class="min-w-0 flex-1 truncate font-medium text-surface-900">{folder.name}</span>
<Icon name="folder" class="h-4 w-4" /> <span class="saas-badge-neutral">{folder.projectCount} 项目</span>
</span> {#if folder.childFolderCount > 0}
<span class="min-w-0 flex-1 truncate font-medium text-surface-900">{folder.name}</span> <span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
<span class="saas-badge-neutral">{folder.projectCount} 项目</span> {/if}
{#if folder.childFolderCount > 0} </button>
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span> {#if onCreateFolder || onCreateProject}
<span
class="flex shrink-0 items-center gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
>
{#if onCreateFolder}
<button
type="button"
class="flex h-6 w-6 items-center justify-center border border-surface-300 bg-surface-50 text-surface-600 transition hover:border-primary-400 hover:text-primary-700"
title="在此新建子文件夹"
aria-label="在 {folder.name} 内新建子文件夹"
onclick={() => onCreateFolder(folder.id)}
>
<Icon name="folder-plus" class="h-4 w-4" />
</button>
{/if}
{#if onCreateProject}
<button
type="button"
class="flex h-6 w-6 items-center justify-center border border-surface-300 bg-surface-50 text-surface-600 transition hover:border-primary-400 hover:text-primary-700"
title="在此新建项目"
aria-label="在 {folder.name} 内新建项目"
onclick={() => onCreateProject(folder.id)}
>
<Icon name="file-plus" class="h-4 w-4" />
</button>
{/if}
</span>
{/if} {/if}
</button> </div>
{#if open} {#if open}
<div class="ml-4 border-l border-surface-300 pl-2"> <div class="ml-4 border-l border-surface-300 pl-2">
<FolderTree {folders} {projects} parentId={folder.id} {slug} /> <FolderTree {folders} {projects} parentId={folder.id} {slug} {onCreateFolder} {onCreateProject} />
</div> </div>
{/if} {/if}
</div> </div>
@@ -9,6 +9,8 @@
projects, projects,
parentId, parentId,
slug, slug,
onCreateFolder,
onCreateProject,
}: { }: {
folders: ExplorerFolder[]; folders: ExplorerFolder[];
projects: { projects: {
@@ -20,6 +22,8 @@
}[]; }[];
parentId: string | null; parentId: string | null;
slug: string; slug: string;
onCreateFolder?: (parentId: string) => void;
onCreateProject?: (folderId: string) => void;
} = $props(); } = $props();
let childFolders = $derived(folders.filter((f) => f.parentId === parentId)); let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
@@ -29,7 +33,7 @@
<div class="space-y-0.5"> <div class="space-y-0.5">
{#each childProjects as p (p.id)} {#each childProjects as p (p.id)}
<a <a
href={`/admin/org/${slug}/projects/${p.id}`} href={`/admin/projects/${p.id}`}
class="flex items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100" class="flex items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100"
> >
<span class="flex h-7 w-7 items-center justify-center border border-primary-200 bg-primary-50 text-primary-700"> <span class="flex h-7 w-7 items-center justify-center border border-primary-200 bg-primary-50 text-primary-700">
@@ -44,6 +48,6 @@
{/each} {/each}
{#each childFolders as f (f.id)} {#each childFolders as f (f.id)}
<FolderNode folder={f} {folders} {projects} {slug} /> <FolderNode folder={f} {folders} {projects} {slug} {onCreateFolder} {onCreateProject} />
{/each} {/each}
</div> </div>
+33 -1
View File
@@ -15,9 +15,13 @@
| 'logout' | 'logout'
| 'org' | 'org'
| 'chevron' | 'chevron'
| 'arrow-left'
| 'folder' | 'folder'
| 'file' | 'file'
| 'check'; | 'folder-plus'
| 'file-plus'
| 'check'
| 'roles';
class?: string; class?: string;
} = $props(); } = $props();
</script> </script>
@@ -100,6 +104,10 @@
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg> </svg>
{:else if name === 'arrow-left'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
{:else if name === 'folder'} {:else if name === 'folder'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path <path
@@ -116,8 +124,32 @@
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/> />
</svg> </svg>
{:else if name === 'folder-plus'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>
{:else if name === 'file-plus'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{:else if name === 'check'} {:else if name === 'check'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
{:else if name === 'roles'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.847-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.847.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.456-2.456L14.25 6l1.035-.259a3.375 3.375 0 002.456-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
{/if} {/if}
@@ -0,0 +1,208 @@
<script lang="ts">
import { Checkbox, Label } from 'bits-ui';
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants';
import SelectField from '$lib/components/SelectField.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let {
r,
models,
skills,
slug,
onupdated,
onskillschanged,
}: {
r: AgentRoleRow;
models: AgentModelRow[];
skills: AgentSkillRow[];
slug: string;
onupdated: (updated: AgentRoleRow) => void;
onskillschanged: (roleId: string, skillNames: string[]) => void;
} = $props();
const initial = {
label: r.label,
defaultModel: r.defaultModel ?? '',
systemPrompt: r.systemPrompt ?? '',
unrestricted: r.tools === null,
tools: r.tools ?? [],
sortOrder: String(r.sortOrder),
isDefault: r.isDefault,
skillNames: r.skillNames,
};
let label = $state(initial.label);
let defaultModel = $state(initial.defaultModel);
let systemPrompt = $state(initial.systemPrompt);
let unrestricted = $state(initial.unrestricted);
let selectedTools = $state<string[]>([...initial.tools]);
let sortOrder = $state(initial.sortOrder);
let isDefault = $state(initial.isDefault);
let selectedSkills = $state<string[]>([...initial.skillNames]);
let saving = $state(false);
const groupedTools = TOOL_OPTIONS.reduce(
(acc, t) => {
(acc[t.group] ??= []).push(t);
return acc;
},
{} as Record<string, typeof TOOL_OPTIONS>,
);
const modelItems = $derived([
{ value: '', label: '(使用平台默认模型)' },
...models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` })),
]);
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
function skillsDirty(): boolean {
const a = [...selectedSkills].sort();
const b = [...r.skillNames].sort();
return a.length !== b.length || a.some((v, i) => v !== b[i]);
}
async function save() {
const trimmedLabel = label.trim();
if (trimmedLabel === '') {
toastError('显示名不能为空');
return;
}
const order = Number(sortOrder);
if (sortKeyDirty() && (!Number.isSafeInteger(order) || order < 0)) {
toastError('排序必须为非负整数');
return;
}
saving = true;
const tools = unrestricted ? null : selectedTools;
try {
const updated = await api.upsertAgentRole(slug, r.roleId, {
label: trimmedLabel,
defaultModel: defaultModel === '' ? null : defaultModel,
systemPrompt: systemPrompt === '' ? null : systemPrompt,
tools,
...(sortKeyDirty() ? { sortOrder: order } : {}),
isDefault,
});
onupdated(updated);
if (skillsDirty()) {
const res = await api.setAgentRoleSkills(slug, r.roleId, selectedSkills);
selectedSkills = [...res.skillNames];
onskillschanged(r.roleId, res.skillNames);
}
toastSuccess('角色已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
function sortKeyDirty(): boolean {
return Number(sortOrder) !== r.sortOrder;
}
</script>
<div class="saas-card-pad">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="saas-badge-primary font-mono">/{r.roleId}</span>
<span class="text-sm text-surface-700">{label || r.label}</span>
{#if r.isDefault}
<span class="saas-badge-success">默认</span>
{/if}
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label.Root for="role-label-{r.id}" class="saas-label">显示名</Label.Root>
<input id="role-label-{r.id}" class="saas-input" bind:value={label} />
</div>
<div>
<Label.Root for="role-sort-{r.id}" class="saas-label">排序</Label.Root>
<input id="role-sort-{r.id}" class="saas-input" type="number" min="0" bind:value={sortOrder} />
</div>
</div>
<div class="mt-4">
<p class="saas-label">默认模型</p>
<SelectField items={modelItems} bind:value={defaultModel} />
</div>
<div class="mt-4">
<span class="saas-label">工具白名单</span>
<label class="mb-3 flex cursor-pointer items-center gap-2 border border-surface-300 bg-surface-100 px-3 py-2">
<CheckboxControl bind:checked={unrestricted} />
<span class="text-sm">不限(使用全部注册工具)</span>
</label>
<div class="space-y-3 {unrestricted ? 'pointer-events-none opacity-40' : ''}">
<Checkbox.Group bind:value={selectedTools} disabled={unrestricted}>
{#each Object.entries(groupedTools) as [group, tools]}
<div>
<p class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-surface-600">{group}</p>
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each tools as t}
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<Checkbox.Root class="saas-checkbox" value={t.id} id={`tool-${r.id}-${t.id}`}>
{#snippet children({ checked })}
{#if checked}
<Icon name="check" class="h-3.5 w-3.5" />
{/if}
{/snippet}
</Checkbox.Root>
<span>{t.label}</span>
</label>
{/each}
</div>
</div>
{/each}
</Checkbox.Group>
</div>
</div>
<div class="mt-4">
<span class="saas-label">技能绑定</span>
{#if skills.length === 0}
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装(ADR-0018)。</p>
{:else}
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each skillItems as s}
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<CheckboxControl
checked={selectedSkills.includes(s.value)}
onchange={(checked) => {
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
}}
/>
<span class="font-mono text-xs">{s.label}</span>
</label>
{/each}
</div>
{/if}
</div>
<div class="mt-4">
<Label.Root for="role-prompt-{r.id}" class="saas-label">系统提示词</Label.Root>
<textarea
id="role-prompt-{r.id}"
class="saas-textarea"
rows="4"
placeholder="系统提示词(可选)。会话开始时注入,定义智能体人格/指令。"
bind:value={systemPrompt}></textarea>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
<label class="flex cursor-pointer items-center gap-2">
<CheckboxControl bind:checked={isDefault} />
<span class="text-sm">设为组织默认角色</span>
</label>
<span class="text-xs text-surface-600">更新于 {fmtDate(r.updatedAt)}</span>
<div class="flex-1"></div>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
@@ -0,0 +1,305 @@
<script lang="ts">
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let {
slug,
skill,
oninstalled,
ondisabled,
}: {
slug: string;
skill: AgentSkillRow;
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
ondisabled: (name: string) => void;
} = $props();
type FileNode = { path: string; content: string };
let files = $state<FileNode[]>([]);
let selectedPath = $state<string | null>(null);
let version = $state(skill.version);
let description = $state(skill.description ?? '');
let loading = $state(false);
let saving = $state(false);
let dirty = $state(false);
let newFilePath = $state('');
let showNewFile = $state(false);
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
const sortedFiles = $derived([...files].sort((a, b) => a.path.localeCompare(b.path)));
async function loadFiles() {
loading = true;
try {
const res = await api.agentSkillFiles(slug, skill.name);
files = res.files.map((f) => ({ path: f.path, content: f.content }));
if (files.length > 0 && selectedPath === null) {
selectedPath = files[0]!.path;
}
dirty = false;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
loading = false;
}
}
function selectFile(path: string) {
selectedPath = path;
}
function updateContent(path: string, content: string) {
const file = files.find((f) => f.path === path);
if (file) {
file.content = content;
dirty = true;
if (path === 'SKILL.md') {
const desc = parseDescription(content);
if (desc !== null) description = desc;
}
}
}
function addFile() {
const path = newFilePath.trim();
if (path === '') {
toastError('文件路径不能为空');
return;
}
if (files.some((f) => f.path === path)) {
toastError(`文件已存在:${path}`);
return;
}
if (path.startsWith('/') || path.includes('..')) {
toastError('文件路径必须为相对路径');
return;
}
files.push({ path, content: '' });
selectedPath = path;
newFilePath = '';
showNewFile = false;
dirty = true;
}
function deleteFile(path: string) {
if (path === 'SKILL.md') {
toastError('SKILL.md 是必需的 manifest,不能删除');
return;
}
files = files.filter((f) => f.path !== path);
if (selectedPath === path) {
selectedPath = files.length > 0 ? files[0]!.path : null;
}
dirty = true;
}
function parseDescription(manifest: string): string | null {
const match = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(manifest);
return match ? match[1]!.trim() : null;
}
async function save() {
if (!hasManifest) {
toastError('缺少 SKILL.md manifest 文件');
return;
}
const trimmedVersion = version.trim();
if (trimmedVersion === '') {
toastError('版本号不能为空');
return;
}
saving = true;
try {
const fileEntries: SkillFileEntry[] = files.map((f) => ({ path: f.path, content: f.content }));
const result = await api.installAgentSkill(slug, skill.name, {
version: trimmedVersion,
files: fileEntries,
});
dirty = false;
toastSuccess('技能已保存');
oninstalled(result);
await loadFiles();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable() {
saving = true;
try {
await api.patchAgentSkill(slug, skill.name, { disabled: true });
toastSuccess('技能已禁用');
ondisabled(skill.name);
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
function saveDescription() {
const manifest = files.find((f) => f.path === 'SKILL.md');
if (!manifest) return;
const updated = updateFrontmatter(manifest.content, 'description', description.trim());
manifest.content = updated;
dirty = true;
}
function updateFrontmatter(content: string, key: string, value: string): string {
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
if (regex.test(content)) {
return content.replace(regex, `${key}: ${value}`);
}
const lines = content.split('\n');
if (lines[0] === '---') {
lines.splice(1, 0, `${key}: ${value}`);
} else {
lines.unshift('---', `${key}: ${value}`, '---');
}
return lines.join('\n');
}
$effect(() => {
if (skill.name) loadFiles();
});
</script>
<div class="saas-card-pad">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="saas-badge-primary font-mono">{skill.name}</span>
<span class="text-sm text-surface-700">v{skill.version}</span>
{#if skill.disabledAt}
<span class="saas-badge-error">已禁用</span>
{/if}
</div>
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="skill-version-{skill.id}" class="saas-label">版本号</label>
<input id="skill-version-{skill.id}" class="saas-input" bind:value={version} placeholder="如 0.1.0" />
</div>
<div>
<label for="skill-desc-{skill.id}" class="saas-label">描述(同步到 SKILL.md frontmatter</label>
<div class="flex gap-2">
<input id="skill-desc-{skill.id}" class="saas-input" bind:value={description} onchange={saveDescription} />
</div>
</div>
</div>
<div class="mb-4 flex flex-wrap items-center gap-2 text-xs text-surface-600">
<span>digest: <code class="font-mono">{skill.contentDigest.slice(0, 12)}</code></span>
<span>·</span>
<span>更新于 {fmtDate(skill.updatedAt)}</span>
{#if skill.boundRoleIds.length > 0}
<span>·</span>
<span>绑定角色: {skill.boundRoleIds.join(', ')}</span>
{/if}
{#if dirty}
<span>·</span>
<span class="font-medium text-warning-700">未保存</span>
{/if}
</div>
{#if loading}
<div class="py-8 text-center text-sm text-surface-600">加载文件中…</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-[16rem_1fr]">
<div class="border border-surface-300 bg-surface-100">
<div class="flex items-center justify-between border-b border-surface-300 px-3 py-2">
<span class="text-xs font-medium text-surface-700">文件</span>
<button
type="button"
class="text-xs text-primary-700 hover:text-primary-900"
onclick={() => (showNewFile = !showNewFile)}
>
{showNewFile ? '取消' : '+ 新增'}
</button>
</div>
{#if showNewFile}
<div class="border-b border-surface-300 px-3 py-2">
<input
class="saas-input text-xs"
placeholder="如 reference.md"
bind:value={newFilePath}
onkeydown={(e) => {
if (e.key === 'Enter') addFile();
}}
/>
</div>
{/if}
<div class="max-h-80 overflow-y-auto">
{#each sortedFiles as f (f.path)}
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition hover:bg-surface-200
{selectedPath === f.path ? 'bg-primary-100 text-primary-900' : 'text-surface-800'}"
onclick={() => selectFile(f.path)}
>
<Icon name="file" class="h-3.5 w-3.5 shrink-0 opacity-60" />
<span class="truncate font-mono text-xs">{f.path}</span>
{#if f.path === 'SKILL.md'}
<span class="ml-auto text-[10px] text-primary-700">manifest</span>
{:else}
<span
class="ml-auto text-xs text-surface-500 hover:text-error-700"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
deleteFile(f.path);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
deleteFile(f.path);
}
}}
>
×
</span>
{/if}
</button>
{/each}
{#if files.length === 0}
<div class="px-3 py-4 text-center text-xs text-surface-600">暂无文件</div>
{/if}
</div>
</div>
<div>
{#if selectedFile}
<div class="mb-2 flex items-center gap-2">
<span class="font-mono text-xs text-surface-600">{selectedFile.path}</span>
</div>
<textarea
class="h-80 w-full resize-y border border-surface-300 bg-white p-3 font-mono text-xs text-surface-900 focus:border-primary-500 focus:outline-none"
bind:value={selectedFile.content}
oninput={() => (dirty = true)}
></textarea>
{:else}
<div class="flex h-80 items-center justify-center border border-surface-300 bg-surface-100 text-sm text-surface-600">
选择一个文件或新建文件
</div>
{/if}
</div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
{saving ? '保存中…' : '保存'}
</button>
{#if !skill.disabledAt}
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
禁用
</button>
{/if}
</div>
{/if}
</div>
+2
View File
@@ -10,6 +10,8 @@ export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'list_files', label: '列目录', group: '文件' }, { id: 'list_files', label: '列目录', group: '文件' },
{ id: 'search_files', label: '搜索', group: '文件' }, { id: 'search_files', label: '搜索', group: '文件' },
{ id: 'bash', label: 'Bash 命令', group: 'Shell' }, { id: 'bash', label: 'Bash 命令', group: 'Shell' },
{ id: 'web_fetch', label: 'WebFetch', group: '网络' },
{ id: 'web_search', label: 'WebSearch', group: '网络' },
{ id: 'cph_check', label: 'cph check', group: 'CPH' }, { id: 'cph_check', label: 'cph check', group: 'CPH' },
{ id: 'cph_build', label: 'cph build', group: 'CPH' }, { id: 'cph_build', label: 'cph build', group: 'CPH' },
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' }, { id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
+46
View File
@@ -29,6 +29,52 @@ export function fmtNum(n: number): string {
return n.toLocaleString(); return n.toLocaleString();
} }
const USAGE_KIND_LABELS: Record<string, string> = {
model_completion: '模型完成',
external_capability: '外部能力',
tool_proxy: '工具代理',
};
const METER_UNIT_LABELS: Record<string, string> = {
pages: '页',
audio_seconds: '音频秒',
invocations: '次调用',
};
export function usageKindLabel(kind: string): string {
return USAGE_KIND_LABELS[kind] ?? kind;
}
export function meterUnitLabel(unit: string | null | undefined): string {
if (!unit) return '—';
return METER_UNIT_LABELS[unit] ?? unit;
}
export function fmtQuantity(quantity: number | null | undefined, unit: string | null | undefined): string {
if (quantity === null || quantity === undefined) return '—';
const u = meterUnitLabel(unit);
return u === '—' ? fmtNum(quantity) : `${fmtNum(quantity)} ${u}`;
}
export function fmtTokens(input: number | null | undefined, output: number | null | undefined): string {
const hasIn = input !== null && input !== undefined;
const hasOut = output !== null && output !== undefined;
if (!hasIn && !hasOut) return '—';
return `${fmtNum(input ?? 0)} / ${fmtNum(output ?? 0)}`;
}
export function runStatusLabel(status: string): string {
const key = status.toUpperCase();
if (key === 'COMPLETED') return '完成';
if (key === 'FAILED') return '失败';
if (key === 'CANCELED') return '取消';
if (key === 'TIMED_OUT') return '超时';
if (key === 'RUNNING') return '运行中';
if (key === 'QUEUED') return '排队';
return status;
}
export function orgRoleLabel(role: string): string { export function orgRoleLabel(role: string): string {
const key = role.toUpperCase() as OrgRole; const key = role.toUpperCase() as OrgRole;
return ORG_ROLE_LABELS[key] ?? role; return ORG_ROLE_LABELS[key] ?? role;
+40
View File
@@ -0,0 +1,40 @@
import type { MeResponse, OrgMembership } from './api';
/** Alpha Silo host prefix: <slug>.educraft[.dev].… */
export function hostOrgSlug(hostname: string = typeof window !== 'undefined' ? window.location.hostname : ''): string | null {
const host = hostname.toLowerCase();
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
return m?.[1] ?? null;
}
export function isOrgAdmin(org: OrgMembership | null | undefined): boolean {
if (!org) return false;
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
/**
* Resolve the tenancy for this browser session.
* Prefers hostname slug (silo), then ?org=, then first admin membership, then first membership.
*/
export function resolveOrg(me: MeResponse | null | undefined, search: string = ''): OrgMembership | null {
if (!me || me.organizations.length === 0) return null;
const host = hostOrgSlug();
if (host) {
const byHost = me.organizations.find((o) => o.slug === host);
if (byHost) return byHost;
}
const q = new URLSearchParams(search).get('org')?.trim();
if (q) {
const byQuery = me.organizations.find((o) => o.slug === q);
if (byQuery) return byQuery;
}
const admin = me.organizations.find((o) => isOrgAdmin(o));
return admin ?? me.organizations[0] ?? null;
}
/** SPA paths no longer embed org slug (subdomain carries tenancy). */
export function adminPath(rest: string = ''): string {
const cleaned = rest.replace(/^\/+/, '');
return cleaned === '' ? '/admin' : `/admin/${cleaned}`;
}
+33 -2
View File
@@ -32,9 +32,40 @@ export async function loadSession(): Promise<void> {
} }
} }
/**
* Resolve org slug for org-scoped Feishu OAuth (`GET /auth/feishu/:orgSlug`).
* Unscoped `/auth/feishu` is disabled unless allowLegacyFeishuOAuth is on.
* Path no longer carries tenancy: prefer hostname silo slug, then ?org=.
*/
export function resolveLoginOrgSlug(): string | null {
const q = new URLSearchParams(window.location.search).get('org');
if (q && q.trim() !== '') return q.trim();
// Alpha Silo public host: <slug>.educraft.paradigm-edu.net (or educraft-dev)
const host = window.location.hostname.toLowerCase();
const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./);
if (m?.[1]) return m[1];
// Legacy path while old bookmarks still land briefly before redirect.
const path = window.location.pathname.split('/').filter(Boolean);
if (path[0] === 'admin' && path[1] === 'org' && path[2]) {
return decodeURIComponent(path[2]);
}
return null;
}
export function redirectToLogin(): void { export function redirectToLogin(): void {
const ret = encodeURIComponent(window.location.pathname + window.location.hash); if (window.location.pathname === '/admin/login') return;
window.location.href = `/auth/feishu?returnTo=${ret}`; const ret = encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash,
);
const slug = resolveLoginOrgSlug();
if (slug === null) {
// Need an org slug for scoped OAuth — backend login helper can prompt.
window.location.href = `/admin/login?returnTo=${ret}`;
return;
}
window.location.href = `/auth/feishu/${encodeURIComponent(slug)}?returnTo=${ret}`;
} }
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
+55 -80
View File
@@ -5,6 +5,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { session, loadSession, logout, redirectToLogin } from '$lib/session'; import { session, loadSession, logout, redirectToLogin } from '$lib/session';
import type { OrgMembership } from '$lib/api'; import type { OrgMembership } from '$lib/api';
import { adminPath, isOrgAdmin, resolveOrg } from '$lib/org';
import { orgRoleLabel } from '$lib/format'; import { orgRoleLabel } from '$lib/format';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.svelte'; import ToastHost from '$lib/components/ToastHost.svelte';
@@ -20,74 +21,61 @@
const navItems = [ const navItems = [
{ key: 'overview', label: '概览', icon: 'overview' as const }, { key: 'overview', label: '概览', icon: 'overview' as const },
{ key: 'usage', label: '用量', icon: 'overview' as const },
{ key: 'members', label: '成员', icon: 'members' as const }, { key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const }, { key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const }, { key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'capacity', label: '容量', icon: 'overview' as const }, { key: 'capacity', label: '容量', icon: 'overview' as const },
{ key: 'provider', label: '供应方', icon: 'provider' as const }, { key: 'provider', label: '供应方', icon: 'provider' as const },
{ key: 'skills', label: '技能', icon: 'roles' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'feishu', label: '飞书', icon: 'feishu' as const }, { key: 'feishu', label: '飞书', icon: 'feishu' as const },
{ key: 'capabilities', label: '能力', icon: 'provider' as const },
]; ];
function isAdmin(org: OrgMembership): boolean {
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
function orgSlugFromPath(): string | null {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) {
return decodeURIComponent(parts[2]);
}
return null;
}
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
return parts[0] === 'admin' && parts[1] === 'org' && parts[3] === 'projects';
}
function memberships(): OrgMembership[] { function memberships(): OrgMembership[] {
return $session.me?.organizations ?? []; return $session.me?.organizations ?? [];
} }
function adminOrgs(): OrgMembership[] { function adminOrgs(): OrgMembership[] {
return memberships().filter(isAdmin); return memberships().filter((o) => isOrgAdmin(o));
} }
function currentOrg(): OrgMembership | null { function currentOrg(): OrgMembership | null {
const slug = orgSlugFromPath(); return resolveOrg($session.me, page.url.search);
if (!slug) return null;
return memberships().find((o) => o.slug === slug) ?? null;
} }
function pickHomeOrg(): OrgMembership | null { function isOnProjectRoute(): boolean {
const admin = adminOrgs()[0]; const parts = page.url.pathname.split('/').filter(Boolean);
if (admin) return admin; // /admin/projects or /admin/projects/:id
return memberships()[0] ?? null; return parts[0] === 'admin' && parts[1] === 'projects';
} }
function activeKey(): string { function activeKey(): string {
const parts = page.url.pathname.split('/').filter(Boolean); const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return ''; if (parts[0] !== 'admin') return '';
return parts[3] ?? 'overview'; // /admin → overview; /admin/usage → usage; /admin/projects/x → projects
return parts[1] ?? 'overview';
} }
function navHref(key: string): string { function navHref(key: string): string {
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug; if (key === 'overview') return adminPath();
if (!slug) return '/'; return adminPath(key);
if (key === 'overview') return `/admin/org/${slug}`;
return `/admin/org/${slug}/${key}`;
} }
function pageTitle(): string { function pageTitle(): string {
const key = activeKey(); const key = activeKey();
if (key === 'overview' || key === '') return '概览'; if (key === 'overview' || key === '') return '概览';
if (key === 'sessions') return '会话详情';
return navItems.find((i) => i.key === key)?.label ?? '管理后台'; return navItems.find((i) => i.key === key)?.label ?? '管理后台';
} }
function switchOrg(nextSlug: string) { function switchOrg(nextSlug: string) {
if (!nextSlug || nextSlug === currentOrg()?.slug) return; if (!nextSlug || nextSlug === currentOrg()?.slug) return;
void goto(`/admin/org/${nextSlug}`); // Path is tenancy-free; keep optional ?org= for local multi-membership debugging.
const url = new URL(page.url.href);
url.searchParams.set('org', nextSlug);
void goto(`${url.pathname}${url.search}`, { replaceState: true });
} }
function handleLogout(e: Event) { function handleLogout(e: Event) {
@@ -110,33 +98,23 @@
$effect(() => { $effect(() => {
if ($session.loading || !$session.me) return; if ($session.loading || !$session.me) return;
const slug = orgSlugFromPath(); const path = page.url.pathname;
const matched = slug ? memberships().find((o) => o.slug === slug) : null; // Legacy /admin/org/:slug… is handled by admin/org/[...path] page.
// Org admins: route to their first admin org if none matched as admin. const org = currentOrg();
const admins = adminOrgs(); const admins = adminOrgs();
if (matched && isAdmin(matched)) {
if (org && isOrgAdmin(org)) {
redirecting = false; redirecting = false;
return; return;
} }
if (!matched && admins.length > 0) {
const target = `/admin/org/${admins[0].slug}`;
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
return;
}
// Members (non-admin): project pages are open to project MANAGE holders; // Member only: allow project routes; bounce admin-only surfaces to projects.
// the org overview and other admin-only surfaces are not for them. if (org && !isOrgAdmin(org)) {
if (matched && !isAdmin(matched)) {
redirecting = false; redirecting = false;
const parts = page.url.pathname.split('/').filter(Boolean); if (!isOnProjectRoute()) {
const onOverview = parts.length === 3; // /admin/org/:slug const target = adminPath('projects');
if (onOverview) { if (path !== target) {
const target = `/admin/org/${matched.slug}/projects`;
if (page.url.pathname !== target) {
redirecting = true; redirecting = true;
void goto(target, { replaceState: true }); void goto(target, { replaceState: true });
} }
@@ -144,12 +122,11 @@
return; return;
} }
// No matched org and no admin orgs: route a member to their first org's // No org resolved but user has memberships → land on first org's projects/overview via resolveOrg next tick
// projects page so they can reach project MANAGE surfaces. if (!org && memberships().length > 0) {
if (!matched && memberships().length > 0) { const home = admins[0] ?? memberships()[0]!;
const home = memberships()[0]; const target = isOrgAdmin(home) ? adminPath() : adminPath('projects');
const target = `/admin/org/${home.slug}/projects`; if (path !== target && !path.startsWith(`${target}/`)) {
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
redirecting = true; redirecting = true;
void goto(target, { replaceState: true }); void goto(target, { replaceState: true });
} }
@@ -170,14 +147,14 @@
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path> ></path>
</svg> </svg>
<p class="text-sm">{redirecting ? '正在进入组织…' : '正在加载会话…'}</p> <p class="text-sm">加载中…</p>
</div> </div>
</div> </div>
{:else if $session.error} {:else if $session.error}
<div class="saas-status-panel"> <div class="saas-status-panel">
<div class="saas-status-card"> <div class="saas-status-card">
<div <div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-error-300 bg-error-100 text-error-700 font-bold" class="mx-auto mb-3 flex h-12 w-12 items-center justify-center border border-error-200 bg-error-50 text-error-700"
> >
! !
</div> </div>
@@ -190,7 +167,7 @@
<div class="saas-status-panel"> <div class="saas-status-panel">
<div class="saas-status-card"> <div class="saas-status-card">
<div <div
class="mx-auto mb-5 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-white font-bold" class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-sm font-bold text-white"
> >
CPH CPH
</div> </div>
@@ -199,7 +176,7 @@
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button> <button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
</div> </div>
</div> </div>
{:else if currentOrg() && isAdmin(currentOrg()!)} {:else if currentOrg() && isOrgAdmin(currentOrg()!)}
{@const org = currentOrg()!} {@const org = currentOrg()!}
{@const me = $session.me!} {@const me = $session.me!}
<div class="saas-shell"> <div class="saas-shell">
@@ -224,17 +201,19 @@
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-sm font-semibold text-surface-900">组织后台</div> <div class="truncate text-sm font-semibold text-surface-900">组织后台</div>
<div class="truncate text-xs text-surface-600">Curriculum Hub</div> <div class="truncate text-xs text-surface-600">{org.name}</div>
</div> </div>
</div> </div>
<div class="px-3 pb-3"> {#if me.organizations.length > 1}
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700"> <div class="px-3 pb-3">
<Icon name="org" class="h-3.5 w-3.5" /> <div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
组织 <Icon name="org" class="h-3.5 w-3.5" />
组织
</div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
</div> </div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} /> {/if}
</div>
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3"> <nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-600">工作台</p> <p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-600">工作台</p>
@@ -304,17 +283,13 @@
</main> </main>
</div> </div>
</div> </div>
{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()} {:else if currentOrg() && !isOrgAdmin(currentOrg()!) && isOnProjectRoute()}
{@const org = currentOrg()!} {@const org = currentOrg()!}
{@const me = $session.me!} {@const me = $session.me!}
<div class="saas-shell"> <div class="saas-shell">
<div class="saas-main"> <div class="saas-main">
<header class="saas-topbar"> <header class="saas-topbar">
<a <a href={adminPath('projects')} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
href={`/admin/org/${org.slug}/projects`}
class="saas-btn-ghost px-2!"
aria-label="返回项目列表"
>
<Icon name="menu" class="h-5 w-5" /> <Icon name="menu" class="h-5 w-5" />
</a> </a>
<div class="min-w-0"> <div class="min-w-0">
@@ -343,7 +318,7 @@
</main> </main>
</div> </div>
</div> </div>
{:else if currentOrg() && !isAdmin(currentOrg()!)} {:else if currentOrg() && !isOrgAdmin(currentOrg()!)}
{@const denied = currentOrg()!} {@const denied = currentOrg()!}
<div class="saas-status-panel"> <div class="saas-status-panel">
<div class="saas-status-card"> <div class="saas-status-card">
@@ -352,7 +327,7 @@
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是 组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。 <span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。
</p> </p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>查看我的项目</a> <a class="saas-btn-primary" href={adminPath('projects')}>查看我的项目</a>
{#if memberships().length > 1} {#if memberships().length > 1}
<p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p> <p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p>
<div class="mb-4"> <div class="mb-4">
@@ -363,14 +338,14 @@
</div> </div>
</div> </div>
{:else if memberships().length > 0} {:else if memberships().length > 0}
{@const denied = pickHomeOrg()!} {@const denied = resolveOrg($session.me) ?? memberships()[0]!}
<div class="saas-status-panel"> <div class="saas-status-panel">
<div class="saas-status-card"> <div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2> <h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-700"> <p class="mb-5 text-sm text-surface-700">
即将进入 <strong>{denied.name}</strong>/{denied.slug})的项目。 即将进入 <strong>{denied.name}</strong>/{denied.slug})的项目。
</p> </p>
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>立即进入</a> <a class="saas-btn-primary" href={adminPath('projects')}>立即进入</a>
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button> <button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -11,7 +11,7 @@
return r === 'OWNER' || r === 'ADMIN'; return r === 'OWNER' || r === 'ADMIN';
}); });
const target = admin ?? me.organizations[0]; const target = admin ?? me.organizations[0];
return target ? `/admin/org/${target.slug}` : null; return target ? `/admin` : null;
} }
onMount(() => { onMount(() => {
@@ -2,6 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type OrgMembership } from '$lib/api'; import { api, type OrgMembership } from '$lib/api';
import { session } from '$lib/session'; import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtCost, fmtNum, orgRoleLabel } from '$lib/format'; import { fmtCost, fmtNum, orgRoleLabel } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte'; import StatCard from '$lib/components/StatCard.svelte';
@@ -10,7 +11,8 @@
import SwitchControl from '$lib/components/SwitchControl.svelte'; import SwitchControl from '$lib/components/SwitchControl.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
let orgSlug = $derived(page.params.slug ?? ''); const orgFromSession = $derived(resolveOrg($session.me, page.url.search));
let orgSlug = $derived(orgFromSession?.slug ?? '');
let org = $derived($session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined); let org = $derived($session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined);
let settings = $state<{ membersCanCreateProjects: boolean } | null>(null); let settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
@@ -94,19 +96,56 @@
<div class="mb-4 flex items-end justify-between gap-3"> <div class="mb-4 flex items-end justify-between gap-3">
<div> <div>
<h2 class="saas-section-title">用量概览</h2> <h2 class="saas-section-title">用量概览</h2>
<p class="saas-muted">全组织智能体运行汇总</p> <p class="saas-muted">totals 含全部 UsageFact;分账明细见用量页。</p>
</div> </div>
<a class="saas-btn-secondary py-1.5! text-sm" href={`/admin/usage`}>完整用量报告</a>
</div> </div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} /> <StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} /> <StatCard label="有成本运行" value={fmtNum(usage.totals.runsWithCost)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} /> <StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} hint="未知 $0" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} /> <StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} /> <StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} /> <StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
</div> </div>
{#if usage.breakdown.length > 0}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-surface-800">消费来源(Top</h3>
<p class="saas-muted text-xs">模型完成 vs 外部能力,按成本降序前 5</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>查看全部分账</a>
</div>
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each [...usage.breakdown].sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1)).slice(0, 5) as row}
<tr>
<td class="text-sm">{row.kind === 'external_capability' ? '外部能力' : row.kind === 'model_completion' ? '模型完成' : row.kind}</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<div class="saas-card overflow-hidden"> <div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3"> <div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3> <h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
@@ -129,7 +168,11 @@
<tbody> <tbody>
{#each usage.projects as p} {#each usage.projects as p}
<tr> <tr>
<td class="font-medium">{p.projectName}</td> <td class="font-medium">
<a class="hover:text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
{p.projectName}
</a>
</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td> <td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td> <td class="tabular-nums text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td> <td class="tabular-nums">{fmtCost(p.costUsd)}</td>
@@ -0,0 +1,205 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapabilityConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const KNOWN_CAPABILITIES = [
{ id: 'pdf_to_md_bundle', label: 'PDF → Markdown', description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)' },
{ id: 'audio_video_to_text', label: '音视频 → 文本', description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)' },
] as const;
let connections = $state<Map<string, CapabilityConnection>>(new Map());
let loading = $state(true);
let error = $state<string | null>(null);
let editingCap = $state<string | null>(null);
let accessKeyId = $state('');
let accessKeySecret = $state('');
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
let saving = $state(false);
let disabling = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
const res = await api.capabilityConnections(slug);
connections = new Map(res.connections.map((c) => [c.capabilityId, c]));
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function startEdit(capId: string) {
editingCap = capId;
accessKeyId = '';
accessKeySecret = '';
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
}
function cancelEdit() {
editingCap = null;
}
async function save(capId: string) {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
return;
}
saving = true;
try {
const result = await api.rotateCapabilityConnection(slug, capId, {
accessKeyId: accessKeyId.trim(),
accessKeySecret: accessKeySecret.trim(),
endpoint: endpoint.trim(),
});
connections.set(capId, result);
connections = new Map(connections);
editingCap = null;
toastSuccess('能力凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable(capId: string) {
if (!confirm('停用后该能力将不可用,确定停用?')) return;
disabling = capId;
try {
const result = await api.disableCapabilityConnection(slug, capId);
connections.set(capId, result);
connections = new Map(connections);
toastSuccess('已停用能力连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = null;
}
}
function statusBadge(status: string): string {
if (status === 'ACTIVE') return 'saas-badge-primary';
if (status === 'DISABLED') return 'saas-badge-error';
return 'saas-badge-muted';
}
function statusLabel(status: string): string {
if (status === 'ACTIVE') return '已启用';
if (status === 'DISABLED') return '已停用';
return '草稿';
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="外部能力"
description="管理文档/媒体转换服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="space-y-6">
{#each KNOWN_CAPABILITIES as cap}
{@const conn = connections.get(cap.id)}
<div class="saas-card-pad">
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h3 class="saas-section-title">{cap.label}</h3>
{#if conn}
<span class={statusBadge(conn.status)}>{statusLabel(conn.status)}</span>
{:else}
<span class="saas-badge-muted">未配置</span>
{/if}
</div>
<p class="saas-muted mt-1 text-sm">{cap.description}</p>
<p class="mt-0.5 font-mono text-xs text-surface-500">{cap.id}</p>
</div>
<div class="flex items-center gap-2">
{#if conn?.status === 'ACTIVE'}
<button
class="saas-btn-ghost text-sm"
onclick={() => disable(cap.id)}
disabled={disabling === cap.id}
>
{disabling === cap.id ? '停用中…' : '停用'}
</button>
{/if}
<button
class="saas-btn-primary text-sm"
onclick={() => startEdit(cap.id)}
disabled={editingCap === cap.id}
>
{conn ? '轮换凭据' : '配置凭据'}
</button>
</div>
</div>
{#if conn}
<dl class="space-y-1.5 text-sm text-surface-700">
<div class="flex justify-between">
<dt class="text-surface-500">版本</dt>
<dd class="font-mono">{conn.activeVersion ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">密钥 ID</dt>
<dd class="font-mono text-xs">{conn.keyId ?? '—'}</dd>
</div>
<div class="flex justify-between">
<dt class="text-surface-500">更新时间</dt>
<dd>{fmtDate(conn.updatedAt)}</dd>
</div>
</dl>
{/if}
{#if editingCap === cap.id}
<div class="mt-4 border-t border-surface-100 pt-4">
<p class="saas-muted mb-3 text-sm">
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
</p>
<div class="grid gap-4">
<div>
<Label.Root class="saas-label" for="ak-id-{cap.id}">AccessKey ID</Label.Root>
<input id="ak-id-{cap.id}" class="saas-input font-mono text-sm" bind:value={accessKeyId} />
</div>
<div>
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root>
<input id="ak-secret-{cap.id}" class="saas-input" type="password" bind:value={accessKeySecret} />
</div>
<div>
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root>
<input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
</div>
</div>
<div class="mt-4 flex items-center justify-end gap-3">
<button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
@@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } from '$lib/api'; import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte'; import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
// Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.). // Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.).
const DIMENSION_LABELS: Record<CapacityDimension, string> = { const DIMENSION_LABELS: Record<CapacityDimension, string> = {
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type FeishuApplicationConnection } from '$lib/api'; import { api, type FeishuApplicationConnection } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import { Label } from 'bits-ui'; import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,7 +10,8 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let connection = $state<FeishuApplicationConnection | null>(null); let connection = $state<FeishuApplicationConnection | null>(null);
let loading = $state(true); let loading = $state(true);
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type OrgMember } from '$lib/api'; import { api, type OrgMember } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants'; import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -10,7 +12,8 @@
import SelectField from '$lib/components/SelectField.svelte'; import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const roleItems = ORG_ROLES.map((r) => ({ value: r, label: ORG_ROLE_LABELS[r] })); const roleItems = ORG_ROLES.map((r) => ({ value: r, label: ORG_ROLE_LABELS[r] }));
const permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / '); const permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / ');
@@ -0,0 +1,23 @@
<script lang="ts">
/**
* Legacy bookmarks: /admin/org/:slug[/...] → /admin[/...]
* Tenancy lives on the silo host, not the path.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
onMount(() => {
const raw = page.params.path ?? '';
const segments = raw.split('/').filter(Boolean);
// Drop the old org slug (first segment) when present.
const rest = segments.length > 0 ? segments.slice(1).join('/') : '';
const target = rest === '' ? '/admin' : `/admin/${rest}`;
const q = page.url.search;
void goto(`${target}${q}`, { replaceState: true });
});
</script>
<div class="saas-status-panel">
<p class="text-sm text-surface-600">正在重定向到新地址…</p>
</div>
@@ -2,6 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api'; import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
import { session } from '$lib/session'; import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import FolderTree from '$lib/components/FolderTree.svelte'; import FolderTree from '$lib/components/FolderTree.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte'; import LoadingState from '$lib/components/LoadingState.svelte';
@@ -13,10 +14,8 @@
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const org = $derived( const slug = $derived(org?.slug ?? '');
($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null,
);
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN')); const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
let data = $state<ExplorerData | null>(null); let data = $state<ExplorerData | null>(null);
@@ -32,6 +31,18 @@
let projectName = $state(''); let projectName = $state('');
let projectFolder = $state(''); let projectFolder = $state('');
function openFolderModal(parentId: string) {
folderName = '';
folderParent = parentId;
showFolderModal = true;
}
function openProjectModal(folderId: string) {
projectName = '';
projectFolder = folderId;
showProjectModal = true;
}
async function load() { async function load() {
loading = true; loading = true;
error = null; error = null;
@@ -77,7 +88,7 @@
projectName = ''; projectName = '';
projectFolder = ''; projectFolder = '';
showProjectModal = false; showProjectModal = false;
window.location.href = `/admin/org/${slug}/projects/${res.id}`; window.location.href = `/admin/projects/${res.id}`;
} catch (err) { } catch (err) {
toastError(err instanceof Error ? err.message : String(err)); toastError(err instanceof Error ? err.message : String(err));
} }
@@ -113,8 +124,8 @@
{:else if isAdmin && data} {:else if isAdmin && data}
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。"> <PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
{#snippet actions()} {#snippet actions()}
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button> <button class="saas-btn-secondary" onclick={() => openFolderModal('')}>新建文件夹</button>
<button class="saas-btn-primary" onclick={() => (showProjectModal = true)}>新建项目</button> <button class="saas-btn-primary" onclick={() => openProjectModal('')}>新建项目</button>
{/snippet} {/snippet}
</PageHeader> </PageHeader>
@@ -122,7 +133,14 @@
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0} {#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" /> <EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
{:else} {:else}
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} /> <FolderTree
folders={data.folders}
projects={data.projects}
parentId={null}
{slug}
onCreateFolder={openFolderModal}
onCreateProject={openProjectModal}
/>
{/if} {/if}
</div> </div>
@@ -190,7 +208,7 @@
</thead> </thead>
<tbody> <tbody>
{#each myProjects as p} {#each myProjects as p}
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}> <tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/projects/${p.id}`)}>
<td class="font-medium">{p.name}</td> <td class="font-medium">{p.name}</td>
<td class="font-mono text-xs">{p.binding ? `群 ${p.binding.chatId}` : '—'}</td> <td class="font-mono text-xs">{p.binding ? `群 ${p.binding.chatId}` : '—'}</td>
<td class="text-surface-700">{fmtDate(p.createdAt)}</td> <td class="text-surface-700">{fmtDate(p.createdAt)}</td>
@@ -7,17 +7,30 @@
type TeamRow, type TeamRow,
type SessionSummary, type SessionSummary,
type ExplorerData, type ExplorerData,
type ProjectUsageReport,
} from '$lib/api'; } from '$lib/api';
import { fmtDate, permissionRoleLabel } from '$lib/format'; import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDate,
fmtNum,
fmtQuantity,
fmtTokens,
permissionRoleLabel,
usageKindLabel,
} from '$lib/format';
import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } from '$lib/constants'; import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } from '$lib/constants';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte'; import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import SelectField from '$lib/components/SelectField.svelte'; import SelectField from '$lib/components/SelectField.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const projectId = $derived(page.params.projectId ?? ''); const projectId = $derived(page.params.projectId ?? '');
const roleItems = PERMISSION_ROLES.map((r) => ({ value: r, label: PERMISSION_ROLE_LABELS[r] })); const roleItems = PERMISSION_ROLES.map((r) => ({ value: r, label: PERMISSION_ROLE_LABELS[r] }));
const roleChain = `${PERMISSION_ROLE_LABELS.READ} ⊂ ${PERMISSION_ROLE_LABELS.EDIT} ⊂ ${PERMISSION_ROLE_LABELS.MANAGE}`; const roleChain = `${PERMISSION_ROLE_LABELS.READ} ⊂ ${PERMISSION_ROLE_LABELS.EDIT} ⊂ ${PERMISSION_ROLE_LABELS.MANAGE}`;
@@ -25,6 +38,7 @@
let proj = $state<ProjectDetail | null>(null); let proj = $state<ProjectDetail | null>(null);
let access = $state<TeamAccessEntry[]>([]); let access = $state<TeamAccessEntry[]>([]);
let sessions = $state<SessionSummary[]>([]); let sessions = $state<SessionSummary[]>([]);
let projectUsage = $state<ProjectUsageReport | null>(null);
let teams = $state<TeamRow[]>([]); let teams = $state<TeamRow[]>([]);
let explorer = $state<ExplorerData | null>(null); let explorer = $state<ExplorerData | null>(null);
let loading = $state(true); let loading = $state(true);
@@ -48,14 +62,18 @@
// Team list is needed for grant UI whenever the actor has project MANAGE // Team list is needed for grant UI whenever the actor has project MANAGE
// (org admin or member). Sessions/explorer stay org-admin oversight only. // (org admin or member). Sessions/explorer stay org-admin oversight only.
const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true; const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true;
const [s, t, e] = await Promise.all([ const [s, t, e, u] = await Promise.all([
p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }), p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }),
needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }), needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }),
p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null), p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null),
p.actorIsOrgAdmin
? api.projectUsage(slug, projectId)
: Promise.resolve(null as ProjectUsageReport | null),
]); ]);
sessions = s.sessions; sessions = s.sessions;
teams = t.teams; teams = t.teams;
explorer = e; explorer = e;
projectUsage = u;
if (p.actorIsOrgAdmin) { if (p.actorIsOrgAdmin) {
moveFolder = p.folderId ?? ''; moveFolder = p.folderId ?? '';
} }
@@ -94,7 +112,7 @@
if (!confirm(`归档项目 ${proj?.name}?`)) return; if (!confirm(`归档项目 ${proj?.name}?`)) return;
try { try {
await api.archiveProject(slug, projectId); await api.archiveProject(slug, projectId);
window.location.href = `/admin/org/${slug}/projects`; window.location.href = `/admin/projects`;
} catch (err) { } catch (err) {
toastError(err instanceof Error ? err.message : String(err)); toastError(err instanceof Error ? err.message : String(err));
} }
@@ -155,10 +173,11 @@
{:else if proj} {:else if proj}
<div class="mb-2"> <div class="mb-2">
<a <a
href={`/admin/org/${slug}/projects`} href={`/admin/projects`}
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600" class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-600"
> >
← 返回项目列表 <Icon name="arrow-left" class="h-4 w-4" />
返回项目列表
</a> </a>
</div> </div>
@@ -267,9 +286,67 @@
</div> </div>
{#if actorIsOrgAdmin} {#if actorIsOrgAdmin}
{#if projectUsage}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">项目用量分账</h3>
<p class="saas-muted mt-0.5 text-xs">
{fmtNum(projectUsage.runCount)} 次运行 · 成本 {fmtCost(projectUsage.costUsd)} · tokens
{fmtTokens(projectUsage.inputTokens, projectUsage.outputTokens)}
</p>
</div>
<a class="text-sm text-primary-700 hover:underline" href={`/admin/usage`}>组织报告</a>
</div>
{#if projectUsage.breakdown.length === 0}
<div class="px-5 py-4 text-sm text-surface-600">尚无 UsageFact。</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>计量</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each projectUsage.breakdown as row}
<tr>
<td>
<span
class={row.kind === 'external_capability' ? 'saas-badge-primary' : 'saas-badge-success'}
>
{usageKindLabel(row.kind)}
</span>
</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{row.capabilityId ?? row.model ?? '—'}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums text-xs">
{#if row.unit}
{fmtQuantity(row.quantity, row.unit)}
{:else}
{fmtTokens(row.inputTokens, row.outputTokens)}
{/if}
</td>
<td class="tabular-nums">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
<div class="saas-card overflow-hidden"> <div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3"> <div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold">智能体会话</h3> <h3 class="text-sm font-semibold">智能体会话</h3>
<p class="saas-muted mt-0.5 text-xs">点进会话可查看每次 run 的 UsageFact 分账(模型 / 外部能力)。</p>
</div> </div>
{#if sessions.length === 0} {#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" /> <EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
@@ -281,6 +358,7 @@
<th>模型</th> <th>模型</th>
<th>运行次数</th> <th>运行次数</th>
<th>更新</th> <th>更新</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -290,6 +368,14 @@
<td class="font-mono text-xs">{s.model}</td> <td class="font-mono text-xs">{s.model}</td>
<td class="tabular-nums">{s.runCount}</td> <td class="tabular-nums">{s.runCount}</td>
<td class="text-surface-700">{fmtDate(s.updatedAt)}</td> <td class="text-surface-700">{fmtDate(s.updatedAt)}</td>
<td class="text-right">
<a
class="text-sm text-primary-700 hover:underline"
href={`/admin/sessions/${s.id}`}
>
详情
</a>
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type ProviderConnectionRow } from '$lib/api'; import { api, type ProviderConnectionRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate, providerModeLabel } from '$lib/format'; import { fmtDate, providerModeLabel } from '$lib/format';
import { Label } from 'bits-ui'; import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,7 +10,8 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let connections = $state<ProviderConnectionRow[]>([]); let connections = $state<ProviderConnectionRow[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -0,0 +1,132 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let roles = $state<AgentRoleRow[]>([]);
let models = $state<AgentModelRow[]>([]);
let skills = $state<AgentSkillRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newRoleId = $state('');
let newLabel = $state('');
let adding = $state(false);
async function load() {
loading = true;
error = null;
try {
const [r, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]);
roles = r.roles;
skills = s.skills;
// Model fetch hits the provider API and may fail or be slow; load it
// independently so roles remain editable even without a model list.
models = [];
api.agentModels(slug)
.then((m) => { models = m.models; })
.catch((err) => { toastError(`模型列表加载失败:${err instanceof Error ? err.message : String(err)}`); });
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function add() {
const roleId = newRoleId.trim();
const label = newLabel.trim();
if (roleId === '' || label === '') {
toastError('角色 ID 与显示名均为必填');
return;
}
if (roles.some((r) => r.roleId === roleId)) {
toastError(`角色 ID 已存在:${roleId}`);
return;
}
adding = true;
try {
const created = await api.upsertAgentRole(slug, roleId, { label });
roles = [...roles, created];
newRoleId = '';
newLabel = '';
toastSuccess('角色已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
function onRoleUpdated(updated: AgentRoleRow) {
roles = roles.map((x) => (x.roleId === updated.roleId ? { ...updated, skillNames: x.skillNames } : x));
if (updated.isDefault) {
roles = roles.map((x) => (x.roleId === updated.roleId ? x : { ...x, isDefault: false }));
}
}
function onRoleSkillsChanged(roleId: string, skillNames: string[]) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="角色"
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="角色 ID(如 draft"
bind:value={newRoleId}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<input
class="saas-input"
placeholder="显示名(如 草稿)"
bind:value={newLabel}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头。</p>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
</div>
{:else}
<div class="space-y-4">
{#each roles as r (r.roleId)}
<RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} />
{/each}
</div>
{/if}
{/if}
@@ -0,0 +1,238 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type SessionDetail, type SessionRunRow, type UsageFactRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDate,
fmtNum,
fmtQuantity,
fmtTokens,
runStatusLabel,
usageKindLabel,
} from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Icon from '$lib/components/Icon.svelte';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const sessionId = $derived(page.params.sessionId ?? '');
let detail = $state<SessionDetail | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let expandedRunId = $state<string | null>(null);
async function load() {
if (!slug || !sessionId) return;
loading = true;
error = null;
try {
detail = await api.session(slug, sessionId);
if (detail.runs.length > 0) {
expandedRunId = detail.runs[0]!.id;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function statusClass(status: string): string {
const key = status.toUpperCase();
if (key === 'COMPLETED') return 'saas-badge-success';
if (key === 'FAILED' || key === 'TIMED_OUT' || key === 'CANCELED') return 'saas-badge-error';
return 'saas-badge-primary';
}
function factMeter(f: UsageFactRow): string {
if (f.unit) return fmtQuantity(f.quantity, f.unit);
if (f.inputTokens !== null || f.outputTokens !== null) {
return fmtTokens(f.inputTokens, f.outputTokens);
}
return '—';
}
function factSource(f: UsageFactRow): string {
if (f.capabilityId) return f.capabilityId;
if (f.model) return f.model;
return '—';
}
function runCostHint(run: SessionRunRow): string {
const factCost = run.usageFacts.reduce<number | null>((acc, f) => {
if (f.costUsd === null) return acc;
return (acc ?? 0) + f.costUsd;
}, null);
const cache = run.costUsd;
if (factCost !== null && cache !== null && Math.abs(factCost - cache) > 1e-9) {
return `运行缓存 ${fmtCost(cache)};事实合计 ${fmtCost(factCost)}(缓存可能未含外部能力)`;
}
if (factCost !== null) return `事实合计 ${fmtCost(factCost)}`;
if (cache !== null) return `运行缓存 ${fmtCost(cache)}`;
return '成本未知';
}
function toggleRun(id: string) {
expandedRunId = expandedRunId === id ? null : id;
}
$effect(() => {
if (slug && sessionId) void load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if detail}
<div class="mb-2">
<a
class="inline-flex items-center gap-1 text-sm text-surface-700 hover:text-primary-700"
href={`/admin/projects/${detail.project.id}`}
>
<Icon name="arrow-left" class="h-4 w-4" />
返回项目 {detail.project.name}
</a>
</div>
<PageHeader
title={detail.title?.trim() || '未命名会话'}
description={`${detail.provider} · ${detail.roleId} · ${detail.model}`}
/>
<div class="saas-card-pad mb-6">
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-surface-600">会话 ID</dt>
<dd class="mt-0.5 break-all font-mono text-xs">{detail.id}</dd>
</div>
<div>
<dt class="text-surface-600">项目</dt>
<dd class="mt-0.5">
<a class="text-primary-700 hover:underline" href={`/admin/projects/${detail.project.id}`}>
{detail.project.name}
</a>
</dd>
</div>
<div>
<dt class="text-surface-600">创建 / 更新</dt>
<dd class="mt-0.5 text-surface-800">{fmtDate(detail.createdAt)} · {fmtDate(detail.updatedAt)}</dd>
</div>
{#if detail.archivedAt}
<div>
<dt class="text-surface-600">已归档</dt>
<dd class="mt-0.5">{fmtDate(detail.archivedAt)}</dd>
</div>
{/if}
<div>
<dt class="text-surface-600">运行数</dt>
<dd class="mt-0.5 tabular-nums">{fmtNum(detail.runs.length)}</dd>
</div>
</dl>
</div>
<div class="mb-3">
<h2 class="saas-section-title">运行与计费事实</h2>
<p class="saas-muted">每条 UsageFact 是一次可计费消费;外部能力与模型完成分开列出。</p>
</div>
{#if detail.runs.length === 0}
<div class="saas-card">
<EmptyState title="尚无运行" description="此会话还没有 Agent run。" />
</div>
{:else}
<div class="space-y-3">
{#each detail.runs as run (run.id)}
{@const open = expandedRunId === run.id}
<div class="saas-card overflow-hidden">
<button
type="button"
class="flex w-full items-start justify-between gap-3 px-5 py-4 text-left hover:bg-surface-50"
onclick={() => toggleRun(run.id)}
>
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class={statusClass(run.status)}>{runStatusLabel(run.status)}</span>
<span class="font-mono text-xs text-surface-700">{run.provider} / {run.model}</span>
<span class="font-mono text-[11px] text-surface-500">{run.id}</span>
</div>
<div class="text-xs text-surface-700">
{fmtDate(run.startedAt)}
{#if run.finishedAt}
{fmtDate(run.finishedAt)}
{/if}
</div>
<div class="text-xs text-surface-600">{runCostHint(run)}</div>
{#if run.error}
<div class="text-xs text-error-700">{run.error}</div>
{/if}
</div>
<div class="shrink-0 text-right text-sm">
<div class="tabular-nums text-surface-800">{fmtTokens(run.inputTokens, run.outputTokens)}</div>
<div class="tabular-nums font-medium">{fmtCost(run.costUsd)}</div>
<div class="mt-1 text-[11px] text-surface-600">{open ? '收起事实' : `${run.usageFacts.length} 条事实`}</div>
</div>
</button>
{#if open}
<div class="border-t border-surface-200">
{#if run.usageFacts.length === 0}
<div class="px-5 py-4">
<p class="text-sm text-surface-600">此 run 没有 UsageFact(可能尚未结束或未记费)。</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>计量</th>
<th>成本</th>
<th>来源</th>
<th>关联 ID</th>
</tr>
</thead>
<tbody>
{#each run.usageFacts as fact}
<tr>
<td class="whitespace-nowrap text-xs text-surface-700">{fmtDate(fact.occurredAt)}</td>
<td>
<span
class={fact.kind === 'external_capability'
? 'saas-badge-primary'
: 'saas-badge-success'}
>
{usageKindLabel(fact.kind)}
</span>
</td>
<td class="font-mono text-xs">{fact.provider}</td>
<td class="font-mono text-xs">{factSource(fact)}</td>
<td class="tabular-nums text-xs">{factMeter(fact)}</td>
<td class="tabular-nums">{fmtCost(fact.costUsd)}</td>
<td class="font-mono text-[11px] text-surface-600">{fact.costSource}</td>
<td class="max-w-[10rem] truncate font-mono text-[11px] text-surface-500" title={fact.correlationId ?? ''}>
{fact.correlationId ?? '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
@@ -0,0 +1,144 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkillEditor from '$lib/components/SkillEditor.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let skills = $state<AgentSkillRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let showNewSkill = $state(false);
let newSkillName = $state('');
let newSkillVersion = $state('0.1.0');
let newSkillDescription = $state('');
let creating = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.agentSkills(slug);
skills = res.skills;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function createSkill() {
const name = newSkillName.trim();
if (name === '') {
toastError('技能名称不能为空');
return;
}
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
return;
}
const version = newSkillVersion.trim();
if (version === '') {
toastError('版本号不能为空');
return;
}
creating = true;
try {
const manifest = buildManifest(name, newSkillDescription.trim());
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
const result = await api.installAgentSkill(slug, name, { version, files });
toastSuccess(`技能 ${result.name} 已创建`);
newSkillName = '';
newSkillDescription = '';
showNewSkill = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
creating = false;
}
}
function buildManifest(name: string, description: string): string {
const desc = description === '' ? name : description;
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
}
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
load();
}
function onDisabled(_name: string) {
load();
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="技能"
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<div class="flex items-center justify-between">
<h2 class="saas-section-title">新建技能</h2>
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
{showNewSkill ? '取消' : '+ 新建'}
</button>
</div>
{#if showNewSkill}
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="技能名(如 typst-help"
bind:value={newSkillName}
/>
<input
class="saas-input text-sm"
placeholder="版本号"
bind:value={newSkillVersion}
/>
<input
class="saas-input text-sm"
placeholder="描述"
bind:value={newSkillDescription}
/>
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
{creating ? '创建中…' : '创建'}
</button>
</div>
<p class="mt-2 text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。
</p>
{/if}
</div>
{#if skills.length === 0}
<div class="saas-card">
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
</div>
{:else}
<div class="space-y-4">
{#each skills as skill (skill.id)}
<SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} />
{/each}
</div>
{/if}
{/if}
@@ -2,6 +2,8 @@
import { Collapsible } from 'bits-ui'; import { Collapsible } from 'bits-ui';
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type TeamRow, type TeamMemberRow } from '$lib/api'; import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte'; import LoadingState from '$lib/components/LoadingState.svelte';
@@ -9,7 +11,8 @@
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let teams = $state<TeamRow[]>([]); let teams = $state<TeamRow[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -0,0 +1,241 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type UsageReport, type UsageBreakdownRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import {
fmtCost,
fmtDateOnly,
fmtNum,
fmtQuantity,
fmtTokens,
usageKindLabel,
} from '$lib/format';
import PageHeader from '$lib/components/PageHeader.svelte';
import StatCard from '$lib/components/StatCard.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let usage = $state<UsageReport | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let from = $state('');
let to = $state('');
function toIsoStart(dateLocal: string): string | undefined {
if (!dateLocal) return undefined;
const d = new Date(`${dateLocal}T00:00:00`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
function toIsoEnd(dateLocal: string): string | undefined {
if (!dateLocal) return undefined;
const d = new Date(`${dateLocal}T23:59:59.999`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
async function load() {
if (!slug) return;
loading = true;
error = null;
try {
usage = await api.usage(slug, {
...(toIsoStart(from) !== undefined ? { from: toIsoStart(from) } : {}),
...(toIsoEnd(to) !== undefined ? { to: toIsoEnd(to) } : {}),
});
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function clearRange() {
from = '';
to = '';
void load();
}
function sourceLabel(row: UsageBreakdownRow): string {
if (row.capabilityId) return row.capabilityId;
if (row.model) return row.model;
return '—';
}
function meterCell(row: UsageBreakdownRow): string {
if (row.unit) return fmtQuantity(row.quantity, row.unit);
if (row.inputTokens > 0 || row.outputTokens > 0) return fmtTokens(row.inputTokens, row.outputTokens);
return '—';
}
function meterHint(row: UsageBreakdownRow): string {
if (row.unit) return '非 token 计量';
if (row.inputTokens > 0 || row.outputTokens > 0) return 'in / out tokens';
return '无计量';
}
$effect(() => {
if (slug) void load();
});
</script>
{#if loading && !usage}
<LoadingState />
{:else if error && !usage}
<ErrorBanner message={error} onretry={load} />
{:else if usage}
<PageHeader
title="用量报告"
description="按 UsageFact 分账:模型完成与外部能力(PDF→MD、ASR 等)分开汇总。缺失成本计为未知,不为 0。"
/>
<div class="saas-card-pad mb-6">
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="saas-label" for="usage-from"></label>
<input id="usage-from" class="saas-input" type="date" bind:value={from} />
</div>
<div>
<label class="saas-label" for="usage-to"></label>
<input id="usage-to" class="saas-input" type="date" bind:value={to} />
</div>
<button class="saas-btn-primary py-1.5! text-sm" type="button" onclick={load} disabled={loading}>
{loading ? '加载中…' : '应用筛选'}
</button>
<button class="saas-btn-secondary py-1.5! text-sm" type="button" onclick={clearRange} disabled={loading}>
清除
</button>
{#if usage.from || usage.to}
<p class="saas-muted grow text-right text-xs">
窗口:
{usage.from ? fmtDateOnly(usage.from) : '—'}
{usage.to ? fmtDateOnly(usage.to) : '—'}
</p>
{/if}
</div>
{#if error}
<p class="mt-3 text-sm text-error-700">{error}</p>
{/if}
</div>
<div class="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="运行总数" value={fmtNum(usage.totals.runCount)} />
<StatCard
label="有成本 / 无成本"
value={`${fmtNum(usage.totals.runsWithCost)} / ${fmtNum(usage.totals.runsWithoutCost)}`}
hint="无成本 = 成本未知不是 $0"
/>
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} hint="仅汇总已知 costUsd" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} hint="主要来自模型完成" />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} hint="主要来自模型完成" />
<StatCard
label="分账条目"
value={fmtNum(usage.breakdown.reduce((n, b) => n + b.factCount, 0))}
hint={`${fmtNum(usage.breakdown.length)} 个分项`}
/>
</div>
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按来源分账</h3>
<p class="saas-muted mt-0.5 text-xs">
kind × provider × model/capability。外部能力显示页数/秒等计量,不与 tokens 混排。
</p>
</div>
{#if usage.breakdown.length === 0}
<EmptyState title="暂无用量事实" description="跑过智能体后,模型与外部能力消费会出现在此。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>类型</th>
<th>供应方</th>
<th>模型 / 能力</th>
<th>次数</th>
<th>计量</th>
<th>有成本 / 未知</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each usage.breakdown as row}
<tr>
<td>
<span
class={row.kind === 'external_capability'
? 'saas-badge-primary'
: row.kind === 'model_completion'
? 'saas-badge-success'
: 'saas-badge-primary'}
>
{usageKindLabel(row.kind)}
</span>
</td>
<td class="font-mono text-xs">{row.provider}</td>
<td class="font-mono text-xs">{sourceLabel(row)}</td>
<td class="tabular-nums">{fmtNum(row.factCount)}</td>
<td class="tabular-nums">
<div>{meterCell(row)}</div>
<div class="text-[11px] text-surface-600">{meterHint(row)}</div>
</td>
<td class="tabular-nums text-surface-700">
{fmtNum(row.factsWithCost)} / {fmtNum(row.factsWithoutCost)}
</td>
<td class="tabular-nums font-medium">{fmtCost(row.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="saas-card overflow-hidden">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目</h3>
<p class="saas-muted mt-0.5 text-xs">项目仍是权限边界;行内成本已含该项目全部 fact 类型。</p>
</div>
{#if usage.projects.length === 0}
<EmptyState title="暂无项目" description="创建项目并触发智能体后会出现用量。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>运行</th>
<th>有成本 / 未知</th>
<th>in / out tokens</th>
<th>成本</th>
<th></th>
</tr>
</thead>
<tbody>
{#each usage.projects as p}
<tr>
<td class="font-medium">{p.projectName}</td>
<td class="tabular-nums">{fmtNum(p.runCount)}</td>
<td class="tabular-nums text-surface-700">
{fmtNum(p.runsWithCost)} / {fmtNum(p.runsWithoutCost)}
</td>
<td class="tabular-nums text-surface-600">{fmtTokens(p.inputTokens, p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
<td class="text-right">
<a class="text-sm text-primary-700 hover:underline" href={`/admin/projects/${p.projectId}`}>
查看项目
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
proxy: { proxy: {
'/api': 'http://127.0.0.1:8788', '/api': 'http://127.0.0.1:8788',
'/auth': 'http://127.0.0.1:8788', '/auth': 'http://127.0.0.1:8788',
// Backend owns /admin/login (registered before the SPA fallback in
// src/admin/static.ts). Proxy it in dev so the SPA doesn't re-render
// its layout on that path and 401-redirect into a returnTo loop.
'/admin/login': 'http://127.0.0.1:8788',
}, },
}, },
}); });
+5 -2
View File
@@ -82,10 +82,11 @@ REMOTE
rsync -az --delete \ rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \ --exclude node_modules --exclude dist --exclude .env \
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \ --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[*]}" \ -e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" "$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 ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c ' flock /var/lock/cph-hub-release-publish bash -c '
@@ -95,11 +96,13 @@ flock /var/lock/cph-hub-release-publish bash -c '
exit 0 exit 0
fi fi
cd "$HUB_DIR" cd "$HUB_DIR"
npm ci PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web npm ci --prefix admin-web
npm ci --prefix filelib-web
npm run audit:production npm run audit:production
npm run build npm run build
test -f admin-web/build/index.html test -f admin-web/build/index.html
test -f filelib-web/build/index.html
touch "$RELEASE_DIR/.complete" touch "$RELEASE_DIR/.complete"
' '
REMOTE REMOTE
+4 -3
View File
@@ -60,10 +60,11 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \ -e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete. # 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete.
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa. # `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" \ ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && 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 fi
# 3. Ensure the service is installed (idempotent), then restart. # 3. Ensure the service is installed (idempotent), then restart.
+1758
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "filelib-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"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",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.2",
"typescript": "^5.7.0",
"vite": "^8.0.16"
}
}
+263
View File
@@ -0,0 +1,263 @@
@import "tailwindcss";
/* 全局 UI 主题令牌(与 hub 端 uiTheme.ts 同源) */
@theme {
--color-bg: #fcfcfb;
--color-panel: #ffffff;
--color-sidebar: #f7f7f5;
--color-ink: #1a1a18;
--color-ink-2: #6b6a66;
--color-ink-3: #9c9b96;
--color-line: #ecece8;
--color-line-soft: #f1f1ee;
--color-hover: #f4f4f1;
--color-selected: #ebebe7;
--color-accent: #1a1a18;
--color-accent-hover: #333330;
--color-danger: #a13a33;
--color-guide: #e9e9e5;
--color-diff-add-bg: #f3f6f2;
--color-diff-add-text: #4a6741;
--color-diff-del-bg: #f8f2f1;
--color-diff-del-text: #a13a33;
}
html,
body {
height: 100%;
}
body {
background: var(--color-bg);
color: var(--color-ink);
font-family:
"Inter",
-apple-system,
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
sans-serif;
font-size: 14px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.font-mono {
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;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.75;
}
pre.diff .add {
display: block;
color: var(--color-diff-add-text);
background: var(--color-diff-add-bg);
}
pre.diff .del {
display: block;
color: var(--color-diff-del-text);
background: var(--color-diff-del-bg);
}
+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}
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
/**
* 回收站(ADR-0031):祖先全活跃的已删节点顶;恢复只清本节点 deletedAt;
* 彻底删除(整支硬删)仅网站管理员,二次确认。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { me, toastErr, toastOk } from "./stores.js";
import type { BinEntry } from "./types.js";
import Icon from "./Icon.svelte";
let entries = $state<BinEntry[] | null>(null);
let error = $state<string | null>(null);
let busyId = $state<string | null>(null);
async function load(): Promise<void> {
try {
const r = await api<{ entries: BinEntry[] }>("/database/api/bin");
entries = r.entries;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
onMount(load);
function fmt(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
async function restore(e: BinEntry): Promise<void> {
busyId = e.id;
try {
await api(`/database/api/bin/${encodeURIComponent(e.id)}/restore`, { method: "POST" });
toastOk(`已恢复「${e.name}」`);
await load();
} catch (err) {
toastErr(err instanceof Error ? err.message : String(err));
} finally {
busyId = null;
}
}
async function purge(e: BinEntry): Promise<void> {
if (!confirm(`彻底删除「${e.name}」及其全部内容?此操作不可恢复。`)) return;
if (!confirm(`再次确认:整支硬删,含子文件夹/项目/文件与授权记录。继续?`)) return;
busyId = e.id;
try {
const r = await api<{ removed: number }>(`/database/api/bin/${encodeURIComponent(e.id)}`, { method: "DELETE" });
toastOk(`已彻底删除(${r.removed} 个节点)`);
await load();
} catch (err) {
toastErr(err instanceof Error ? err.message : String(err));
} finally {
busyId = null;
}
}
</script>
<div class="flex-1 overflow-y-auto px-6 py-5">
<h1 class="mb-4 text-[15px] font-semibold text-ink">回收站</h1>
{#if error !== null}
<div class="py-8 text-center text-[13px] text-danger">{error}</div>
{:else if entries === null}
<div class="quiet py-8 text-center">加载中…</div>
{:else if entries.length === 0}
<div class="quiet py-8 text-center">回收站是空的</div>
{:else}
<div class="panel !p-2">
{#each entries as e (e.id)}
<div class="flex items-center gap-3 rounded-lg px-3 py-2">
<span class="flex text-ink-3"><Icon name={e.kind === "FOLDER" ? "folder" : "layers"} size={15} /></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] text-ink">{e.name}</span>
<span class="quiet block">删除于 {fmt(e.deletedAt)}</span>
</span>
<button
class="btn btn-sm disabled:opacity-50"
onclick={() => void restore(e)}
disabled={busyId === e.id}
>
<Icon name="restore" size={12} /> 恢复
</button>
{#if $me?.isWebsiteAdmin}
<button
class="btn btn-sm btn-danger disabled:opacity-50"
onclick={() => void purge(e)}
disabled={busyId === e.id}
>
<Icon name="trash" size={12} /> 彻底删除
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,55 @@
<script lang="ts" module>
import type { IconName } from "./Icon.svelte";
export interface MenuItem {
readonly label: string;
readonly icon?: IconName;
readonly danger?: boolean;
readonly onclick: () => void;
}
</script>
<script lang="ts">
/**
* 通用右键菜单:光标处弹出,点任意处/再次右键关闭。
* 位置做视口夹取(右侧/底部溢出时向内收)。
*/
import Icon from "./Icon.svelte";
let { x, y, items, onclose }: { x: number; y: number; items: readonly MenuItem[]; onclose: () => void } = $props();
const MENU_W = 178;
const ITEM_H = 34;
const px = $derived(
typeof window === "undefined" ? x : Math.max(4, Math.min(x, window.innerWidth - MENU_W - 8)),
);
const py = $derived(
typeof window === "undefined" ? y : Math.max(4, Math.min(y, window.innerHeight - items.length * ITEM_H - 20)),
);
</script>
<div
class="fixed inset-0 z-50"
role="presentation"
onclick={onclose}
oncontextmenu={(e) => { e.preventDefault(); onclose(); }}
>
<div
class="fixed rounded-xl border border-line-soft bg-panel py-1.5 shadow-[0_8px_28px_rgba(26,26,24,.12)]"
style="left:{px}px;top:{py}px;width:{MENU_W}px"
>
{#each items as item (item.label)}
<button
class="flex w-full items-center gap-2.5 px-3.5 py-[7px] text-left text-[12.5px] transition {item.danger
? 'text-danger hover:bg-hover'
: 'text-ink hover:bg-hover'}"
onclick={() => { onclose(); item.onclick(); }}
>
{#if item.icon}
<span class={item.danger ? "" : "text-ink-3"}><Icon name={item.icon} size={14} /></span>
{/if}
{item.label}
</button>
{/each}
</div>
</div>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { api, ApiError } from "./api.js";
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();
let file = $state<FileContent | null>(null);
let draft = $state("");
let loadError = $state<string | null>(null);
let conflict = $state<{ currentVersion: string; diff: string } | null>(null);
let showHistory = $state(false);
let history = $state<VersionInfo[]>([]);
const canEdit = $derived(role !== "VIEW");
async function load(): Promise<void> {
try {
file = await api<FileContent>(`/database/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
draft = file.encoding === "utf8" ? file.content : "";
loadError = null;
conflict = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
$effect(() => {
void projectId;
void path;
void load();
});
async function save(): Promise<void> {
if (file === null) return;
try {
const r = await api<{ version: string }>(`/database/api/projects/${projectId}/file/commits`, {
method: "POST",
body: { path: file.path, baseVersion: file.version, content: draft },
});
toastOk("已提交 " + r.version);
await load();
onchanged();
} catch (e) {
if (e instanceof ApiError && e.status === 409 && typeof e.details?.["currentVersion"] === "string") {
await showConflict(e.details["currentVersion"]);
} else {
toastErr(e instanceof Error ? e.message : String(e));
}
}
}
async function showConflict(currentVersion: string): Promise<void> {
if (file === null) return;
try {
const r = await api<{ diff: string }>(
`/database/api/projects/${projectId}/file/diff?path=${encodeURIComponent(file.path)}&from=${encodeURIComponent(file.version)}&to=${encodeURIComponent(currentVersion)}`,
);
conflict = { currentVersion, diff: r.diff };
file = { ...file, version: currentVersion };
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function acceptLatest(): Promise<void> {
conflict = null;
await load();
toast("已载入最新内容,请在此基础上合并", "info");
}
async function remove(): Promise<void> {
if (file === null || !confirm("删除文件 " + file.path + "?")) return;
try {
await api(`/database/api/projects/${projectId}/file?path=${encodeURIComponent(file.path)}`, {
method: "DELETE",
body: { baseVersion: file.version },
});
toastOk("已删除");
file = null;
onchanged();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function openHistory(): Promise<void> {
try {
const r = await api<{ history: VersionInfo[] }>(`/database/api/projects/${projectId}/file/history?path=${encodeURIComponent(path)}`);
history = r.history;
showHistory = true;
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
function renderDiff(diff: string): string {
return diff
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/^\+(.*)$/gm, '<span class="add">+$1</span>')
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>');
}
</script>
{#if loadError}
<div class="panel text-xs text-danger">{loadError}</div>
{:else if file}
<div class="panel">
<div class="mb-2.5 flex items-center justify-between">
<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="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除文件</button>
{/if}
{#if onclose}
<button class="btn !px-2.5" onclick={onclose} title="关闭预览" aria-label="关闭预览"></button>
{/if}
</div>
</div>
{#if file.encoding === "base64"}
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
{:else}
<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="btn btn-primary" onclick={save}>提交修改</button>
</div>
{/if}
{#if conflict}
<div class="mt-3.5 rounded-xl border border-[#E8E2C8] bg-[#FCFBF4] p-4">
<div class="mb-2 text-[13px] font-semibold text-[#6E6329]">冲突:他人已提交 {conflict.currentVersion},差异如下(你的基版 → 最新版)</div>
<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="btn" onclick={acceptLatest}>载入最新内容</button>
</div>
</div>
{/if}
</div>
{:else}
<div class="panel quiet">加载中…</div>
{/if}
{#if showHistory}
<Modal title="版本历史" onclose={() => (showHistory = false)}>
<div class="max-h-80 overflow-y-auto">
{#each history as v (v.version)}
<div class="border-t border-line-soft py-2 text-xs first:border-t-0">
<span class="font-mono text-accent">{v.version}</span> {v.message}
<div class="text-ink-3">{new Date(v.committedAt).toLocaleString("zh-CN")}{v.author ? " · " + v.author : ""}</div>
</div>
{/each}
</div>
<div class="mt-3 flex justify-end">
<button class="btn" onclick={() => (showHistory = false)}>关闭</button>
</div>
</Modal>
{/if}
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
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();
let files = $state<FileEntry[] | null>(null);
let loadError = $state<string | null>(null);
let showNewFile = $state(false);
let newPath = $state("");
let newContent = $state("");
// bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。
let uploadInput = $state<HTMLInputElement | null>(null);
const canEdit = $derived(node.role !== "VIEW");
async function loadFiles(): Promise<void> {
try {
const r = await api<{ files: FileEntry[] }>(`/database/api/projects/${node.id}/files`);
files = r.files;
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
$effect(() => {
void node.id;
void $filesVersion;
void loadFiles();
});
async function submitNewFile(): Promise<void> {
const path = newPath.trim();
if (path === "") return;
try {
await api(`/database/api/projects/${node.id}/file`, {
method: "PUT",
body: { path, content: newContent },
});
toastOk("已创建");
showNewFile = false;
newPath = ""; newContent = "";
await loadFiles();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
function u8ToBase64(bytes: Uint8Array): string {
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK)) as unknown as number[]);
}
return btoa(bin);
}
async function doUpload(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
toastErr("文件超过 10MB 上限");
return;
}
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/${node.id}/file`, { method: "PUT", body });
toastOk("已上传 " + file.name);
await loadFiles();
} catch (err) {
toastErr(err instanceof Error ? err.message : String(err));
}
}
</script>
<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-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="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="quiet py-5 text-center">空仓库 · 可新建或上传文件</div>
{:else if files}
<table class="list">
<tbody>
{#each files as f (f.path)}
<tr
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
onclick={() => selectedFilePath.set(f.path)}
>
<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>
</table>
{/if}
</div>
{#if showNewFile}
<Modal title="新建文件" onclose={() => (showNewFile = false)}>
<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="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="btn" onclick={() => (showNewFile = false)}>取消</button>
<button class="btn btn-primary" onclick={submitNewFile}>创建</button>
</div>
</Modal>
{/if}
+381
View File
@@ -0,0 +1,381 @@
<script lang="ts">
/**
* 节点授权面板(表格化改版)。迁自已删除的 routes/libraryBrowser.ts
* `renderGrantsTab`(ADR-0029)。
*
* 布局:顶部工具条(左:授权成员搜索框;右:「+ 添加授权」弹窗入口);
* 下方一行一条授权 —— 成员(名称+id,点击跳用户管理/Group 管理)、
* 类型(个人/Group)、权限(下拉可改)、加入时间、操作(删除)。
*
* 语义(契约 8.1 / ADR-0021 / ADR-0028):
* - 创建者授权(isCreatorGrant)不可收回、不可改;
* - MANAGE 仅创建者可授,前端不做矩阵拦截 —— 后端 fail closed,报错原样 toast;
* - GROUP 主体走 in-hub MemberGroup,/groups/search 搜索选(MANAGE 即可);
* USER 主体用 /users/search(仅网站管理员),老师端无此权限时回落手输 id。
*/
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { ROLE_LABEL } from "./labels.js";
import type { Grant, MemberGroupSearchResult, NodeDetail, Role, UserSearchResult } from "./types.js";
import Icon from "./Icon.svelte";
import Modal from "./Modal.svelte";
import Avatar from "./Avatar.svelte";
let { node }: { node: NodeDetail } = $props();
const ROLES: readonly Role[] = ["VIEW", "EDIT", "MANAGE"];
interface PrincipalOption {
readonly id: string;
readonly label: string;
readonly sub: string;
}
let grants = $state<Grant[] | null>(null);
let error = $state<string | null>(null);
let searchText = $state("");
// 添加授权弹窗
let showAdd = $state(false);
let principalType = $state<"USER" | "GROUP">("USER");
let principalQuery = $state("");
let principalOptions = $state<readonly PrincipalOption[] | null>(null);
let selectedPrincipal = $state<{ readonly id: string; readonly label: string } | null>(null);
let manualId = $state("");
let searchUnavailable = $state(false);
let role = $state<Role>("VIEW");
let saving = $state(false);
let searchSeq = 0;
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const canManage = $derived(node.role === "MANAGE");
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
/** 工具条搜索:按名称 / id / 类型过滤当前授权行(纯前端过滤,数据已全量在手)。 */
const shown = $derived.by((): Grant[] | null => {
if (grants === null) return null;
const q = searchText.trim().toLowerCase();
if (q === "") return grants;
return grants.filter(
(g) =>
g.principalId.toLowerCase().includes(q) ||
(g.principalName ?? "").toLowerCase().includes(q) ||
(g.principalOpenId ?? "").toLowerCase().includes(q) ||
(g.principalType === "USER" ? "个人" : "group").includes(q),
);
});
$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);
}
}
/** 成员单元格跳转:USER → 用户管理(带过滤词);GROUP → Group 管理(选中该组)。 */
function principalHref(g: Grant): string {
return g.principalType === "USER"
? `/database/dashboard/users?q=${encodeURIComponent(g.principalId)}`
: `/database/dashboard/groups?select=${encodeURIComponent(g.principalId)}`;
}
function fmtDate(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
/* ------------------------------------------------------------ 添加授权弹窗 */
function openAdd(): void {
showAdd = true;
principalType = "USER";
principalQuery = "";
principalOptions = null;
selectedPrincipal = null;
manualId = "";
searchUnavailable = false;
role = "VIEW";
void searchPrincipals("");
}
function onTypeChange(): void {
principalQuery = "";
principalOptions = null;
selectedPrincipal = null;
manualId = "";
searchUnavailable = false;
void searchPrincipals("");
}
function onQueryInput(): void {
selectedPrincipal = null;
if (searchTimer !== undefined) clearTimeout(searchTimer);
const q = principalQuery.trim();
searchTimer = setTimeout(() => void searchPrincipals(q), 250);
}
async function searchPrincipals(q: string): Promise<void> {
// seq 防乱序:慢响应不覆盖新查询的结果。
const seq = ++searchSeq;
try {
if (principalType === "USER") {
const r = await api<{ users: UserSearchResult[] }>(
`/database/api/users/search?q=${encodeURIComponent(q)}`,
);
if (seq !== searchSeq) return;
principalOptions = r.users.map((u) => ({
id: u.userId,
label: u.displayName === "" ? u.userId : u.displayName,
sub: u.feishuOpenId,
}));
} else {
const r = await api<{ groups: MemberGroupSearchResult[] }>(
`/database/api/groups/search?q=${encodeURIComponent(q)}`,
);
if (seq !== searchSeq) return;
principalOptions = r.groups.map((g) => ({ id: g.id, label: g.name, sub: g.breadcrumb }));
}
searchUnavailable = false;
} catch {
if (seq !== searchSeq) return;
// 老师端 MANAGE 持有者没有 users/search 权限(403)——回落为手输 id。
principalOptions = null;
searchUnavailable = true;
}
}
function pick(option: PrincipalOption): void {
selectedPrincipal = { id: option.id, label: option.label };
principalQuery = option.label;
principalOptions = null;
}
async function addGrant(): Promise<void> {
const principalId = selectedPrincipal?.id ?? manualId.trim();
if (principalId === "") {
toastErr("请选择或填写授权主体");
return;
}
saving = true;
try {
// PUT /grants 是 upsert 语义(putGrants):同主体已有授权则改级别,否则新建。
await api(`/database/api/nodes/${node.id}/grants`, {
method: "PUT",
body: { grants: [{ principalType, principalId, role }] },
});
toastOk("已授予");
showAdd = false;
await load();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
/** 权限下拉改级别:复用 PUT upsert;被 8.1 矩阵拒绝时 toast 并 reload 回显真实态。 */
async function changeRole(g: Grant, next: Role): Promise<void> {
if (next === g.role) return;
try {
await api(`/database/api/nodes/${node.id}/grants`, {
method: "PUT",
body: { grants: [{ principalType: g.principalType, principalId: g.principalId, role: next }] },
});
toastOk("权限已更新");
await load();
} catch (e) {
toastErr(errText(e));
await load();
}
}
async function revoke(g: Grant): Promise<void> {
if (!confirm(`删除「${g.principalName ?? g.principalId}」的${ROLE_LABEL[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));
}
}
</script>
<div class="panel">
<!-- 工具条:左侧授权成员搜索框,右侧添加授权入口 -->
<div class="mb-3 flex items-center gap-2">
<div class="relative min-w-0 flex-1">
<span class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3">
<Icon name="search" size={14} />
</span>
<input
class="input w-full !pl-8"
placeholder="搜索授权成员(名称 / id / 类型)"
bind:value={searchText}
/>
</div>
{#if canManage}
<button class="btn btn-primary shrink-0" onclick={openAdd}>
<Icon name="plus" size={13} /> 添加授权
</button>
{/if}
</div>
{#if error !== null}
<div class="py-2 text-[12.5px] text-danger">{error}</div>
{:else if shown === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else}
<!-- 内容不可断行(头像名/id/日期/下拉)可能超宽:溢出时横向滚动,
而不是把表格挤变形或顶出 panel 右边界(文件预览打开时主区变窄)。 -->
<div class="overflow-x-auto">
<table class="list">
<thead>
<tr>
<th class="whitespace-nowrap pr-4">成员</th>
<th class="whitespace-nowrap pr-4">userId</th>
<th class="whitespace-nowrap pr-4">飞书 ID</th>
<th class="whitespace-nowrap pr-4">类型</th>
<th class="whitespace-nowrap pr-4">权限</th>
<th class="whitespace-nowrap pr-4">加入时间</th>
<th></th>
</tr>
</thead>
<tbody>
{#if shown.length === 0}
<tr>
<td colspan="7" class="quiet !py-[18px] text-center">
{searchText.trim() === "" ? "暂无授权" : `无匹配「${searchText.trim()}」的授权`}
</td>
</tr>
{:else}
{#each shown as g (g.id)}
<tr>
<td class="whitespace-nowrap pr-4">
<a class="inline-flex items-center gap-2 text-ink hover:text-accent" href={principalHref(g)}>
<Avatar displayName={g.principalName} userId={g.principalId} size={26} />
<span class="flex items-center gap-1.5 text-[13px]">
{g.principalName ?? g.principalId}
{#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if}
</span>
</a>
</td>
<td class="file-meta max-w-[230px] truncate pr-4 font-mono" title={g.principalType === "USER" ? g.principalId : ""}>
{g.principalType === "USER" ? g.principalId : "—"}
</td>
<td class="file-meta max-w-[230px] truncate pr-4 font-mono" title={g.principalOpenId ?? ""}>
{g.principalOpenId ?? "—"}
</td>
<td class="whitespace-nowrap pr-4"><span class="tag">{g.principalType === "USER" ? "个人" : "Group"}</span></td>
<td class="whitespace-nowrap pr-4">
<!-- 创建者授权不可动(契约 8.1);非 MANAGE 持有者只读。 -->
{#if !g.isCreatorGrant && canManage}
<select
class="select !w-[110px]"
value={g.role}
onchange={(e) => void changeRole(g, e.currentTarget.value as Role)}
>
{#each ROLES as r (r)}
<option value={r}>{ROLE_LABEL[r]}</option>
{/each}
</select>
{:else}
<span class="file-meta">{ROLE_LABEL[g.role]}</span>
{/if}
</td>
<td class="file-meta whitespace-nowrap pr-4">{fmtDate(g.createdAt)}</td>
<td class="whitespace-nowrap pr-2 text-right">
{#if !g.isCreatorGrant && canManage}
<button class="link-danger inline-flex items-center gap-1" onclick={() => void revoke(g)}>
<Icon name="trash" size={12} /> 删除
</button>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
{/if}
</div>
{#if showAdd}
<Modal title="添加授权" onclose={() => (showAdd = false)}>
<div class="form-row">
<label class="form-label" for="grant-type">类型</label>
<select id="grant-type" class="select" bind:value={principalType} onchange={onTypeChange}>
<option value="USER">个人</option>
<option value="GROUP">Group</option>
</select>
</div>
<div class="form-row">
<label class="form-label" for="grant-principal">{principalType === "USER" ? "用户" : "Group"}</label>
<input
id="grant-principal"
class="input"
placeholder={principalType === "USER" ? "搜索显示名 / openId" : "搜索组名"}
bind:value={principalQuery}
oninput={onQueryInput}
/>
</div>
{#if selectedPrincipal !== null}
<div class="form-row">
<span class="form-label">已选</span>
<span class="quiet self-center text-[12.5px]">
{selectedPrincipal.label}<span class="font-mono">({selectedPrincipal.id})</span>
</span>
</div>
{/if}
{#if principalOptions !== null && principalOptions.length > 0}
<div class="mb-3 max-h-[180px] overflow-y-auto rounded-lg border border-line-soft">
{#each principalOptions as o (o.id)}
<button class="block w-full px-3 py-2 text-left hover:bg-hover" onclick={() => pick(o)}>
<span class="block text-[13px] text-ink">{o.label}</span>
<span class="block font-mono text-[11px] text-ink-3">{o.sub}</span>
</button>
{/each}
</div>
{:else if searchUnavailable}
<div class="form-row">
<label class="form-label" for="grant-manual-id">主体 id</label>
<input
id="grant-manual-id"
class="input font-mono"
placeholder="无搜索权限,请直接填写 id"
bind:value={manualId}
/>
</div>
{:else if principalOptions !== null}
<div class="quiet mb-3 py-2 text-center text-[12px]">无匹配结果</div>
{/if}
<div class="form-row">
<label class="form-label" for="grant-role">权限</label>
<select id="grant-role" class="select" bind:value={role}>
{#each ROLES as r (r)}
<option value={r}>{ROLE_LABEL[r]}</option>
{/each}
</select>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showAdd = false)}>取消</button>
<button class="btn btn-primary disabled:opacity-50" onclick={addGrant} disabled={saving}>
{saving ? "授予中…" : "添加"}
</button>
</div>
</Modal>
{/if}
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
/**
* 网盘式大图标卡片:文件夹(琥珀填充)/项目(立方体描边)/文件(文档描边)。
* 单击选中、onopen(双击)、oncontextmenu(右键,回传光标坐标)。
*/
let {
kind,
name,
meta = null,
selected = false,
onselect,
onopen,
oncontextmenu,
}: {
kind: "FOLDER" | "PROJECT" | "FILE";
name: string;
meta?: string | null;
selected?: boolean;
onselect: () => void;
onopen: () => void;
oncontextmenu: (x: number, y: number) => void;
} = $props();
</script>
<button
class="flex cursor-pointer flex-col items-center gap-1.5 rounded-xl px-2 pb-2 pt-3 select-none {selected
? 'bg-selected'
: 'hover:bg-hover'}"
onclick={onselect}
ondblclick={onopen}
oncontextmenu={(e) => { e.preventDefault(); oncontextmenu(e.clientX, e.clientY); }}
title={name}
>
<span class="flex h-14 w-14 items-center justify-center">
{#if kind === "FOLDER"}
<svg width="54" height="54" viewBox="0 0 24 24" fill="#f5c94a" stroke="#d9a92b" stroke-width="0.6" stroke-linejoin="round"><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>
{:else if kind === "PROJECT"}
<svg width="50" height="50" viewBox="0 0 24 24" fill="#e8f0ea" stroke="#4a6741" stroke-width="1.4" 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>
{:else}
<svg width="46" height="46" viewBox="0 0 24 24" fill="#ffffff" stroke="#9c9b96" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z" /><path d="M14 3v5h5" /><path d="M9 13h6M9 17h6" /></svg>
{/if}
</span>
<span class="line-clamp-2 w-full break-all text-center text-[12.5px] leading-snug text-ink">{name}</span>
{#if meta !== null}
<span class="-mt-1 text-[10.5px] text-ink-3">{meta}</span>
{/if}
</button>
@@ -0,0 +1,539 @@
<script lang="ts">
/**
* 老师端网盘式文件库浏览器(仅 /app;管理后台沿用树状 LibraryView)。
*
* 下钻导航:双击文件夹/项目进入,面包屑 + 返回跳级;项目内文件同样网格化,
* 双击进 FileEditor 预览。管理动作全走右键菜单,按节点 role 动态显隐:
* 打开 / 新建子文件夹(EDIT+,仅 FOLDER)/ 重命名(MANAGE)/ 授权管理(MANAGE,
* 宽 Modal 复用 GrantsPanel)/ 详情(复用 OverviewPanel)/ 删除(MANAGE)。
* 文件菜单:打开预览 / 下载 / 删除(EDIT+)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { me, toastErr, toastOk } from "./stores.js";
import { logout } from "./session.js";
import { selectedFilePath, clearSelectedFile } from "./browser.js";
import { ROLE_LABEL } from "./labels.js";
import type { FileEntry, NodeChild, NodeDetail, Role } from "./types.js";
import Icon from "./Icon.svelte";
import Modal from "./Modal.svelte";
import ContextMenu, { type MenuItem } from "./ContextMenu.svelte";
import GridCard from "./GridCard.svelte";
import FileEditor from "./FileEditor.svelte";
import GrantsPanel from "./GrantsPanel.svelte";
import OverviewPanel from "./OverviewPanel.svelte";
const RANK: Record<Role, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min];
type View = "nodes" | "files";
type StackItem = Pick<NodeChild, "id" | "name" | "kind" | "role">;
let view = $state<View>("nodes");
/** 下钻栈(均为 FOLDER;根层为空栈)。 */
let stack = $state<StackItem[]>([]);
let children = $state<NodeChild[] | null>(null);
let nodesError = $state<string | null>(null);
/** 文件视图:当前项目详情 + 文件列表。 */
let projectNode = $state<NodeDetail | null>(null);
let files = $state<FileEntry[] | null>(null);
let filesError = $state<string | null>(null);
let selected = $state<string | null>(null);
let menu = $state<{ x: number; y: number; items: readonly MenuItem[] } | null>(null);
// 弹窗:create(新建文件夹/项目)/ rename / grants / detail / newFile
let modal = $state<"create" | "rename" | "grants" | "detail" | "newFile" | null>(null);
let createKind = $state<"FOLDER" | "PROJECT">("FOLDER");
let createParentId = $state<string | null>(null);
let formName = $state("");
let formDesc = $state("");
let renameTarget = $state<StackItem | null>(null);
let detailNode = $state<NodeDetail | null>(null);
let newPath = $state("");
let newContent = $state("");
let saving = $state(false);
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
const currentFolder = $derived(stack.length === 0 ? null : stack[stack.length - 1]!);
const canCreateHere = $derived(
currentFolder === null ? ($me?.isWebsiteAdmin ?? false) : atLeast(currentFolder.role, "EDIT"),
);
const projectCanEdit = $derived(projectNode !== null && projectNode.role !== "VIEW");
const initial = $derived(($me?.displayName ?? $me?.userId ?? "U").slice(0, 1).toUpperCase());
/* ------------------------------------------------------------ 数据加载 */
async function loadChildren(): Promise<void> {
children = null;
nodesError = null;
try {
const parent = currentFolder;
const url = parent === null
? "/database/api/nodes"
: `/database/api/nodes?parentId=${encodeURIComponent(parent.id)}`;
const r = await api<{ nodes: NodeChild[] }>(url);
children = r.nodes;
} catch (e) {
nodesError = errText(e);
}
}
async function loadFiles(): Promise<void> {
if (projectNode === null) return;
files = null;
filesError = null;
try {
const r = await api<{ files: FileEntry[] }>(`/database/api/projects/${projectNode.id}/files`);
files = r.files;
} catch (e) {
filesError = errText(e);
}
}
async function fetchDetail(id: string): Promise<NodeDetail> {
const r = await api<{ node: NodeDetail }>(`/database/api/nodes/${id}`);
return r.node;
}
onMount(loadChildren);
function refresh(): void {
selected = null;
menu = null;
if (view === "files") void loadFiles();
else void loadChildren();
}
/* ------------------------------------------------------------ 导航 */
function openNode(n: StackItem): void {
selected = null;
if (n.kind === "FOLDER") {
stack = [...stack, n];
void loadChildren();
} else {
void (async () => {
try {
projectNode = await fetchDetail(n.id);
view = "files";
await loadFiles();
} catch (e) {
toastErr(errText(e));
}
})();
}
}
function goRoot(): void {
if (view === "files") {
view = "nodes";
projectNode = null;
clearSelectedFile();
return;
}
stack = [];
void loadChildren();
}
function goUp(): void {
if (view === "files") {
goRoot();
return;
}
if (stack.length === 0) return;
stack = stack.slice(0, -1);
void loadChildren();
}
function goToDepth(depth: number): void {
if (view === "files") {
view = "nodes";
projectNode = null;
clearSelectedFile();
}
stack = stack.slice(0, depth);
void loadChildren();
}
/* ------------------------------------------------------------ 节点操作 */
function openCreate(kind: "FOLDER" | "PROJECT", parentId: string | null): void {
createKind = kind;
createParentId = parentId;
formName = "";
formDesc = "";
modal = "create";
}
async function submitCreate(): Promise<void> {
const name = formName.trim();
if (name === "") return;
saving = true;
try {
await api("/database/api/nodes", {
method: "POST",
body: {
parentId: createParentId,
kind: createKind,
name,
...(formDesc.trim() !== "" ? { description: formDesc.trim() } : {}),
},
});
toastOk("已创建");
modal = null;
refresh();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
function openRename(n: StackItem): void {
renameTarget = n;
formName = n.name;
modal = "rename";
}
async function submitRename(): Promise<void> {
if (renameTarget === null) return;
const name = formName.trim();
if (name === "" || name === renameTarget.name) return;
saving = true;
try {
await api(`/database/api/nodes/${renameTarget.id}`, { method: "PATCH", body: { name } });
toastOk("已重命名");
modal = null;
refresh();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
async function removeNode(n: StackItem): Promise<void> {
if (!confirm(`删除「${n.name}」?软删除后不可见。`)) return;
try {
await api(`/database/api/nodes/${n.id}`, { method: "DELETE" });
toastOk("已删除");
refresh();
} catch (e) {
toastErr(errText(e));
}
}
async function openGrants(n: StackItem): Promise<void> {
try {
detailNode = await fetchDetail(n.id);
modal = "grants";
} catch (e) {
toastErr(errText(e));
}
}
async function openDetail(n: StackItem): Promise<void> {
try {
detailNode = await fetchDetail(n.id);
modal = "detail";
} catch (e) {
toastErr(errText(e));
}
}
/* ------------------------------------------------------------ 文件操作 */
function previewFile(f: FileEntry): void {
selectedFilePath.set(f.path);
}
async function submitNewFile(): Promise<void> {
if (projectNode === null) return;
const path = newPath.trim();
if (path === "") return;
saving = true;
try {
await api(`/database/api/projects/${projectNode.id}/file`, {
method: "PUT",
body: { path, content: newContent },
});
toastOk("已创建");
modal = null;
newPath = ""; newContent = "";
await loadFiles();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
async function removeFile(f: FileEntry): Promise<void> {
if (projectNode === null || !confirm(`删除文件 ${f.path}?`)) return;
try {
const cur = await api<{ version: string }>(
`/database/api/projects/${projectNode.id}/file?path=${encodeURIComponent(f.path)}`,
);
await api(`/database/api/projects/${projectNode.id}/file?path=${encodeURIComponent(f.path)}`, {
method: "DELETE",
body: { baseVersion: cur.version },
});
toastOk("已删除");
if ($selectedFilePath === f.path) clearSelectedFile();
await loadFiles();
} catch (e) {
toastErr(errText(e));
}
}
/* ------------------------------------------------------------ 右键菜单 */
function nodeMenuItems(n: StackItem): MenuItem[] {
const items: MenuItem[] = [{ label: "打开", icon: "chevron", onclick: () => openNode(n) }];
if (n.kind === "FOLDER" && atLeast(n.role, "EDIT")) {
items.push({ label: "新建子文件夹", icon: "plus", onclick: () => openCreate("FOLDER", n.id) });
}
if (n.role === "MANAGE") {
items.push(
{ label: "重命名", icon: "pencil", onclick: () => openRename(n) },
{ label: "授权管理", icon: "shield", onclick: () => void openGrants(n) },
);
}
items.push({ label: "详情", icon: "info", onclick: () => void openDetail(n) });
if (n.role === "MANAGE") {
items.push({ label: "删除", icon: "trash", danger: true, onclick: () => void removeNode(n) });
}
return items;
}
function fileMenuItems(f: FileEntry): MenuItem[] {
const items: MenuItem[] = [
{ label: "打开预览", icon: "chevron", onclick: () => previewFile(f) },
{
label: "下载",
icon: "download",
onclick: () => {
if (projectNode === null) return;
const a = document.createElement("a");
a.href = `/database/api/projects/${projectNode.id}/file/raw?path=${encodeURIComponent(f.path)}`;
a.download = "";
a.click();
},
},
];
if (projectCanEdit) {
items.push({ label: "删除", icon: "trash", danger: true, onclick: () => void removeFile(f) });
}
return items;
}
function bgMenuItems(): MenuItem[] {
const items: MenuItem[] = [];
if (view === "nodes" && canCreateHere) {
items.push(
{ label: "新建文件夹", icon: "plus", onclick: () => openCreate("FOLDER", currentFolder?.id ?? null) },
{ label: "新建项目", icon: "plus", onclick: () => openCreate("PROJECT", currentFolder?.id ?? null) },
);
}
if (view === "files" && projectCanEdit) {
items.push({ label: "新建文件", icon: "plus", onclick: () => (modal = "newFile") });
}
items.push({ label: "刷新", icon: "refresh", onclick: refresh });
return items;
}
</script>
<div class="flex min-h-0 flex-1 flex-col">
<!-- 顶栏:返回 + 面包屑 + 动作 + 身份 -->
<header class="flex shrink-0 items-center gap-2 border-b border-line-soft bg-panel px-5 py-3">
{#if view === "files" || stack.length > 0}
<button class="btn btn-sm" onclick={goUp} title="返回上级">
<Icon name="arrowLeft" size={13} /> 返回
</button>
{/if}
<nav class="flex min-w-0 flex-1 items-center gap-1 text-[13px]">
<button
class="shrink-0 {view === 'nodes' && stack.length === 0 ? 'font-semibold text-ink' : 'text-ink-3 hover:text-ink'}"
onclick={goRoot}
>文件库</button>
{#each stack as n, i (n.id)}
<span class="text-line">/</span>
<button
class="truncate {view === 'nodes' && i === stack.length - 1
? 'font-semibold text-ink'
: 'text-ink-3 hover:text-ink'}"
onclick={() => goToDepth(i + 1)}
>{n.name}</button>
{/each}
{#if view === "files" && projectNode}
<span class="text-line">/</span>
<span class="truncate font-semibold text-ink">{projectNode.name}</span>
{/if}
</nav>
{#if view === "nodes" && canCreateHere}
<button class="btn btn-sm" onclick={() => openCreate("FOLDER", currentFolder?.id ?? null)}>
<Icon name="plus" size={13} /> 新建文件夹
</button>
<button class="btn btn-sm" onclick={() => openCreate("PROJECT", currentFolder?.id ?? null)}>
<Icon name="plus" size={13} /> 新建项目
</button>
{/if}
{#if view === "files" && projectCanEdit}
<button class="btn btn-sm" onclick={() => (modal = "newFile")}>
<Icon name="plus" size={13} /> 新建文件
</button>
{/if}
<button class="btn btn-sm" onclick={refresh} title="刷新"><Icon name="refresh" size={13} /></button>
<div class="ml-1 flex shrink-0 items-center gap-2 border-l border-line-soft pl-3">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</span>
<span class="max-w-[120px] truncate text-[12.5px] text-ink">{$me?.displayName ?? $me?.userId ?? ""}</span>
<button
class="rounded-lg border border-line-soft px-2 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink"
onclick={logout}
title="退出登录"
>退出</button>
</div>
</header>
<!-- 主体:网格 + 文件预览栏 -->
<div class="flex min-h-0 flex-1">
<main
class="flex-1 overflow-y-auto px-6 py-5"
role="presentation"
oncontextmenu={(e) => { e.preventDefault(); menu = { x: e.clientX, y: e.clientY, items: bgMenuItems() }; }}
>
{#if view === "nodes"}
{#if nodesError !== null}
<div class="py-10 text-center text-[13px] text-danger">{nodesError}</div>
{:else if children === null}
<div class="quiet py-10 text-center">加载中…</div>
{:else if children.length === 0}
<div class="quiet py-10 text-center">
{currentFolder === null ? "空文件库" : "空文件夹"}{canCreateHere ? " · 右键或点上方按钮新建" : ""}
</div>
{:else}
<div class="grid grid-cols-[repeat(auto-fill,minmax(118px,1fr))] gap-x-2 gap-y-4">
{#each children as n (n.id)}
<GridCard
kind={n.kind}
name={n.name}
meta={ROLE_LABEL[n.role]}
selected={selected === n.id}
onselect={() => (selected = n.id)}
onopen={() => openNode(n)}
oncontextmenu={(x, y) => (menu = { x, y, items: nodeMenuItems(n) })}
/>
{/each}
</div>
{/if}
{:else}
{#if filesError !== null}
<div class="py-10 text-center text-[13px] text-danger">{filesError}</div>
{:else if files === null}
<div class="quiet py-10 text-center">加载中…</div>
{:else if files.length === 0}
<div class="quiet py-10 text-center">空仓库{projectCanEdit ? " · 右键或点上方按钮新建文件" : ""}</div>
{:else}
<div class="grid grid-cols-[repeat(auto-fill,minmax(118px,1fr))] gap-x-2 gap-y-4">
{#each files as f (f.path)}
<GridCard
kind="FILE"
name={f.path}
meta="{f.size} B"
selected={selected === f.path}
onselect={() => (selected = f.path)}
onopen={() => previewFile(f)}
oncontextmenu={(x, y) => (menu = { x, y, items: fileMenuItems(f) })}
/>
{/each}
</div>
{/if}
{/if}
</main>
{#if view === "files" && $selectedFilePath && projectNode}
<section class="flex w-[46%] min-w-[420px] shrink-0 flex-col overflow-y-auto border-l border-line-soft bg-bg p-4">
<FileEditor
projectId={projectNode.id}
path={$selectedFilePath}
role={projectNode.role}
onchanged={() => void loadFiles()}
onclose={clearSelectedFile}
/>
</section>
{/if}
</div>
</div>
{#if menu}
<ContextMenu x={menu.x} y={menu.y} items={menu.items} onclose={() => (menu = null)} />
{/if}
{#if modal === "create"}
<Modal title={createKind === "FOLDER" ? "新建文件夹" : "新建项目"} onclose={() => (modal = null)}>
<div class="form-row">
<label class="form-label" for="gc-name">名称</label>
<input id="gc-name" class="input" bind:value={formName} placeholder={createKind === "FOLDER" ? "例如:物理" : "例如:微积分基础"} />
</div>
<div class="form-row">
<label class="form-label" for="gc-desc">简介(可选)</label>
<textarea id="gc-desc" rows="3" class="textarea" bind:value={formDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (modal = null)}>取消</button>
<button class="btn btn-primary disabled:opacity-50" onclick={submitCreate} disabled={saving}>
{saving ? "创建中…" : "创建"}
</button>
</div>
</Modal>
{/if}
{#if modal === "rename" && renameTarget}
<Modal title="重命名" onclose={() => (modal = null)}>
<div class="form-row">
<label class="form-label" for="gr-name">新名称</label>
<input id="gr-name" class="input" bind:value={formName} />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (modal = null)}>取消</button>
<button class="btn btn-primary disabled:opacity-50" onclick={submitRename} disabled={saving}>
{saving ? "保存中…" : "保存"}
</button>
</div>
</Modal>
{/if}
{#if modal === "grants" && detailNode}
<Modal maxW={880} title="授权管理 · {detailNode.name}" onclose={() => (modal = null)}>
<GrantsPanel node={detailNode} />
</Modal>
{/if}
{#if modal === "detail" && detailNode}
<Modal maxW={680} title="详情 · {detailNode.name}" onclose={() => (modal = null)}>
<OverviewPanel node={detailNode} />
</Modal>
{/if}
{#if modal === "newFile"}
<Modal title="新建文件" onclose={() => (modal = null)}>
<div class="form-row">
<label class="form-label" for="gf-path">路径</label>
<input id="gf-path" class="input font-mono" bind:value={newPath} placeholder="讲义/第一章.md" />
</div>
<div class="form-row">
<label class="form-label" for="gf-content">内容</label>
<textarea id="gf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (modal = null)}>取消</button>
<button class="btn btn-primary disabled:opacity-50" onclick={submitNewFile} disabled={saving}>
{saving ? "创建中…" : "创建"}
</button>
</div>
</Modal>
{/if}
+769
View File
@@ -0,0 +1,769 @@
<script lang="ts">
/**
* Group 管理面板。从已删除的 routes/adminPanels.ts `renderGroupsPanel`(747 行)
* 迁来(ADR-0029),功能与视觉逐条对齐:折叠树 / 组名过滤(命中项保留祖先链)/
* 归档组展示与恢复 / 右键菜单 / 面包屑 / 统计条 / 成员表(头像·openId·加入时间)。
*/
import { onMount } from "svelte";
import { page } from "$app/state";
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(async () => {
await loadGroups();
// 授权面板「成员」单元格跳转:?select=<groupId> 直接选中该组。
// 组不在列表(已归档且未开归档展示)时不动作,停留默认态。
const target = page.url.searchParams.get("select");
if (target !== null && groups.some((g) => g.id === target)) select(target);
});
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}
+47
View File
@@ -0,0 +1,47 @@
<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",
download: "M12 3v12m0 0 4-4m-4 4-4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2",
refresh: "M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6",
arrowLeft: "M19 12H5m0 0 6 6m-6-6 6-6",
info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-10v6m0-11v.5",
shield: "M12 3l8 3v6c0 4.5-3.2 7.7-8 9-4.8-1.3-8-4.5-8-9V6l8-3Z",
folder: "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",
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 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>
+147
View File
@@ -0,0 +1,147 @@
<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";
import NodeDetailPanel from "./NodeDetailPanel.svelte";
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);
let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
let newDesc = $state("");
async function loadRoots(): Promise<void> {
try {
const r = await api<{ nodes: NodeChild[] }>("/database/api/nodes");
roots = r.nodes;
treeError = null;
} catch (e) {
treeError = e instanceof Error ? e.message : String(e);
}
}
onMount(loadRoots);
$effect(() => {
void $treeVersion;
void loadRoots();
});
async function createRoot(): Promise<void> {
const name = newName.trim();
if (name === "") return;
try {
await api("/database/api/nodes", {
method: "POST",
body: {
parentId: null,
kind: newKind,
name,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
});
toastOk("已创建");
showCreateRoot = false;
newName = ""; newKind = "FOLDER"; newDesc = "";
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase());
</script>
<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="btn btn-sm" onclick={() => (showCreateRoot = true)}>
+ 根目录
</button>
{/if}
</div>
<div class="flex-1 overflow-y-auto px-2 py-2 text-[13px]">
{#if treeError}
<div class="px-3 py-6 text-center text-xs text-danger">{treeError}</div>
{:else if roots === null}
<div class="px-3 py-6 text-center text-xs text-ink-3">加载中…</div>
{:else if roots.length === 0}
<div class="px-3 py-6 text-center text-xs text-ink-3">
{$me?.isWebsiteAdmin ? "空文件库 · 点上方「+ 根目录」开始" : "文件库为空,请联系管理员创建根目录"}
</div>
{:else}
{#each roots as node (node.id)}
<TreeNode {node} depth={0} />
{/each}
{/if}
</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>
<!-- 主区 -->
<main class="flex-1 overflow-y-auto">
<NodeDetailPanel />
</main>
<!-- 右侧:文件预览/编辑栏(选中文件时出现) -->
{#if $selectedFilePath && $currentNode?.kind === "PROJECT"}
<section class="flex w-[46%] min-w-[420px] shrink-0 flex-col overflow-y-auto border-l border-line-soft bg-bg p-4">
<FileEditor
projectId={$currentNode.id}
path={$selectedFilePath}
role={$currentNode.role}
onchanged={bumpFiles}
onclose={clearSelectedFile}
/>
</section>
{/if}
</div>
{#if showCreateRoot}
<Modal title="新建根目录" onclose={() => (showCreateRoot = false)}>
<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="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="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="btn" onclick={() => (showCreateRoot = false)}>取消</button>
<button class="btn btn-primary" onclick={createRoot}>创建</button>
</div>
</Modal>
{/if}
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { onMount } from "svelte";
import { loadConfig, type AppConfig } from "./config.js";
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
try {
info = await loadConfig();
} catch {
loadFailed = true;
}
});
</script>
<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 tracking-wide text-ink">文件库</div>
<p class="mt-2.5 mb-8 text-center text-[13px] text-ink-3">课程资源与教研文件,一处安放,随处可查</p>
{#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="/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}
<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>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { title, onclose, children, maxW = 440 }: { title: string; onclose: () => void; children: Snippet; maxW?: number } = $props();
</script>
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/30 p-4"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<div class="max-h-[88vh] w-full overflow-y-auto rounded-2xl border border-line-soft bg-panel p-6 shadow-[0_4px_20px_rgba(26,26,24,.07)]" style="max-width:{maxW}px">
<div class="mb-4 text-[15px] font-semibold">{title}</div>
{@render children()}
</div>
</div>
@@ -0,0 +1,164 @@
<script lang="ts">
import { api } from "./api.js";
import { currentNode, breadcrumb, bumpTree, clearSelectedFile } from "./browser.js";
import { toastOk, toastErr } from "./stores.js";
import { ROLE_LABEL } from "./labels.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";
type Tab = "detail" | "files" | "grants";
let tab = $state<Tab>("detail");
let showCreateChild = $state(false);
let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
let newDesc = $state("");
const node = $derived($currentNode);
const crumbs = $derived($breadcrumb);
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";
clearSelectedFile();
});
async function createChild(): Promise<void> {
const name = newName.trim();
if (name === "" || node === null) return;
try {
await api("/database/api/nodes", {
method: "POST",
body: {
parentId: node.id,
kind: newKind,
name,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
});
toastOk("已创建");
showCreateChild = false;
newName = ""; newKind = "FOLDER"; newDesc = "";
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function renameNode(): Promise<void> {
if (node === null) return;
const name = prompt("新名称", node.name);
if (name === null) return;
try {
await api(`/database/api/nodes/${node.id}`, { method: "PATCH", body: { name } });
toastOk("已重命名");
bumpTree();
currentNode.update((n) => (n ? { ...n, name } : n));
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function deleteNode(): Promise<void> {
if (node === null || !confirm(`确认删除「${node.name}」?软删除后不可见。`)) return;
try {
await api(`/database/api/nodes/${node.id}`, { method: "DELETE" });
toastOk("已删除");
currentNode.set(null);
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
{#if node === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">从左侧选择一个文件夹或项目</div>
{:else}
<div class="mx-auto max-w-[1400px] px-9 py-9">
<div class="mb-2 text-[12.5px] text-ink-3">
{#each crumbs as c, i (i)}
{#if i > 0}<span class="mx-1 text-line">/</span>{/if}
<span>{c.name ?? "…"}</span>
{/each}
</div>
<div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-2 text-[17px] font-semibold text-ink">
{node.name}
<span class="tag">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
<span class="tag !border-line !text-ink-2">{ROLE_LABEL[node.role]}</span>
</div>
<div class="flex gap-1.5">
{#if canEdit && node.kind === "FOLDER"}
<button class="btn" onclick={() => (showCreateChild = true)}>
<Icon name="plus" size={13} /> 新建子节点
</button>
{/if}
{#if canManage}
<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>
<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}
<OverviewPanel {node} />
{#if node.kind === "FOLDER"}
<div class="quiet mt-3.5">文件夹是透明组织节点,点左侧树展开以浏览子内容。</div>
{/if}
{/if}
</div>
{/if}
{#if showCreateChild && node}
<Modal title="新建子节点" onclose={() => (showCreateChild = false)}>
<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="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="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="btn" onclick={() => (showCreateChild = false)}>取消</button>
<button class="btn btn-primary" onclick={createChild}>创建</button>
</div>
</Modal>
{/if}
@@ -0,0 +1,129 @@
<script lang="ts">
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { currentNode } from "./browser.js";
import { ROLE_LABEL } from "./labels.js";
import type { ExportJob, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
let { node }: { node: NodeDetail } = $props();
let showEditDesc = $state(false);
let descDraft = $state("");
let exportJob = $state<ExportJob | null>(null);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const roleLabel = $derived(ROLE_LABEL[node.role]);
$effect(() => {
void node.id;
exportJob = null;
});
function openEditDesc(): void {
descDraft = node.description ?? "";
showEditDesc = true;
}
async function saveDesc(): Promise<void> {
const description = descDraft.trim();
try {
await api(`/database/api/nodes/${node.id}`, {
method: "PATCH",
body: { description: description === "" ? null : description },
});
toastOk("简介已保存");
showEditDesc = false;
const next = description === "" ? null : description;
currentNode.update((n) => (n && n.id === node.id ? { ...n, description: next } : n));
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function submitExport(): Promise<void> {
try {
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
method: "POST",
body: { target: "manifest" },
});
toastOk("导出已提交");
void pollExport(r.jobId);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function pollExport(jobId: string): Promise<void> {
for (;;) {
await new Promise((r) => setTimeout(r, 800));
try {
const job = await api<ExportJob>(`/database/api/exports/${jobId}`);
exportJob = job;
if (job.status === "DONE" || job.status === "FAILED") break;
} catch {
break;
}
}
}
</script>
<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">
{#if node.description}
{node.description}
{:else}
<span class="italic text-ink-3">暂无简介</span>
{/if}
{#if canEdit}
<button class="btn ml-2.5 !px-2.5 !py-0.5 align-middle !text-[11.5px]" onclick={openEditDesc}>编辑</button>
{/if}
</div>
</div>
<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).toLocaleString("zh-CN")}</b></div>
<div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
</div>
<!-- 导出只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
{#if node.kind === "PROJECT"}
<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="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="btn" onclick={() => (showEditDesc = false)}>取消</button>
<button class="btn btn-primary" onclick={saveDesc}>保存</button>
</div>
</Modal>
{/if}
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import { toasts } from "./stores.js";
</script>
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{#each $toasts as t (t.id)}
<div class="max-w-[340px] rounded-lg px-4 py-2 text-sm text-white {t.kind === 'err' ? 'bg-[#7E2C26]' : 'bg-[#333230]'}">
{t.message}
</div>
{/each}
</div>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import TreeNode from "./TreeNode.svelte";
import { api } from "./api.js";
import { expanded, currentNode, breadcrumb, toggleExpanded, treeVersion } from "./browser.js";
import { toastErr } from "./stores.js";
import { ROLE_LABEL } from "./labels.js";
import type { BreadcrumbEntry, NodeChild, NodeDetail } from "./types.js";
let { node, depth }: { node: NodeChild; depth: number } = $props();
let children = $state<NodeChild[] | null>(null);
const isOpen = $derived($expanded.has(node.id));
const isSelected = $derived($currentNode?.id === node.id);
// 树刷新信号(增/删/移/重命名)→ 失效子节点缓存,展开状态下随之重载
$effect(() => {
void $treeVersion;
children = null;
});
$effect(() => {
if (isOpen && node.kind === "FOLDER" && children === null) {
api<{ nodes: NodeChild[] }>(`/database/api/nodes?parentId=${encodeURIComponent(node.id)}`)
.then((r) => (children = r.nodes))
.catch((e) => toastErr(e instanceof Error ? e.message : String(e)));
}
});
async function select(): Promise<void> {
if (node.kind === "FOLDER") toggleExpanded(node.id);
try {
const [detail, crumb] = await Promise.all([
api<{ node: NodeDetail }>(`/database/api/nodes/${node.id}`),
api<{ breadcrumb: BreadcrumbEntry[] }>(`/database/api/nodes/${node.id}/breadcrumb`),
]);
currentNode.set(detail.node);
breadcrumb.set(crumb.breadcrumb);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<div>
<div
class="tree-item flex cursor-pointer items-center gap-1 rounded-lg px-1.5 py-1.5 select-none {isSelected ? 'bg-selected' : 'hover:bg-hover'}"
role="button"
tabindex="0"
onclick={select}
onkeydown={(e) => e.key === "Enter" && select()}
>
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-ink-3">
{#if node.kind === "FOLDER"}
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
{#if isOpen}<path d="M6 9l6 6 6-6z" />{:else}<path d="M9 6l6 6-6 6z" />{/if}
</svg>
{/if}
</span>
<span class="flex h-4 w-4 shrink-0 items-center justify-center {node.kind === 'PROJECT' ? 'text-ink' : 'text-ink-3'}">
{#if node.kind === "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>
{:else}
<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>
{/if}
</span>
<span class="truncate">{node.name}</span>
{#if node.role !== "MANAGE"}
<span class="ml-auto pr-1 text-[10px] text-ink-3">{ROLE_LABEL[node.role]}</span>
{/if}
</div>
{#if node.kind === "FOLDER" && isOpen}
<div class="ml-[15px] border-l border-guide pl-1">
{#if children === null}
<div class="px-3 py-1.5 text-xs text-ink-3"></div>
{:else}
{#each children as child (child.id)}
<TreeNode node={child} depth={depth + 1} />
{/each}
{/if}
</div>
{/if}
</div>
+49
View File
@@ -0,0 +1,49 @@
/** 与 /database/api/* 的约定一致;401 时清空会话(回到登录视图)。 */
import { me } from "./stores.js";
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "ApiError";
}
}
export class UnauthenticatedError extends Error {
constructor() {
super("unauthenticated");
this.name = "UnauthenticatedError";
}
}
interface RequestOpts {
readonly method?: string;
readonly body?: unknown;
}
export async function api<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
const res = await fetch(path, {
credentials: "same-origin",
method: opts.method ?? "GET",
...(opts.body !== undefined
? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts.body) }
: {}),
});
if (res.status === 401) {
me.set(null);
throw new UnauthenticatedError();
}
if (res.status === 204) return null as T;
const text = await res.text();
const data = text === "" ? null : (JSON.parse(text) as unknown);
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | null)?.error ?? {};
throw new ApiError(res.status, err.code ?? "unknown", err.message ?? res.statusText, err as Record<string, unknown>);
}
return data as T;
}
+34
View File
@@ -0,0 +1,34 @@
import { writable } from "svelte/store";
import type { BreadcrumbEntry, NodeDetail } from "./types.js";
/** 树展开集合 / 当前选中节点 / 面包屑 / 树刷新计数。 */
export const expanded = writable<Set<string>>(new Set());
export const currentNode = writable<NodeDetail | null>(null);
export const breadcrumb = writable<BreadcrumbEntry[]>([]);
export const treeVersion = writable(0);
/** 右侧预览栏:当前选中文件路径(项目内);切换节点时清空。 */
export const selectedFilePath = writable<string | null>(null);
/** 文件列表刷新计数(编辑器保存/删除后 bump,列表随之重载)。 */
export const filesVersion = writable(0);
export function bumpTree(): void {
treeVersion.update((v) => v + 1);
}
export function bumpFiles(): void {
filesVersion.update((v) => v + 1);
}
export function clearSelectedFile(): void {
selectedFilePath.set(null);
}
export function toggleExpanded(id: string): void {
expanded.update((set) => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
+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;
}
+10
View File
@@ -0,0 +1,10 @@
/** 展示层文案(与 API 枚举值解耦;传参仍用英文枚举)。 */
import type { Role } from "./types.js";
/** 文件库权限级(契约 8.1 MANAGE>EDIT>VIEW)的中文展示名。 */
export const ROLE_LABEL: Record<Role, string> = {
VIEW: "只读",
EDIT: "可编辑",
MANAGE: "可管理",
};
+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);
}
+26
View File
@@ -0,0 +1,26 @@
import { writable } from "svelte/store";
import type { MeResponse } from "./types.js";
/** 当前登录身份;null = 未登录(显示登录视图)。 */
export const me = writable<MeResponse | null>(null);
export const authChecked = writable(false);
export interface ToastItem {
readonly id: number;
readonly message: string;
readonly kind: "info" | "err";
}
let nextToastId = 1;
export const toasts = writable<ToastItem[]>([]);
export function toast(message: string, kind: ToastItem["kind"] = "info"): void {
const id = nextToastId++;
toasts.update((list) => [...list, { id, message, kind }]);
setTimeout(() => {
toasts.update((list) => list.filter((t) => t.id !== id));
}, 3600);
}
export const toastOk = (m: string): void => toast(m, "info");
export const toastErr = (m: string): void => toast(m, "err");
+168
View File
@@ -0,0 +1,168 @@
/** 与后端 /database/api/* 响应形状对齐。 */
export type NodeKind = "FOLDER" | "PROJECT";
export type Role = "VIEW" | "EDIT" | "MANAGE";
export interface NodeChild {
readonly id: string;
readonly parentId: string | null;
readonly kind: NodeKind;
readonly name: string;
readonly role: Role;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface BreadcrumbEntry {
readonly depth: number;
readonly id: string | null;
readonly name: string | null;
readonly kind: NodeKind;
/** 该节点对调用者的 effective role;无 View 为 null。 */
readonly role: Role | null;
}
export interface NodeDetail {
readonly id: string;
readonly parentId: string | null;
readonly kind: NodeKind;
readonly name: string;
readonly description: string | null;
readonly role: Role;
readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED";
readonly createdAt: string;
readonly updatedAt: string;
}
export interface MeResponse {
readonly userId: string;
readonly isWebsiteAdmin: boolean;
/** 侧栏身份区显示用;后端取不到 User 行时回落为 userId。 */
readonly displayName: string;
readonly avatarUrl: string | null;
}
export interface FileEntry {
readonly path: string;
readonly size: number;
}
export type FileContentEncoding = "utf8" | "base64";
export interface FileContent {
readonly path: string;
readonly version: string;
readonly encoding: FileContentEncoding;
readonly content: string;
readonly size: number;
}
export interface VersionInfo {
readonly version: string;
readonly message: string;
readonly author?: string;
readonly committedAt: string;
}
export interface ExportJob {
readonly id: string;
readonly nodeId: string;
readonly target: string;
readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
readonly error: string | null;
readonly createdAt: string;
}
export interface GroupSearchResult {
readonly id: string;
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;
/** 主体显示名(用户 displayName / 组 name);主体已删为 null,展示回落 principalId。 */
readonly principalName: string | null;
/** USER 主体的飞书 openId;GROUP 或主体已删为 null。 */
readonly principalOpenId: string | null;
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/bin)。 */
export interface BinEntry {
readonly id: string;
readonly parentId: string | null;
readonly kind: NodeKind;
readonly name: string;
readonly deletedAt: string;
}
/** 管理后台概览统计(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;
}
+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,51 @@
<script lang="ts">
/** 老师端。未登录显示登录卡片;登录后是带左栏导航的文件库(ADR-0031)。 */
import { onMount } from "svelte";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import LoginView from "$lib/LoginView.svelte";
import GridLibraryView from "$lib/GridLibraryView.svelte";
import BinView from "$lib/BinView.svelte";
import Icon from "$lib/Icon.svelte";
onMount(loadSession);
type View = "library" | "bin";
let view = $state<View>("library");
const tabs: ReadonlyArray<readonly [View, string, "layers" | "trash"]> = [
["library", "文件库", "layers"],
["bin", "回收站", "trash"],
];
</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">
<!-- 左栏导航(ADR-0031) -->
<nav class="flex w-[168px] shrink-0 flex-col gap-0.5 border-r border-line-soft bg-sidebar px-2.5 py-3.5">
{#each tabs as [id, label, icon] (id)}
<button
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] transition {view === id
? 'bg-selected font-semibold text-ink'
: 'text-ink-2 hover:bg-hover'}"
onclick={() => (view = id)}
>
<span class="text-ink-3"><Icon name={icon} size={15} /></span>
{label}
</button>
{/each}
</nav>
{#if view === "library"}
<GridLibraryView />
{:else}
<BinView />
{/if}
</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,200 @@
<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 { page } from "$app/state";
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";
import Icon from "$lib/Icon.svelte";
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);
// 列表过滤;授权面板跳转会带 ?q=<userId>,以此为初始过滤词。
let filterText = $state(page.url.searchParams.get("q") ?? "");
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)}`);
/** 按显示名 / userId / openId 过滤(纯前端;成员全量在手)。 */
const shown = $derived.by((): OrgMember[] | null => {
if (members === null) return null;
const q = filterText.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),
);
});
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="mb-2.5 flex items-center justify-between gap-2">
<div class="section-title">成员列表</div>
<div class="relative w-[260px] shrink-0">
<span class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input
class="input w-full !py-[5px] !pl-8 !text-[12.5px]"
placeholder="过滤:名称 / userId / openId"
bind:value={filterText}
/>
</div>
</div>
{#if error}
<div class="py-3 text-[12.5px] text-danger">{error}</div>
{:else if shown === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else if shown.length === 0}
<div class="quiet py-[18px] text-center">
{filterText.trim() === "" ? "暂无成员" : `无匹配「${filterText.trim()}」的成员`}
</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>角色</th>
<th></th>
</tr>
</thead>
<tbody>
{#each shown 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;
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}
}

Some files were not shown because too many files have changed in this diff Show More