Compare commits

...

119 Commits

Author SHA1 Message Date
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
hongjr03 78f94fcc8c fix(ci): sync npm lockfiles so fleet deploy npm ci succeeds
Hub and admin-web package-lock.json were missing @emnapi/* entries that
npm 11 on the Alpha host requires, so deploy_fleet_release failed at
npm ci. Regenerate both lockfiles and retry incomplete release trees.
2026-07-15 00:18:22 +08:00
hongjr03 7269480abb ci: deploy Hub admin SPA fleets via Gitea Actions
Add deploy_fleet_release.sh and a workflow that rolls immutable Hub
releases (including admin-web) to educraft-dev on push/PR and educraft
on main/tags, selecting silos by HUB_PUBLIC_BASE_URL middle domain.
2026-07-15 00:14:22 +08:00
hongjr03 1d2f4657ba fix(org): match listMyProjects grants by principal pair
Filtering permission grants with separate principalType/principalId IN
lists matched cross-product rows (e.g. TEAM + user id). Use OR of exact
(type, id) pairs so members only see projects their principals hold.
2026-07-14 23:55:20 +08:00
hongjr03 4ad0259193 fix(admin): let project MANAGE holders list teams for grants
GET /teams was org-admin only while team-access mutations require project
MANAGE, so members with MANAGE saw an empty grant picker. Open the
read-only team list to any org member and load it in the project page
whenever the actor can manage the project.
2026-07-14 23:55:16 +08:00
hongjr03 4e2699d0a5 fix(admin): serve built SPA and include it in release builds
registerStaticSpa was never mounted, so production only exposed org-admin
APIs. Wire it after auth/API routes, fold admin:build into npm run build,
and install admin-web deps during deploy so admin-web/build ships with the
release for same-origin /admin/*.
2026-07-14 23:54:47 +08:00
ChickenPige0n 153d74d033 feat: 更新容量策略页面的维度标签和逻辑分组 2026-07-14 22:54:37 +08:00
ChickenPige0n 080efa70c5 feat(admin): gate project surfaces behind permission grants for members
The org admin SPA was org-admin only: every project route used
requireOrgRole, so a plain MEMBER could not reach the projects they held
a project grant on, and an org OWNER/ADMIN could mutate any project
without holding the project's `manage` grant. That contradicts ADR-0004
(spec `Permission.lean`): org role is not a project authorization root,
and the only out-of-role override is platform-admin force-release
(`RequiresAdmin`), not org admin.

Add `requireProjectPermission` (guards.ts): resolve any org member, bind
the project to their org, then check the PermissionGrant authorizer.
`allowOrgAdminOversight=true` lets OWNER/ADMIN through for *read*
oversight only; mutations pinned to `collaborator.manage`
(grant/revoke team-access) pass `allowOrgAdminOversight=false`, so an org
admin still needs the project MANAGE grant to mutate access. The project
detail GET now also returns `actorIsOrgAdmin` and `actorCanManageProject`
so the SPA can render mutation controls only for entitled actors.

Add a member-facing project surface:
- `GET /api/org/:orgSlug/my-projects` + `listMyProjects` resolve the
  actor's principals and return the projects with a READ+ grant.
- The SPA routes members (non-admin) to the projects page instead of the
  admin overview, renders a member project shell on project routes, shows
  a `我的项目` list for members and the full folder explorer for admins.
- The project detail page gates rename/archive/bind/sessions behind org
  admin and the grant/revoke UI behind `actorCanManageProject`.
- The denied panel now points members at their authorized projects.

Update admin-members-teams integration test: seed the owner with a MANAGE
grant on the test project so the org-owner flow still passes the new
project-level gate on team-access grant/revoke.
2026-07-14 21:25:18 +08:00
ChickenPige0n ab9dfad53a feat: org capacity policy admin surface
ADR-0022 / Spec.System.Capacity pins layered capacity limits: a platform
ceiling per dimension is unbreakable, and each organization may only set a
lower `organizationLimit`. The effective limit is the minimum of the two
(`LayeredLimit.effective`); dimensions with no org override fall back to
the platform ceiling. No dimension may be unlimited (a ceiling must exist
before an org limit can be set, `LayeredLimit.Valid`).

Add the backend: the pinned 23 CapacityDimension set + labels
(src/capacity/dimensions.ts), platform ceilings sourced from existing
runtime env vars plus `HUB_CEILING_<DIMENSION>` (src/capacity/ceilings.ts),
the OrganizationCapacityPolicy prisma model + migration, the
getCapacityPolicy/setCapacityPolicy service enforcing LayeredLimit.Valid,
and org-admin GET/PUT /api/org/:orgSlug/capacity-policy routes wired into
the org route tree.

Add the admin-web surface: CapacityDimension/CapacityPolicyView api client
types, capacityPolicy/setCapacityPolicy methods, a `容量` nav entry, and a
capacity page that lists every dimension with its platform ceiling (or
`未配置` when unset), an org-limit input (disabled until a ceiling exists),
and a live effective-value column. Saving sends the partial limits map;
the service rejects values above the ceiling or for unconfigured dimensions.
2026-07-14 21:23:41 +08:00
ChickenPige0n adce8fb6f5 chore: drop legacy PlatformRoleAssignment model
ADR-0023 / Spec.System.PlatformAdministration pins the platform
administrator as a separate identity/session/audit control plane,
intentionally not modeled in alpha (ADR-0025). The legacy
PlatformRoleAssignment / PlatformRole{ADMIN,TEACHER} table had no runtime
reader (no guard, route, or service queried it for an authorization
decision) and ADR-0023 requires it to be replaced before the platform
panel ships.

Drop the model, the PlatformRole enum, the User.platformRoles relation,
and the migration. Stop seeding platformRoles in externalSync principal
ingestion and the integration test helper. Update the doc comments on
OrganizationMembership and PermissionRole to point at the platform-admin
control plane instead of the dropped model.

The 20260709180000_organization_tenant_root backfill only referenced
PlatformRoleAssignment in a one-time INSERT...SELECT; no persistent
object references it, so dropping the table is safe after that migration.
2026-07-14 21:20:50 +08:00
ChickenPige0n b574ef871c refactor(org): keep archived-team grants as dead rows
Archiving a team no longer cascade-revokes its active TEAM->PROJECT grants
and memberships. The archived flag alone makes the team principal
unresolvable (permissions/principals.ts refuses archived teams), so the
dead grant/membership rows confer no access. listProjectTeamAccess now
filters archived teams out of the project view instead of relying on a
revokedAt cascade, and the org-admin teams page confirm copy is updated.
archiveTeam drops the revokedGrants count from its return shape.

ADR-0019 / Spec.System.Organization: principal resolution, not grant
mutation, is the access boundary for archived teams.
2026-07-14 21:17:20 +08:00
ChickenPige0n cbe569d7e6 style(admin-web): apply Prettier formatting
Run `prettier --write .` across all source files. Changes are purely
formatting: trailing commas, line wrapping at 120 chars, import
reordering, and CSS whitespace. No logic changes. Verified with
`prettier --check .` and `svelte-check` (0 errors, 0 warnings).
2026-07-14 19:19:13 +08:00
ChickenPige0n ae870a9b73 chore(admin-web): add Prettier formatter with Svelte support
Add prettier + prettier-plugin-svelte as devDependencies with a
.prettierrc.json matching the existing code style (tabs, single quotes,
semicolons, trailing commas, 120 char width). Add .prettierignore for
build artifacts and lockfile. Wire up `npm run format` (write) and
`npm run format:check` (CI gate) scripts.

Prettier is chosen over Biome because its prettier-plugin-svelte correctly
preserves <script> block indentation per Svelte convention; Biome's
experimental Svelte formatter flattens script-block indentation to column 0,
producing inconsistent output.
2026-07-14 19:19:13 +08:00
ChickenPige0n 18acc823c3 feat(admin-web): add Feishu Application Connection admin page
ADR-0021 pins the organization<->Feishu application binding to 1:1 and
the backend already exposes
  GET    /api/org/:orgSlug/feishu-application-connection
  PUT    /api/org/:orgSlug/feishu-application-connection  (rotate/create)
  DELETE /api/org/:orgSlug/feishu-application-connection  (disable)
backed by FeishuApplicationConnectionService with versioned envelopes
(ADR-0024). The org-admin SPA had no surface for it, so the only
connection type the spec requires was unmanageable from the admin UI.

Add a Feishu page that reads the current connection (status, redacted
app fingerprint, active version, updatedAt), rotates credentials with
appId/appSecret/botOpenId (+ optional verificationToken/encryptKey) as
the backend requires, and disables with confirmation. Add the matching
api client (feishuApplication / rotateFeishuApplication /
disableFeishuApplication), a feishu nav icon and a nav entry.
2026-07-14 19:13:29 +08:00
ChickenPige0n 8d2e0cb2c6 fix(admin-web): align provider surface with backend and ADR-0024
The org-admin provider page was a stale prototype wired to a removed
singular /provider-connection endpoint. It contradicted the pinned
invariants in several ways:

- It documented a process-env fallback for platform-managed credentials,
  but ADR-0024 / Spec.System.Organization pins the resolver fail-closed
  with no process-global key fallback.
- It exposed a BYOK<->PLATFORM_MANAGED mode toggle to org admins, but
  ADR-0021 makes platform-managed connections platform-admin owned; the
  org-side API (requireByokActor) rejects mutating them.
- It modeled one connection per org, while OrganizationProviderConnection
  is keyed by (org, providerId) and the backend exposes a list plus a
  per-providerId BYOK rotation.
- Its HTTP contract (/provider-connection, {baseUrl, hasAuthToken}) did
  not match the real backend (/provider-connections + /:providerId,
  {status, activeVersion, keyId}).
- It dangled a pointer to role/model pages that do not exist in the SPA;
  roles/skills are managed via the CLI.

Rewrite the page to list connections, show status/version/keyId, and
rotate BYOK credentials per providerId with baseUrl + authToken (+ optional
anthropicApiKey) as the backend requires. Platform-managed rows render
read-only. Drop the fallback copy and the dangling pointer. Replace the
singular ProviderConnection API client with providerConnections /
rotateProviderConnection and remove the now-unused PROVIDER_MODES constant.
2026-07-14 19:13:29 +08:00
ChickenPige0n 3ae0cc3e60 feat: update .gitignore, remove unused API interfaces, and add local dev scripts for bootstrap and seeding connections 2026-07-14 19:13:29 +08:00
ChickenPige0n b1ddf32238 chore: drop superseded admin-panel agent-config prototype after rebase onto main
main now ships the canonical org-scoped agent-config implementation
(OrganizationAgentRole/OrganizationAgentSkill + hub/src/agent/configuration.ts
+ hub/src/agent/skillStore.ts per ADR-0018, and envelope-encrypted
OrganizationProviderConnection per ADR-0024). The earlier admin-panel
prototype (OrgModel/OrgRole/simple ProviderConnection baseUrl+authToken,
modelRoutes.ts, agentConfig.ts, migration 20260710120000, admin-web
models/roles pages) is superseded and clashes with main's schema; drop it.

Follow-up still needed: rewire admin-web SPA to the new config APIs
(+layout.svelte nav still lists models/roles, RoleCard.svelte unused).
2026-07-14 19:13:28 +08:00
ChickenPige0n 9c33a4e9b9 feat: validate team slug format and improve error handling in team creation 2026-07-14 19:13:28 +08:00
ChickenPige0n acf7ae0cd7 style: update surface colors and typography for improved contrast and readability across various components
- Changed text colors from surface-400 to surface-600 and surface-500 to surface-700 for better visibility in multiple Svelte files.
- Updated background and border colors in app.css for a more cohesive industrial design.
- Adjusted font weights and sizes for headings, labels, and buttons to enhance clarity and user experience.
- Refined styles for tables, badges, and buttons to align with the new design language.
- Added new styles for input fields and switches to maintain consistency in the UI.
2026-07-14 19:13:27 +08:00
ChickenPige0n 0968545b5a refactor(admin-web): polish Chinese UI and bits-ui controls
Map roles to Chinese labels, remove ADR/spec wording from the surface, and replace native selects/checkboxes/modals with bits-ui Select, Checkbox, Switch, Dialog, Label, and Collapsible.
2026-07-14 19:13:27 +08:00
ChickenPige0n 552c1c353e feat: add org admin SPA for models, roles and provider
Introduce admin-web (Skeleton/SvelteKit), Prisma models for provider connection / OrgModel / OrgRole, DB-backed runtime settings, and admin API routes so org admins can manage agent configuration end-to-end.
2026-07-14 19:13:14 +08:00
sjfhsjfh 461d2e89b0 chore: Merge origin/main: hub v0.0.23-v0.0.25 into main with spec-rewrite 2026-07-14 15:44:21 +08:00
hongjr03 93f252b177 chore: release v0.0.25 2026-07-13 17:05:47 +08:00
hongjr03 2211beb42c fix: move folder creation into project move flow 2026-07-13 17:05:45 +08:00
hongjr03 2f0e0f2cd7 chore: release v0.0.24 2026-07-13 16:52:51 +08:00
hongjr03 69837bd50c feat: redesign Feishu project console 2026-07-13 16:52:45 +08:00
hongjr03 82f57317df feat: archive bindings on Feishu lifecycle events 2026-07-13 15:53:28 +08:00
hongjr03 d94cc787b2 Revert "fix: import legacy projects without wrapper folder"
This reverts commit 4f8df12fb0.
2026-07-13 15:28:59 +08:00
hongjr03 4f8df12fb0 fix: import legacy projects without wrapper folder 2026-07-13 15:27:57 +08:00
sjfhsjfh 3ebe4b754d refactor(spec): clean prose patterns across all modules
Remove filler/redundant patterns: 钉死/钉, 本模块, likec4 画不出/画得出,
臆造, 散文, 分歧点测试, 纯 plumbing, 恰好, 留白, 宪法第N条, 刻意.
No code definitions changed, only doc comments.
2026-07-13 11:26:23 +08:00
sjfhsjfh 3fa6a5a5a5 fix(spec): replace @Claude with @bot in Prelude and Run 2026-07-12 18:55:52 +08:00
sjfhsjfh be4260bcd0 refactor(spec): move AgentRole/Run/Memory/AgentSurface into System/Agent/ subdir 2026-07-12 18:43:11 +08:00
sjfhsjfh a4449f03c4 chore: ignore .env 2026-07-12 18:38:45 +08:00
sjfhsjfh 01bc20d25f feat(spec): add AgentRole, AgentSkill, RoleSkillBinding (ADR-0017/0018) 2026-07-12 18:38:17 +08:00
sjfhsjfh 38c3231190 refactor(spec): generalize Feishu to Connections
- Connections/Prelude.lean: ConnectionProvider 枚举 (当前仅飞书)
- Connections/Feishu.lean: FeishuAppBinding + FeishuProfile
- Connections.lean: ConnectionBinding/ConnectionProfile inductive
- Organization.feishu → connections: List ConnectionBinding
- User.feishu → connections: List ConnectionProfile
- 删除 FeishuConnection.lean
2026-07-12 15:54:16 +08:00
sjfhsjfh 63416e06ea refactor(spec): move FeishuProfile to FeishuConnection, clean docs
- FeishuProfile 从 User.lean 移到 FeishuConnection.lean
- User.lean 只留用户创建路径声明
- 清理所有 doc comment
2026-07-12 09:33:33 +08:00
sjfhsjfh 39bd2c9ff7 feat(spec): pin org-feishu app binding to 1:1
- FeishuConnection.lean: FeishuAppBinding (appId + appSecretEnvelope)
- Organization.feishu: Option FeishuAppBinding (Option 自带 1:1)
- 删除 FeishuConnectionId (不再需要游离类型)
- FeishuProfile 删除 connection 字段 (由 org 隐含)
2026-07-12 09:29:41 +08:00
sjfhsjfh e17e038232 feat(spec): add FeishuUserId to FeishuProfile
飞书 user_id 是租户内身份,换应用不变;open_id 是应用内身份,换应用即变。
两者都存:user_id 更稳定,open_id 是 API 调用句柄。
2026-07-12 09:26:13 +08:00
sjfhsjfh 678bc9f56c refactor(spec): tighten prose, replace jargon
- 角色格→角色体系 (3处)
- 租户根/tenant root→租户 (3处)
- 清理 Hierarchy/Organization/System 的 doc 注释
2026-07-12 09:23:39 +08:00
hongjr03 07aa10ef27 chore: release v0.0.23 2026-07-12 02:42:49 +08:00
hongjr03 816af1abdb feat: add paginated project discovery 2026-07-12 02:40:53 +08:00
sjfhsjfh 3a50ed0ce2 feat(spec): add three-tier subject hierarchy and org role lattice
- Hierarchy.lean: Platform/Organization/User struct, 三层主体层级
- User.lean: FeishuProfile, 飞书身份是绑定不是本体
- User struct: id/displayName/passwordHash/feishu
- Organization.lean: OrganizationRole(owner/admin/member) + 成员管理规则
- Prelude.lean: UserId/FeishuOpenId/FeishuConnectionId

实现偏离: spec 钉 User 为独立实体, 实现 User.id 由飞书身份派生

lake build 35/35 全绿
2026-07-12 00:21:13 +08:00
hongjr03 53d372e29b fix: report untracked legacy symlinks 2026-07-11 23:37:25 +08:00
303 changed files with 31657 additions and 3225 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
name: checker check
# 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
# (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a
# spec-to-implementation conformance gate — implementations align to the Lean
# contract by human review, not by CI. See the repo README.
# This is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). There is no
# decision-to-implementation conformance gate — implementations align to the
# ADRs by human review, not by CI. See the repo README.
on:
push:
+126
View File
@@ -0,0 +1,126 @@
name: deploy admin (hub fleet)
# Rolls Hub releases that include the org-admin SPA (`admin-web` → registerStaticSpa)
# onto the managed Alpha host. Two fleets share one machine and are separated by
# the middle DNS label of each Silo's public URL:
#
# dev → https://<slug>.educraft-dev.paradigm-edu.net (every push + PR)
# prod → https://<slug>.educraft.paradigm-edu.net (main + tags)
#
# Example prod tenant:
# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100
#
# Required Gitea secret:
# DEPLOY_SSH_KEY — private key for root@39.107.254.4
#
# Optional secrets / vars (defaults match NEW_SILO_RUNBOOK):
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_PORT, DEPLOY_BASE
# vars.ALLOW_EMPTY_FLEET=1 — green when the fleet has no silos yet
on:
push:
paths:
- "hub/**"
- ".gitea/workflows/deploy-admin.yml"
pull_request:
paths:
- "hub/**"
- ".gitea/workflows/deploy-admin.yml"
workflow_dispatch:
inputs:
fleet:
description: "Which fleet to deploy (dev | prod | both)"
required: true
default: both
allow_empty_fleet:
description: "Succeed if no silo matches (1/0)"
required: false
default: "0"
concurrency:
group: deploy-admin-host
cancel-in-progress: false
jobs:
deploy-dev:
name: deploy fleet dev (educraft-dev)
runs-on: ubuntu-latest
if: >
github.event_name == 'pull_request' ||
(github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) ||
(github.event_name == 'workflow_dispatch' &&
(github.event.inputs.fleet == 'dev' || github.event.inputs.fleet == 'both'))
steps:
- uses: actions/checkout@v5
- name: Install rsync + ssh
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
- name: Deploy Hub + admin SPA to educraft-dev fleet
env:
CPH_FLEET: dev
PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }}
PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }}
PLATFORM_DEPLOY_RELEASE: ${{ github.sha }}
ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }}
DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then
echo "missing secret DEPLOY_SSH_KEY" >&2
exit 1
fi
key="$(mktemp)"
trap 'rm -f "$key"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key"
chmod 600 "$key"
export PLATFORM_DEPLOY_SSH_KEY="$key"
# Apply managed-host defaults when secrets are unset.
export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}"
export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
bash hub/deploy/deploy_fleet_release.sh
deploy-prod:
name: deploy fleet prod (educraft)
runs-on: ubuntu-latest
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) ||
(github.event_name == 'workflow_dispatch' &&
(github.event.inputs.fleet == 'prod' || github.event.inputs.fleet == 'both'))
steps:
- uses: actions/checkout@v5
- name: Install rsync + ssh
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
- name: Deploy Hub + admin SPA to educraft fleet
env:
CPH_FLEET: prod
PLATFORM_DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
PLATFORM_DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
PLATFORM_DEPLOY_PORT: ${{ secrets.DEPLOY_SSH_PORT }}
PLATFORM_DEPLOY_BASE: ${{ secrets.DEPLOY_BASE }}
PLATFORM_DEPLOY_RELEASE: ${{ github.sha }}
ALLOW_EMPTY_FLEET: ${{ github.event.inputs.allow_empty_fleet || vars.ALLOW_EMPTY_FLEET || '0' }}
DEPLOY_SSH_KEY_BODY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_SSH_KEY_BODY:-}" ]; then
echo "missing secret DEPLOY_SSH_KEY" >&2
exit 1
fi
key="$(mktemp)"
trap 'rm -f "$key"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY_BODY" >"$key"
chmod 600 "$key"
export PLATFORM_DEPLOY_SSH_KEY="$key"
export PLATFORM_DEPLOY_HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
export PLATFORM_DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
export PLATFORM_DEPLOY_PORT="${PLATFORM_DEPLOY_PORT:-22}"
export PLATFORM_DEPLOY_BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
bash hub/deploy/deploy_fleet_release.sh
+2 -2
View File
@@ -1,8 +1,8 @@
name: hub check
# Builds, type-checks, and tests the Hub TS package under hub/.
# The Hub is the Feishu-group collaboration + agent runtime half
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
# The Hub is the Feishu-group collaboration + agent runtime half.
# This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half.
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
+5 -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)
/target
**/*.pdf
@@ -11,8 +7,13 @@
# regenerable, not for VCS. The embedded engine mounts cph-render directly.
render/vendor/local-packages/
# Environment
.env
# Node (hub/ TS workspace and any future JS package)
node_modules/
# OS / editor
.DS_Store
.omo/
@@ -29,7 +29,7 @@ workload brakes.
The full current-state inventory, accepted behavior, and release evidence are
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.
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
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,
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,
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
observable/recoverable writes instead of silently swallowing lost evidence.
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.
The complete accepted decision and implementation divergences are in
[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),
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md),
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
Exact numeric session/invitation/step-up limits and browser mechanics remain
+21 -16
View File
@@ -1,46 +1,51 @@
# AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- `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 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
`Spec.System.Organization`)。
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
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 分层;有效限制取两者较低值。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
`Spec.System.Capacity`)。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
双因子的离线恢复流程(见 ADR-0023)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
控制面与 Docker adapter 后置(见 ADR-0025)。
- Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、
tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。
每个 Organization 必须且只能有一个启用中的默认 role;新建群绑定从该默认值初始化,之后
`ProjectGroupBinding` 持久化群内当前 role,run 在接纳时冻结该 role。飞书公开 slash 协议只含
`/project``/usage``/help`role、会话、目录操作走 `/project` 卡片,Claude 原生
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
系统 `Inbox` 只作为未分类项目的内部落点,不作为业务 folder 暴露;已绑定群通过
`@bot /project` 随时打开项目管理卡片,重命名仍走 org-scoped MANAGE 授权与审计。
## 纪律
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 写操作前与开发者确认(这是开发者的全局偏好)。
+8 -10
View File
@@ -1,25 +1,23 @@
# CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- `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 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
## 纪律
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 写操作前与开发者确认(这是开发者的全局偏好)。
+4
View File
@@ -99,3 +99,7 @@ _Avoid_: Cost budget, unlimited run
**Emergency Workload Brake**:
An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident.
_Avoid_: Organization deletion, service restart
**Member Group**:
A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0028). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization.
_Avoid_: Team (the org-scoped flat grouping), Feishu department
+16 -23
View File
@@ -2,7 +2,7 @@
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。**
## 安装 `cph` 命令行
@@ -33,47 +33,40 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
```
README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
spec/ ← Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源)
CONTEXT.md ← 平台语言词汇表(术语与禁用说法)
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-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 spec/
(exporter/ …) ← 将来的其他部件,平级于 crates/
```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。
## 宪法
5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
4 条是本仓库的协作约定,是一切工作的前提。
1. **角色 —— Lean 是研发侧的上游参照**
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来
1. **角色 —— ADR 是决策真相**
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档
2. **对齐机制 —— Lean 只做上游参照**
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
2. **对齐机制 —— 人肉承载,无机器兜底**
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。
3. **资产性 —— 由 review 纪律承载,无机器兜底**
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档
3. **形态 —— 自包含**
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定
4. **形态 —— 它是人机共识的契约**
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
4. **深度判据 —— 只收录分歧点**
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
## 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`
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
+3 -2
View File
@@ -1,7 +1,8 @@
# crates/
These crates implement the rule-based lesson checker that aligns to the
semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
These crates implement the rule-based lesson checker whose semantics are
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
`element.toml`), validates structure and content, and emits diagnostics.
`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.
///
/// **PINNED to `warning` by the contract.** Mirrors the Lean master's
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
/// **PINNED to `warning` by ADR-0005:** when a
/// `(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
/// severity as a const makes "it is a warning, not an error" a greppable,
/// alignable fact rather than an inline literal.
/// severity as a const makes "it is a warning, not an error" a greppable
/// fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]).
@@ -57,12 +55,12 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity.
///
/// **Legality decision (spec alignment).** `!has_errors()` is the
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
/// gate enforcing this alignment (repo constitution); it is kept greppable
/// here so a reviewer can tie the orchestrator's gate to the Lean master.
/// **Legality decision (ADR-0010).** `!has_errors()` decides lesson
/// legality: a lesson is *legal* iff its diagnostics contain no error-level
/// diagnostic (warnings are non-blocking — see `Severity`). There is no CI
/// gate enforcing ADR↔implementation alignment (repo constitution); it is
/// kept greppable here so a reviewer can tie the orchestrator's gate to
/// the ADR.
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
+10 -15
View File
@@ -2,7 +2,7 @@
//!
//! Every other crate in the workspace depends on these types to report
//! 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
//! [`Diagnostic`] tying them together with a human message and a fix hint.
//!
@@ -17,32 +17,27 @@ use serde::Serialize;
/// Severity of a diagnostic.
///
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
///
/// ```text
/// inductive Severity where
/// | warning
/// | error
/// warning | error
/// ```
///
/// This two-valued shape is a **contract decision**, not an accident: the Lean
/// module pins `Severity` to exactly `warning | error` and states the finer
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
/// This two-valued shape is a **contract decision**, not an accident: the
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
/// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
///
/// There is no CI gate enforcing this alignment (see the repo constitution);
/// it is maintained by review, which is why this correspondence is documented
/// here rather than only in the spec.
/// There is no CI gate enforcing ADR↔implementation alignment (see the repo
/// constitution); it is maintained by review, which is why the decision is
/// documented here rather than only in the ADR.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. Mirrors Lean `Severity.warning`.
/// element. ADR-0010 `warning`.
Warning,
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
/// Blocking: the artifact is invalid. ADR-0010 `error`.
Error,
}
+25 -38
View File
@@ -3,9 +3,7 @@
//! This crate is the **loader**, not the full checker. It reads
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
//! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)`
//! (`spec/Spec/Courseware/Model/Lesson.lean`), where the order of `parts` carries
//! teaching semantics.
//! ordered [`Lesson`], where the order of `parts` carries teaching semantics.
//!
//! Scope boundaries (deliberately staying in lane):
//! - 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.
///
/// 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
/// is declarative" — the `[[parts]]` array order is the single source of truth).
#[derive(Debug, Clone, PartialEq, Serialize)]
@@ -86,9 +84,8 @@ pub struct TargetConfig {
/// with template `exports/<name>.typ` when no `[[steps]]` are given.
pub steps: Vec<Step>,
/// The **render-coverage declaration**: which element kinds this target
/// renders. Realizes `Spec.Courseware.TargetSpec.covers : KindId → Prop`
/// (`spec/Spec/Courseware/Export/Render.lean`) and ADR-0011's "render
/// coverage is a declaration, not a payload": the contract keeps *which
/// renders. Realizes ADR-0011's "render
/// coverage is a declaration, not a payload": the declaration keeps *which
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
/// 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).
///
/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
/// **Pinned by ADR-0011** as an ADT with fields:
///
/// ```text
/// inductive Artifact where
/// | singleFile (filepath : String)
/// | fileTree (root : String) (outputs : String)
/// Artifact = singleFile (filepath) | fileTree (root, outputs)
/// ```
///
/// 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"` →
/// [`Artifact::FileTree`].
///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this
/// alignment (see the repo constitution) — it is maintained by review, which is
/// why the correspondence is documented here.
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing ADR↔
/// implementation alignment (see the repo constitution) — it is maintained by
/// review, which is why the decision is documented here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Artifact {
/// 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 {
/// Where the single product is written (relative to the engineering
/// root), e.g. `build/student.pdf`.
filepath: PathBuf,
},
/// A set of files under `root` matching the `outputs` glob. Mirrors Lean
/// `Artifact.fileTree`.
/// A set of files under `root` matching the `outputs` glob. ADR-0011
/// `fileTree`.
FileTree {
/// The output directory (relative to the engineering root).
root: PathBuf,
@@ -156,14 +150,10 @@ impl Artifact {
/// One typed build step (ADR-0011).
///
/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
/// **Pinned by ADR-0011** as an ADT:
///
/// ```text
/// inductive Step where
/// | typstCompile (template : String)
/// | shell (run : String)
/// | assembleMarkdown (field : String)
/// Step = typstCompile (template) | shell (run) | assembleMarkdown (field)
/// ```
///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a
@@ -183,20 +173,20 @@ impl Artifact {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Step {
/// Compile a template file (relative to the engineering root) into the
/// artifact; the framework injects the manifest. Mirrors Lean
/// `Step.typstCompile`.
/// artifact; the framework injects the manifest. ADR-0011
/// `typstCompile`.
TypstCompile {
/// The template file to compile as main, e.g. `exports/student.typ`.
template: PathBuf,
},
/// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`.
/// Run a shell command — the escape hatch. ADR-0011 `shell`.
Shell {
/// The command line to run.
run: String,
},
/// Assemble a single-file markdown deliverable by concatenating each
/// element's `field` markdown content file in `[[parts]]` order. Mirrors
/// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the
/// element's `field` markdown content file in `[[parts]]` order. ADR-0011
/// `assembleMarkdown` (ADR-0015). Not a typst build — the
/// framework owns the read/concatenate/write itself.
AssembleMarkdown {
/// 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).
///
/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
/// The *canonical* model whose
/// `authors` is always a list. The authoring-surface form (string-or-array
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
/// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
/// CI gate enforces this alignment (repo constitution); it is kept greppable.
/// at the load boundary. No
/// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Info {
/// Lesson title.
@@ -240,7 +229,6 @@ pub struct Info {
/// 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)
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
/// Mirrors Lean `Info.authors : List String`.
pub authors: Vec<String>,
}
@@ -290,8 +278,7 @@ struct RawProject {
name: String,
}
/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
/// The authoring-surface `[info]`: the raw form that exists for
/// fill-in convenience, normalized into the canonical [`Info`] at the load
/// boundary. Not the form the rest of the model traffics in.
#[derive(Debug, Deserialize)]
@@ -301,7 +288,7 @@ struct RawInfo {
}
/// 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`]
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
#[derive(Debug, Deserialize)]
@@ -312,7 +299,7 @@ enum 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.
fn into_vec(self) -> Vec<String> {
match self {
@@ -34,8 +34,12 @@ provider runtime cursor needed to continue a conversation. For Claude Code SDK,
that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
`claudeSessionId` and pass it back to the next `query()` call as
`options.resume`. Role is part of the session binding because role prompts and
tool surfaces can differ even when the underlying model is the same; `/draft`
and `/review` must not resume the same Claude runtime cursor by accident.
tool surfaces can differ even when the underlying model is the same. A Feishu
project group's active binding selects one Organization role for ordinary
messages. Switching that selection routes future messages to the selected
role's own session; it never mutates or merges provider cursors across roles.
Work freezes the selected role when accepted so queued requests cannot drift
after a later switch.
Role definitions are Organization-scoped runtime data. A role bundle selects
its default model, system prompt, tool allowlist and installed Agent skill
@@ -47,6 +51,20 @@ a Hub release or process restart. A change to the role's execution surface
the next run cannot resume a provider context created under stale instructions;
label and ordering-only changes preserve conversational continuity.
Exactly one active role per Organization is the default used when a project
group is first bound. The default is configuration data, not a hard-coded role
name. Roles are selected through the Hub project console; role ids are not
public slash commands. A project participant may change the group's shared
selection only when both `agent.trigger` for the project and `role.trigger` for
the target role authorize that actor. The selection affects every participant's
future messages, while already accepted work keeps its frozen role.
Hub slash commands are a closed control-plane protocol. Unknown commands fail
explicitly and are never downgraded to Agent text. Claude SDK session commands
are invoked only through typed Hub actions. In particular, compaction resumes
the selected role session and sends the exact `/compact` prompt without
prepending Feishu context.
Environment variables:
```
ANTHROPIC_BASE_URL=https://openrouter.ai/api
@@ -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
sandbox paths.
- Agent skills are Organization-scoped runtime configuration, not Hub release
assets. A controlled host-console installer imports each version into a
content-addressed persistent store and records its digest in PostgreSQL. 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.
assets. Skill content is imported into a content-addressed persistent store
through a shared ingestion pipeline (`importSkillDirectory` for host-console
CLI, `importSkillFromFiles` for org-admin web surface) that enforces the same
safety checks: `SKILL.md` manifest required, 512-file / 16-byte limits,
symlink rejection, SHA-256 content addressing. The web surface
(`OrganizationAgentConfiguration.installSkillFromFiles`) writes to the same
store as the host-console CLI (`installSkill`); both flow through
`commitSkillContent` for deduplication and atomic rename into
`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).
`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.
+9 -4
View File
@@ -32,7 +32,7 @@ App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
| 以应用身份发送消息 | `im:message:send_as_bot` |
| 读取触发消息和线程上下文 | `im:message:readonly` |
| 获取与上传图片或文件 | `im:resource` |
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
@@ -66,14 +66,19 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
## 4. 配置事件与卡片回调
进入“事件与回调”。
1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。
2. 添加事件“接收消息” `im.message.receive_v1`
3. 在“回调配置”中同样选择长连接
4. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮
3. 添加事件“解散群” `im.chat.disbanded_v1`,用于立即归档该群的项目绑定
4. 添加事件“机器人被移出群” `im.chat.member.bot.deleted_v1`,用于立即归档该群的项目绑定
5. 在“回调配置”中同样选择长连接。
6. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。
![长连接与消息事件配置](assets/feishu-setup/03-events.png)
@@ -174,7 +179,7 @@ App IDcli_...
App Secret:(通过安全渠道单独发送)
应用已发布:是 / 否
机器人能力已启用:是 / 否
消息事件和卡片回调已配置:是 / 否
消息事件(含解散群、机器人被移出群)和卡片回调已配置:是 / 否
OAuth 重定向 URL 已配置:是 / 否
【首位 OWNER】
+4
View File
@@ -34,6 +34,10 @@ HUB_FEISHU_EVENTS_PER_MINUTE="120"
# to this path and rejects any overlap before installing the unit.
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
# resolved from that Organization's encrypted ACTIVE connection.
HUB_SILO_ORGANIZATION_ID=""
+11
View File
@@ -4,3 +4,14 @@ dist/
.env
.env.*
!.env.example
.secrets/
.dev-keyring.json
.dev-workspaces/
.dev-skills/
.filelib-repos/
admin-web/node_modules/
admin-web/build/
admin-web/.svelte-kit/
filelib-web/node_modules/
filelib-web/build/
filelib-web/.svelte-kit/
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+4
View File
@@ -0,0 +1,4 @@
build
.svelte-kit
node_modules
package-lock.json
+9
View File
@@ -0,0 +1,9 @@
{
"useTabs": true,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 120,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.16.2 create --template minimal --types ts --install npm D:/Projects/curriculum-project-hub/hub/admin-web
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+2641
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "admin-web",
"private": true,
"version": "0.0.1",
"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",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"bits-ui": "^2.18.1",
"prettier": "^3.9.5",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="zh-CN" data-theme="hamlindigo">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<meta name="description" content="Curriculum Project Hub — 组织管理后台" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
/* Fallback before CSS bundle: CJK-first industrial base */
html {
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', 'Inter', sans-serif;
}
</style>
<title>CPH Admin</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+518
View File
@@ -0,0 +1,518 @@
/**
* Thin API client for the org admin backend. Same-origin cookie auth.
*/
export class ApiError extends Error {
code: string;
status: number;
constructor(code: string, message: string, status: number) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
}
}
async function request(method: string, url: string, body?: unknown): Promise<unknown> {
const init: RequestInit = {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
};
const res = await fetch(url, init);
const text = await res.text();
let data: unknown = null;
if (text !== '') {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | null)?.error;
throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
}
return data;
}
const get = (u: string) => request('GET', u);
const post = (u: string, b?: unknown) => request('POST', u, b);
const put = (u: string, b?: unknown) => request('PUT', u, b);
const patch = (u: string, b?: unknown) => request('PATCH', u, b);
const del = (u: string) => request('DELETE', u);
const orgBase = (slug: string) => `/api/org/${encodeURIComponent(slug)}`;
// --- Types ---
export interface OrgMembership {
id: string;
slug: string;
name: string;
status: string;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
}
export interface MeResponse {
user: {
id: string;
feishuOpenId: string;
displayName: string;
avatarUrl: string | null;
};
organizations: OrgMembership[];
}
export interface OrgMember {
userId: string;
feishuOpenId: string;
displayName: string;
avatarUrl: string | null;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
createdAt: string;
}
export interface TeamRow {
id: string;
slug: string;
name: string;
description: string | null;
memberCount: number;
createdAt: string;
}
export interface TeamMemberRow {
userId: string;
feishuOpenId: string;
displayName: string;
createdAt: string;
}
export interface ExplorerFolder {
id: string;
name: string;
parentId: string | null;
sortKey: string;
projectCount: number;
childFolderCount: number;
}
export interface ExplorerProject {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string; createdAt: string } | null;
}
export interface ExplorerData {
folders: ExplorerFolder[];
projects: ExplorerProject[];
}
export interface ProjectDetail {
id: string;
name: string;
folderId: string | null;
folder: { id: string; name: string } | null;
workspaceDir: string;
createdAt: string;
archivedAt: string | null;
createdBy: { id: string; displayName: string; feishuOpenId: string } | null;
binding: { chatId: string; createdAt: string } | null;
actorIsOrgAdmin?: boolean;
actorCanManageProject?: boolean;
}
export interface TeamAccessEntry {
grantId: string;
projectId: string;
organizationId: string;
teamId: string;
teamSlug: string;
teamName: string;
role: 'READ' | 'EDIT' | 'MANAGE';
}
export interface SessionSummary {
id: string;
provider: string;
roleId: string;
model: string;
title: string | null;
runCount: number;
createdAt: string;
updatedAt: string;
}
export interface ProviderConnectionRow {
id: string;
providerId: string;
mode: 'BYOK' | 'PLATFORM_MANAGED';
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface FeishuApplicationConnection {
id: string;
appFingerprint: string;
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: 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 {
runCount: number;
runsWithCost: number;
runsWithoutCost: number;
inputTokens: number;
outputTokens: number;
costUsd: number | null;
}
export interface ProjectUsageRow extends UsageTotals {
projectId: string;
projectName: string;
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 {
from: string | null;
to: string | null;
projects: ProjectUsageRow[];
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 =
| 'requestRate'
| 'requestBodySize'
| 'agentConcurrency'
| 'admissionQueueLength'
| 'admissionQueueWait'
| 'fileSize'
| 'attachmentCount'
| 'archiveExpansion'
| 'projectStorage'
| 'organizationStorage'
| 'memberCount'
| 'projectCount'
| 'teamCount'
| 'folderCount'
| 'sessionCount'
| 'runWallTime'
| 'runTurns'
| 'runToolCalls'
| 'toolWallTime'
| 'runOutputSize'
| 'processMemory'
| 'processCpu'
| 'processCount';
export interface CapacityDimensionRow {
dimension: CapacityDimension;
platformCeiling: number | null;
organizationLimit: number | null;
effective: number | null;
}
export interface CapacityPolicyView {
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 ---
export const api = {
me: () => get('/api/me') as Promise<MeResponse>,
logout: () => post('/auth/logout'),
org: (slug: string) =>
get(orgBase(slug)) as Promise<{
organization: { id: string; slug: string; name: string; status: string };
actorRole: string;
}>,
settings: (slug: string) => get(`${orgBase(slug)}/settings`) as Promise<{ membersCanCreateProjects: boolean }>,
setSettings: (slug: string, body: { membersCanCreateProjects: boolean }) =>
patch(`${orgBase(slug)}/settings`, body) as Promise<{ membersCanCreateProjects: boolean }>,
members: (slug: string) => get(`${orgBase(slug)}/members`) as Promise<{ members: OrgMember[] }>,
addMember: (slug: string, body: { feishuOpenId: string; displayName?: string; role: string }) =>
post(`${orgBase(slug)}/members`, body) as Promise<OrgMember>,
setMemberRole: (slug: string, userId: string, role: string) => patch(`${orgBase(slug)}/members/${userId}`, { role }),
revokeMember: (slug: string, userId: string) => post(`${orgBase(slug)}/members/${userId}/revoke`),
teams: (slug: string) => get(`${orgBase(slug)}/teams`) as Promise<{ teams: TeamRow[] }>,
createTeam: (slug: string, body: { slug: string; name: string; description?: string }) =>
post(`${orgBase(slug)}/teams`, body) as Promise<TeamRow>,
updateTeam: (slug: string, teamId: string, body: { name?: string; description?: string | null }) =>
patch(`${orgBase(slug)}/teams/${teamId}`, body) as Promise<TeamRow>,
archiveTeam: (slug: string, teamId: string) => post(`${orgBase(slug)}/teams/${teamId}/archive`),
teamMembers: (slug: string, teamId: string) =>
get(`${orgBase(slug)}/teams/${teamId}/members`) as Promise<{ members: TeamMemberRow[] }>,
addTeamMember: (slug: string, teamId: string, body: { userId?: string; feishuOpenId?: string }) =>
post(`${orgBase(slug)}/teams/${teamId}/members`, body) as Promise<TeamMemberRow>,
revokeTeamMember: (slug: string, teamId: string, userId: string) =>
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
myProjects: (slug: string) => get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
post(`${orgBase(slug)}/folders`, body) as Promise<{
id: string;
name: string;
parentId: string | null;
sortKey: string;
}>,
renameFolder: (slug: string, folderId: string, body: { name?: string; sortKey?: string; parentId?: string | null }) =>
patch(`${orgBase(slug)}/folders/${folderId}`, body) as Promise<{
id: string;
name: string;
parentId: string | null;
sortKey: string;
}>,
archiveFolder: (slug: string, folderId: string) =>
post(`${orgBase(slug)}/folders/${folderId}/archive`) as Promise<{ archived: true; folderId: string }>,
createProject: (slug: string, body: { name: string; folderId?: string }) =>
post(`${orgBase(slug)}/projects`, body) as Promise<{ id: string; name: string }>,
project: (slug: string, projectId: string) => get(`${orgBase(slug)}/projects/${projectId}`) as Promise<ProjectDetail>,
renameProject: (slug: string, projectId: string, name: string) =>
patch(`${orgBase(slug)}/projects/${projectId}`, { name }),
moveProject: (slug: string, projectId: string, folderId: string | null) =>
patch(`${orgBase(slug)}/projects/${projectId}/folder`, { folderId }),
archiveProject: (slug: string, projectId: string) => post(`${orgBase(slug)}/projects/${projectId}/archive`),
archiveBinding: (slug: string, projectId: string) => post(`${orgBase(slug)}/projects/${projectId}/binding/archive`),
teamAccess: (slug: string, projectId: string) =>
get(`${orgBase(slug)}/projects/${projectId}/team-access`) as Promise<{ access: TeamAccessEntry[] }>,
grantTeamAccess: (slug: string, projectId: string, body: { teamId?: string; teamSlug?: string; role: string }) =>
put(`${orgBase(slug)}/projects/${projectId}/team-access`, body) as Promise<TeamAccessEntry>,
revokeTeamAccess: (slug: string, projectId: string, teamId: string) =>
del(`${orgBase(slug)}/projects/${projectId}/team-access/${teamId}`),
sessions: (slug: string, projectId: string, limit?: number) =>
get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{
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 }) => {
const q = new URLSearchParams();
if (params?.from) q.set('from', params.from);
if (params?.to) q.set('to', params.to);
if (params?.folderId) q.set('folderId', params.folderId);
const qs = q.toString();
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) =>
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
rotateProviderConnection: (
slug: string,
providerId: string,
body: { baseUrl: string; authToken: string; anthropicApiKey?: string },
) =>
put(
`${orgBase(slug)}/provider-connections/${encodeURIComponent(providerId)}`,
body,
) as Promise<ProviderConnectionRow>,
feishuApplication: (slug: string) =>
get(`${orgBase(slug)}/feishu-application-connection`) as Promise<{
connection: FeishuApplicationConnection | null;
}>,
rotateFeishuApplication: (
slug: string,
body: {
appId: string;
appSecret: string;
botOpenId: string;
verificationToken?: string;
encryptKey?: string;
},
) => put(`${orgBase(slug)}/feishu-application-connection`, body) as Promise<FeishuApplicationConnection>,
disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
capabilityConnections: (slug: string) =>
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>> }) =>
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[] }>,
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,32 @@
<script lang="ts">
import { Checkbox } from 'bits-ui';
import Icon from './Icon.svelte';
let {
checked = $bindable(false),
disabled = false,
class: className = '',
onchange,
}: {
checked?: boolean;
disabled?: boolean;
class?: string;
onchange?: (checked: boolean) => void;
} = $props();
</script>
<Checkbox.Root
class="saas-checkbox {className}"
{disabled}
{checked}
onCheckedChange={(next) => {
checked = next;
onchange?.(next);
}}
>
{#snippet children({ checked: isChecked })}
{#if isChecked}
<Icon name="check" class="h-3.5 w-3.5" />
{/if}
{/snippet}
</Checkbox.Root>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title = '暂无数据',
description,
action,
}: {
title?: string;
description?: string;
action?: Snippet;
} = $props();
</script>
<div class="saas-empty">
<div
class="mb-1 flex h-12 w-12 items-center justify-center border border-surface-300 bg-surface-100 text-surface-600"
>
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" />
</svg>
</div>
<p class="text-sm font-medium text-surface-900">{title}</p>
{#if description}
<p class="max-w-sm text-sm text-surface-600">{description}</p>
{/if}
{#if action}
<div class="mt-2">
{@render action()}
</div>
{/if}
</div>
@@ -0,0 +1,26 @@
<script lang="ts">
let {
message,
onretry,
}: {
message: string;
onretry?: () => void;
} = $props();
</script>
<div class="saas-card flex flex-wrap items-start gap-3 border-error-200 bg-error-50 p-4 text-error-700">
<svg class="mt-0.5 h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">请求失败</p>
<p class="mt-0.5 text-sm opacity-90">{message}</p>
</div>
{#if onretry}
<button type="button" class="saas-btn-ghost text-sm" onclick={onretry}>重试</button>
{/if}
</div>
@@ -0,0 +1,78 @@
<script lang="ts">
import type { ExplorerFolder } from '$lib/api';
import Icon from './Icon.svelte';
import FolderTree from './FolderTree.svelte';
let {
folder,
folders,
projects,
slug,
onCreateFolder,
onCreateProject,
}: {
folder: ExplorerFolder;
folders: ExplorerFolder[];
projects: {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string } | null;
}[];
slug: string;
onCreateFolder?: (parentId: string) => void;
onCreateProject?: (folderId: string) => void;
} = $props();
let open = $state(true);
</script>
<div>
<div class="group flex w-full items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100">
<button type="button" class="flex min-w-0 flex-1 items-center gap-2.5 text-left" onclick={() => (open = !open)}>
<span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span>
<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>
<span class="min-w-0 flex-1 truncate font-medium text-surface-900">{folder.name}</span>
<span class="saas-badge-neutral">{folder.projectCount} 项目</span>
{#if folder.childFolderCount > 0}
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
{/if}
</button>
{#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}
</div>
{#if open}
<div class="ml-4 border-l border-surface-300 pl-2">
<FolderTree {folders} {projects} parentId={folder.id} {slug} {onCreateFolder} {onCreateProject} />
</div>
{/if}
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { ExplorerFolder } from '$lib/api';
import { fmtDate } from '$lib/format';
import Icon from './Icon.svelte';
import FolderNode from './FolderNode.svelte';
let {
folders,
projects,
parentId,
slug,
onCreateFolder,
onCreateProject,
}: {
folders: ExplorerFolder[];
projects: {
id: string;
name: string;
folderId: string | null;
createdAt: string;
binding: { chatId: string } | null;
}[];
parentId: string | null;
slug: string;
onCreateFolder?: (parentId: string) => void;
onCreateProject?: (folderId: string) => void;
} = $props();
let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
let childProjects = $derived(projects.filter((p) => p.folderId === parentId));
</script>
<div class="space-y-0.5">
{#each childProjects as p (p.id)}
<a
href={`/admin/projects/${p.id}`}
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">
<Icon name="file" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1 truncate font-medium text-surface-900">{p.name}</span>
{#if p.binding}
<span class="saas-badge-success">已绑定</span>
{/if}
<span class="hidden text-xs text-surface-600 sm:inline">{fmtDate(p.createdAt)}</span>
</a>
{/each}
{#each childFolders as f (f.id)}
<FolderNode folder={f} {folders} {projects} {slug} {onCreateFolder} {onCreateProject} />
{/each}
</div>
@@ -0,0 +1,155 @@
<script lang="ts">
/** Inline nav icons for the admin shell. */
let {
name,
class: className = 'h-4 w-4',
}: {
name:
| 'overview'
| 'members'
| 'teams'
| 'projects'
| 'provider'
| 'feishu'
| 'menu'
| 'logout'
| 'org'
| 'chevron'
| 'arrow-left'
| 'folder'
| 'file'
| 'folder-plus'
| 'file-plus'
| 'check'
| 'roles';
class?: string;
} = $props();
</script>
{#if name === 'overview'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 12l9-9 9 9M5 10v9a1 1 0 001 1h3v-5h6v5h3a1 1 0 001-1v-9"
/>
</svg>
{:else if name === 'members'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 7.5a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.5 19.5a7.5 7.5 0 0115 0"
/>
</svg>
{:else if name === 'teams'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.09 9.09 0 003.74-.72 9 9 0 00-5.07-5.95M15 11a4 4 0 10-8 0 4 4 0 008 0zM4.26 18a9 9 0 0115.48 0"
/>
</svg>
{:else if name === 'projects'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75A2.25 2.25 0 016 4.5h3.379c.6 0 1.175.238 1.6.66l.842.84c.424.423 1 .66 1.6.66H18A2.25 2.25 0 0120.25 9v8.25A2.25 2.25 0 0118 19.5H6a2.25 2.25 0 01-2.25-2.25V6.75z"
/>
</svg>
{:else if name === 'provider'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.19 8.688a4.5 4.5 0 016.364 6.364l-3.182 3.182a4.5 4.5 0 01-6.364-6.364"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.81 15.312a4.5 4.5 0 01-6.364-6.364l3.182-3.182a4.5 4.5 0 016.364 6.364"
/>
</svg>
{:else if name === 'feishu'}
<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 8.25h15a8.25 8.25 0 01-8.25 8.25A8.25 8.25 0 014.5 8.25z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 11.25h.01M12 11.25h.01M15.75 11.25h.01" />
</svg>
{:else if name === 'menu'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
{:else if name === 'logout'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6A2.25 2.25 0 005.25 5.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l3 3m0 0l-3 3m3-3H6"
/>
</svg>
{:else if name === 'org'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
/>
</svg>
{:else if name === 'chevron'}
<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" />
</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'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75A2.25 2.25 0 016 4.5h3.379c.6 0 1.175.238 1.6.66l.842.84c.424.423 1 .66 1.6.66H18A2.25 2.25 0 0120.25 9v8.25A2.25 2.25 0 0118 19.5H6a2.25 2.25 0 01-2.25-2.25V6.75z"
/>
</svg>
{:else if name === 'file'}
<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.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>
{: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'}
<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" />
</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}
@@ -0,0 +1,15 @@
<script lang="ts">
let { label = '加载中…' }: { label?: string } = $props();
</script>
<div class="flex flex-col items-center justify-center gap-3 py-16 text-surface-600">
<svg class="h-7 w-7 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
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>
</svg>
<p class="text-sm">{label}</p>
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Dialog } from 'bits-ui';
let {
open = $bindable(false),
title,
children,
onclose,
}: {
open?: boolean;
title: string;
children: Snippet;
onclose?: () => void;
} = $props();
</script>
<Dialog.Root
bind:open
onOpenChange={(next) => {
if (!next) onclose?.();
}}
>
<Dialog.Portal>
<Dialog.Overlay class="saas-modal-backdrop" />
<Dialog.Content class="saas-modal">
<div class="mb-4 flex items-start justify-between gap-3">
<Dialog.Title class="text-lg font-semibold text-surface-900">{title}</Dialog.Title>
<Dialog.Close class="saas-btn-ghost px-2! py-1! text-surface-600" aria-label="关闭">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</Dialog.Close>
</div>
{@render children()}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: Snippet;
} = $props();
</script>
<div class="saas-toolbar">
<div class="min-w-0">
<h1 class="saas-page-title">{title}</h1>
{#if description}
<p class="saas-muted mt-1">{description}</p>
{/if}
</div>
{#if actions}
<div class="ml-auto flex flex-wrap items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
@@ -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,66 @@
<script lang="ts">
import { Select } from 'bits-ui';
import Icon from './Icon.svelte';
export type SelectItem = { label: string; value: string; disabled?: boolean };
let {
items,
value = $bindable(''),
class: className = '',
disabled = false,
placeholder = '请选择…',
onchange,
}: {
items: SelectItem[];
value?: string;
class?: string;
disabled?: boolean;
placeholder?: string;
onchange?: (value: string) => void;
} = $props();
</script>
<Select.Root
type="single"
{items}
{disabled}
{value}
onValueChange={(next) => {
value = next;
onchange?.(next);
}}
>
<Select.Trigger class="saas-select-trigger {className}" {disabled}>
<span class="min-w-0 flex-1 truncate text-left">
<Select.Value {placeholder} />
</span>
<svg
class="ml-2 h-4 w-4 shrink-0 text-surface-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15l3.75 3.75L15.75 15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9l3.75-3.75L15.75 9" />
</svg>
</Select.Trigger>
<Select.Portal>
<Select.Content class="saas-select-content" sideOffset={6} collisionPadding={8}>
<Select.Viewport class="p-1">
{#each items as item (item.value)}
<Select.Item class="saas-select-item" value={item.value} label={item.label} disabled={item.disabled}>
{#snippet children({ selected })}
<span class="min-w-0 flex-1 truncate">{item.label}</span>
{#if selected}
<Icon name="check" class="ml-2 h-4 w-4 shrink-0 text-primary-600" />
{/if}
{/snippet}
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
@@ -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>
@@ -0,0 +1,19 @@
<script lang="ts">
let {
label,
value,
hint,
}: {
label: string;
value: string;
hint?: string;
} = $props();
</script>
<div class="saas-stat">
<div class="saas-stat-label">{label}</div>
<div class="saas-stat-value">{value}</div>
{#if hint}
<div class="saas-help">{hint}</div>
{/if}
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Switch } from 'bits-ui';
let {
checked = $bindable(false),
disabled = false,
class: className = '',
onchange,
}: {
checked?: boolean;
disabled?: boolean;
class?: string;
onchange?: (checked: boolean) => void;
} = $props();
</script>
<Switch.Root
class="saas-switch {className}"
{disabled}
{checked}
onCheckedChange={(next) => {
checked = next;
onchange?.(next);
}}
>
<Switch.Thumb class="saas-switch-thumb" />
</Switch.Root>
@@ -0,0 +1,25 @@
<script lang="ts">
import { dismissToast, toasts } from '$lib/toast';
const kindClass: Record<string, string> = {
info: 'border-surface-200 bg-surface-50 text-surface-800',
success: 'border-success-200 bg-success-50 text-success-800',
error: 'border-error-200 bg-error-50 text-error-800',
};
</script>
<div class="pointer-events-none fixed inset-x-0 top-0 z-100 flex flex-col items-end gap-2 p-4">
{#each $toasts as t (t.id)}
<div
class="pointer-events-auto flex max-w-sm items-start gap-3 border px-4 py-3 text-sm shadow-[4px_4px_0_rgb(15_23_42/0.12)] {kindClass[
t.kind
] ?? kindClass.info}"
role="status"
>
<p class="min-w-0 flex-1">{t.message}</p>
<button type="button" class="opacity-60 hover:opacity-100" onclick={() => dismissToast(t.id)} aria-label="关闭">
×
</button>
</div>
{/each}
</div>
+41
View File
@@ -0,0 +1,41 @@
export interface ToolOption {
id: string;
label: string;
group: string;
}
export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'read_file', label: '读取文件', group: '文件' },
{ id: 'write_file', label: '写入文件', group: '文件' },
{ id: 'list_files', label: '列目录', group: '文件' },
{ id: 'search_files', label: '搜索', group: '文件' },
{ 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_build', label: 'cph build', group: 'CPH' },
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
{ id: 'request_approval', label: '请求审批', group: '飞书' },
];
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel */
export const ORG_ROLES = ['OWNER', 'ADMIN', 'MEMBER'] as const;
export type OrgRole = (typeof ORG_ROLES)[number];
export const ORG_ROLE_LABELS: Record<OrgRole, string> = {
OWNER: '所有者',
ADMIN: '管理员',
MEMBER: '成员',
};
/** 项目团队授权角色(接口枚举保持英文,界面用 permissionRoleLabel */
export const PERMISSION_ROLES = ['READ', 'EDIT', 'MANAGE'] as const;
export type PermissionRole = (typeof PERMISSION_ROLES)[number];
export const PERMISSION_ROLE_LABELS: Record<PermissionRole, string> = {
READ: '只读',
EDIT: '编辑',
MANAGE: '管理',
};
+92
View File
@@ -0,0 +1,92 @@
import { ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS, type OrgRole, type PermissionRole } from './constants';
export function fmtDate(iso: string): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function fmtDateOnly(iso: string): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
}
export function fmtCost(usd: number | null): string {
if (usd === null) return '—';
return `$${Number(usd).toFixed(4)}`;
}
export function fmtNum(n: number): string {
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 {
const key = role.toUpperCase() as OrgRole;
return ORG_ROLE_LABELS[key] ?? role;
}
export function permissionRoleLabel(role: string): string {
const key = role.toUpperCase() as PermissionRole;
return PERMISSION_ROLE_LABELS[key] ?? role;
}
export function providerModeLabel(mode: string): string {
if (mode === 'BYOK') return '自带密钥';
if (mode === 'PLATFORM_MANAGED') return '平台托管';
return mode;
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+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}`;
}
+74
View File
@@ -0,0 +1,74 @@
import { writable } from 'svelte/store';
import { api, type MeResponse } from './api';
interface SessionState {
loading: boolean;
me: MeResponse | null;
error: string | null;
}
export const session = writable<SessionState>({
loading: true,
me: null,
error: null,
});
export async function loadSession(): Promise<void> {
session.update((s) => ({ ...s, loading: true, error: null }));
try {
const me = await api.me();
session.set({ loading: false, me, error: null });
} catch (err) {
const status = (err as { status?: number }).status;
if (status === 401) {
redirectToLogin();
return;
}
session.set({
loading: false,
me: null,
error: err instanceof Error ? err.message : String(err),
});
}
}
/**
* 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 {
if (window.location.pathname === '/admin/login') return;
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> {
await api.logout();
redirectToLogin();
}
+34
View File
@@ -0,0 +1,34 @@
import { writable } from 'svelte/store';
export type ToastKind = 'info' | 'success' | 'error';
export interface ToastItem {
id: number;
message: string;
kind: ToastKind;
}
let seq = 0;
export const toasts = writable<ToastItem[]>([]);
export function pushToast(message: string, kind: ToastKind = 'info', ms = 3200): void {
const id = ++seq;
toasts.update((list) => [...list, { id, message, kind }]);
if (ms > 0) {
setTimeout(() => {
toasts.update((list) => list.filter((t) => t.id !== id));
}, ms);
}
}
export function dismissToast(id: number): void {
toasts.update((list) => list.filter((t) => t.id !== id));
}
export function toastSuccess(message: string): void {
pushToast(message, 'success');
}
export function toastError(message: string): void {
pushToast(message, 'error', 5000);
}
+364
View File
@@ -0,0 +1,364 @@
<script lang="ts">
import '../routes/app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
import type { OrgMembership } from '$lib/api';
import { adminPath, isOrgAdmin, resolveOrg } from '$lib/org';
import { orgRoleLabel } from '$lib/format';
import Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.svelte';
import SelectField from '$lib/components/SelectField.svelte';
let { children } = $props();
let mobileNavOpen = $state(false);
let redirecting = $state(false);
onMount(() => {
loadSession();
});
const navItems = [
{ key: 'overview', label: '概览', icon: 'overview' as const },
{ key: 'usage', label: '用量', icon: 'overview' as const },
{ key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'capacity', label: '容量', icon: 'overview' 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: 'capabilities', label: '能力', icon: 'provider' as const },
];
function memberships(): OrgMembership[] {
return $session.me?.organizations ?? [];
}
function adminOrgs(): OrgMembership[] {
return memberships().filter((o) => isOrgAdmin(o));
}
function currentOrg(): OrgMembership | null {
return resolveOrg($session.me, page.url.search);
}
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
// /admin/projects or /admin/projects/:id
return parts[0] === 'admin' && parts[1] === 'projects';
}
function activeKey(): string {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'admin') return '';
// /admin → overview; /admin/usage → usage; /admin/projects/x → projects
return parts[1] ?? 'overview';
}
function navHref(key: string): string {
if (key === 'overview') return adminPath();
return adminPath(key);
}
function pageTitle(): string {
const key = activeKey();
if (key === 'overview' || key === '') return '概览';
if (key === 'sessions') return '会话详情';
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
}
function switchOrg(nextSlug: string) {
if (!nextSlug || nextSlug === currentOrg()?.slug) return;
// 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) {
e.preventDefault();
logout();
}
function orgSelectItems(list: OrgMembership[]) {
return list.map((o) => ({
value: o.slug,
label: `${o.name} · ${orgRoleLabel(o.role)}`,
}));
}
$effect(() => {
page.url.pathname;
mobileNavOpen = false;
});
$effect(() => {
if ($session.loading || !$session.me) return;
const path = page.url.pathname;
// Legacy /admin/org/:slug… is handled by admin/org/[...path] page.
const org = currentOrg();
const admins = adminOrgs();
if (org && isOrgAdmin(org)) {
redirecting = false;
return;
}
// Member only: allow project routes; bounce admin-only surfaces to projects.
if (org && !isOrgAdmin(org)) {
redirecting = false;
if (!isOnProjectRoute()) {
const target = adminPath('projects');
if (path !== target) {
redirecting = true;
void goto(target, { replaceState: true });
}
}
return;
}
// No org resolved but user has memberships → land on first org's projects/overview via resolveOrg next tick
if (!org && memberships().length > 0) {
const home = admins[0] ?? memberships()[0]!;
const target = isOrgAdmin(home) ? adminPath() : adminPath('projects');
if (path !== target && !path.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
}
});
</script>
<ToastHost />
{#if $session.loading || redirecting}
<div class="saas-status-panel">
<div class="flex flex-col items-center gap-3 text-surface-600">
<svg class="h-8 w-8 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
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>
</svg>
<p class="text-sm">加载中…</p>
</div>
</div>
{:else if $session.error}
<div class="saas-status-panel">
<div class="saas-status-card">
<div
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>
<h2 class="mb-1 text-lg font-semibold">无法连接到后端</h2>
<p class="mb-5 text-sm text-surface-700">{$session.error}</p>
<button class="saas-btn-primary" onclick={() => loadSession()}>重试</button>
</div>
</div>
{:else if !$session.me}
<div class="saas-status-panel">
<div class="saas-status-card">
<div
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
</div>
<h1 class="text-xl font-semibold">Curriculum Project Hub</h1>
<p class="mt-2 mb-6 text-sm text-surface-700">登录以管理组织、项目、团队与模型供应。</p>
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
</div>
</div>
{:else if currentOrg() && isOrgAdmin(currentOrg()!)}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
{#if mobileNavOpen}
<button
type="button"
class="fixed inset-0 z-40 bg-surface-950/40 md:hidden"
aria-label="关闭导航"
onclick={() => (mobileNavOpen = false)}
></button>
{/if}
<aside
class="saas-sidebar fixed inset-y-0 left-0 z-50 transition-transform md:static md:translate-x-0
{mobileNavOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}"
>
<div class="flex items-center gap-2.5 px-4 py-4">
<div
class="flex h-9 w-9 items-center justify-center border border-primary-700 bg-primary-600 text-xs font-bold tracking-wide text-white"
>
CPH
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-surface-900">组织后台</div>
<div class="truncate text-xs text-surface-600">{org.name}</div>
</div>
</div>
{#if me.organizations.length > 1}
<div class="px-3 pb-3">
<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>
{/if}
<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>
{#each navItems as item}
{@const active =
activeKey() === item.key || (item.key === 'overview' && (activeKey() === '' || activeKey() === 'overview'))}
<a href={navHref(item.key)} class="saas-nav-item" data-active={active ? 'true' : 'false'}>
<Icon name={item.icon} class="h-4 w-4 shrink-0 opacity-80" />
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="border-t border-surface-300 p-3">
<div class="flex items-center gap-2.5 border border-surface-300 bg-surface-100 px-2.5 py-2">
{#if me.user.avatarUrl}
<img src={me.user.avatarUrl} alt="" class="h-8 w-8 object-cover" />
{:else}
<div
class="flex h-8 w-8 items-center justify-center border border-primary-300 bg-primary-100 text-xs font-semibold text-primary-800"
>
{me.user.displayName.slice(0, 1)}
</div>
{/if}
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-surface-900">{me.user.displayName}</div>
<div class="truncate text-[11px] text-surface-600">{orgRoleLabel(org.role)}</div>
</div>
<button
type="button"
class="p-1.5 text-surface-600 transition hover:bg-surface-200 hover:text-error-700"
title="退出登录"
onclick={handleLogout}
>
<Icon name="logout" class="h-4 w-4" />
</button>
</div>
</div>
</aside>
<div class="saas-main">
<header class="saas-topbar">
<button
type="button"
class="saas-btn-ghost px-2! md:hidden"
onclick={() => (mobileNavOpen = !mobileNavOpen)}
aria-label="打开导航"
>
<Icon name="menu" class="h-5 w-5" />
</button>
<div class="min-w-0">
<div class="flex items-center gap-1.5 text-xs text-surface-600">
<span class="truncate">{org.name}</span>
<span>/</span>
<span class="truncate font-medium text-surface-900">{pageTitle()}</span>
</div>
</div>
<div class="ml-auto hidden items-center gap-2 sm:flex">
<span class="saas-badge-primary">{org.status}</span>
<span class="saas-badge-neutral font-mono">/{org.slug}</span>
</div>
</header>
<main class="saas-content">
<div class="saas-content-inner">
{@render children()}
</div>
</main>
</div>
</div>
{:else if currentOrg() && !isOrgAdmin(currentOrg()!) && isOnProjectRoute()}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
<div class="saas-main">
<header class="saas-topbar">
<a href={adminPath('projects')} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
<Icon name="menu" class="h-5 w-5" />
</a>
<div class="min-w-0">
<div class="flex items-center gap-1.5 text-xs text-surface-600">
<span class="truncate">{org.name}</span>
<span>/</span>
<span class="truncate font-medium text-surface-900">项目</span>
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<span class="saas-badge-neutral font-mono">/{org.slug}</span>
<button
type="button"
class="p-1.5 text-surface-600 transition hover:bg-surface-200 hover:text-error-700"
title="退出登录"
onclick={handleLogout}
>
<Icon name="logout" class="h-4 w-4" />
</button>
</div>
</header>
<main class="saas-content">
<div class="saas-content-inner">
{@render children()}
</div>
</main>
</div>
</div>
{:else if currentOrg() && !isOrgAdmin(currentOrg()!)}
{@const denied = currentOrg()!}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
<p class="mb-3 text-sm text-surface-700">
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。
</p>
<a class="saas-btn-primary" href={adminPath('projects')}>查看我的项目</a>
{#if memberships().length > 1}
<p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p>
<div class="mb-4">
<SelectField items={orgSelectItems(memberships())} value={denied.slug} onchange={switchOrg} />
</div>
{/if}
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div>
</div>
{:else if memberships().length > 0}
{@const denied = resolveOrg($session.me) ?? memberships()[0]!}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-700">
即将进入 <strong>{denied.name}</strong>/{denied.slug})的项目。
</p>
<a class="saas-btn-primary" href={adminPath('projects')}>立即进入</a>
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div>
</div>
{:else}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">未加入组织</h2>
<p class="mb-3 text-sm text-surface-700">飞书账号已登录,但当前账号尚未加入任何组织。</p>
<p class="mb-5 text-left text-xs text-surface-600">
open_id
<code class="break-all font-mono text-surface-600">{$session.me!.user.feishuOpenId}</code>
</p>
<button class="saas-btn-primary" onclick={handleLogout}>退出登录</button>
</div>
</div>
{/if}
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { session, redirectToLogin } from '$lib/session';
function homeForSession(): string | null {
const me = $session.me;
if (!me) return null;
const admin = me.organizations.find((o) => {
const r = String(o.role ?? '').toUpperCase();
return r === 'OWNER' || r === 'ADMIN';
});
const target = admin ?? me.organizations[0];
return target ? `/admin` : null;
}
onMount(() => {
const unsub = session.subscribe((s) => {
if (s.loading) return;
if (!s.me) {
redirectToLogin();
return;
}
const dest = homeForSession();
if (dest) void goto(dest, { replaceState: true });
});
return unsub;
});
</script>
<div class="saas-status-panel">
<div class="flex flex-col items-center gap-3 text-surface-600">
<svg class="h-7 w-7 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
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>
</svg>
<p class="text-sm">正在进入工作台…</p>
</div>
</div>
+190
View File
@@ -0,0 +1,190 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtCost, fmtNum, orgRoleLabel } 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 SwitchControl from '$lib/components/SwitchControl.svelte';
import { toastError, toastSuccess } from '$lib/toast';
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 settings = $state<{ membersCanCreateProjects: boolean } | null>(null);
let usage = $state<Awaited<ReturnType<typeof api.usage>> | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let saving = $state(false);
async function load() {
loading = true;
error = null;
try {
[settings, usage] = await Promise.all([api.settings(orgSlug), api.usage(orgSlug)]);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function setMembersCanCreate(next: boolean) {
if (!settings || settings.membersCanCreateProjects === next) return;
saving = true;
const prev = settings.membersCanCreateProjects;
settings.membersCanCreateProjects = next;
try {
await api.setSettings(orgSlug, { membersCanCreateProjects: next });
toastSuccess(next ? '已允许成员自助建项' : '已关闭成员自助建项');
} catch (err) {
settings.membersCanCreateProjects = prev;
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (orgSlug) load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if org && settings && usage}
<PageHeader title={org.name} description="组织健康度、用量与生产策略一览。" />
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="saas-card-pad sm:col-span-2 lg:col-span-1">
<p class="saas-section-title mb-3">组织信息</p>
<dl class="space-y-2.5 text-sm">
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">Slug</dt>
<dd class="font-mono text-xs text-surface-800">/{org.slug}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">状态</dt>
<dd><span class="saas-badge-success">{org.status}</span></dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">你的角色</dt>
<dd><span class="saas-badge-primary">{orgRoleLabel(org.role)}</span></dd>
</div>
</dl>
</div>
<div class="saas-card-pad sm:col-span-2">
<p class="saas-section-title mb-1">项目自助创建策略</p>
<p class="saas-muted mb-4">开启后,普通老师可在飞书群自助创建项目;关闭后仅所有者与管理员可建。</p>
<div class="flex items-center gap-3 border border-surface-300 bg-surface-100 px-4 py-3">
<SwitchControl checked={settings.membersCanCreateProjects} disabled={saving} onchange={setMembersCanCreate} />
<div>
<div class="text-sm font-medium text-surface-900">允许成员自助创建项目</div>
<div class="text-xs text-surface-600">普通成员在飞书群中自助建项</div>
</div>
</div>
</div>
</div>
<div class="mb-4 flex items-end justify-between gap-3">
<div>
<h2 class="saas-section-title">用量概览</h2>
<p class="saas-muted">totals 含全部 UsageFact;分账明细见用量页。</p>
</div>
<a class="saas-btn-secondary py-1.5! text-sm" href={`/admin/usage`}>完整用量报告</a>
</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)} />
<StatCard label="无成本运行" value={fmtNum(usage.totals.runsWithoutCost)} hint="未知 $0" />
<StatCard label="输入 tokens" value={fmtNum(usage.totals.inputTokens)} />
<StatCard label="输出 tokens" value={fmtNum(usage.totals.outputTokens)} />
<StatCard label="成本 (USD)" value={fmtCost(usage.totals.costUsd)} />
</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="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">按项目用量</h3>
</div>
{#if usage.projects.length === 0}
<div class="saas-empty">
<p class="text-sm text-surface-600">暂无项目用量数据</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>运行</th>
<th>in / out tokens</th>
<th>成本</th>
</tr>
</thead>
<tbody>
{#each usage.projects as p}
<tr>
<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 text-surface-600">{fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)}</td>
<td class="tabular-nums">{fmtCost(p.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{:else}
<div class="saas-empty">
<p class="text-sm text-surface-600">组织数据不可用</p>
</div>
{/if}
@@ -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}
@@ -0,0 +1,313 @@
<script lang="ts">
import { page } from '$app/state';
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 LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
// Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.).
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
requestRate: '请求速率',
requestBodySize: '请求体大小',
agentConcurrency: '并发数',
admissionQueueLength: '队列长度',
admissionQueueWait: '队列等待',
fileSize: '单文件大小',
attachmentCount: '附件数',
archiveExpansion: '归档展开',
projectStorage: '项目存储',
organizationStorage: '组织存储',
memberCount: '成员数',
projectCount: '项目数',
teamCount: '团队数',
folderCount: '文件夹数',
sessionCount: '会话数',
runWallTime: '运行时长',
runTurns: '对话轮次',
runToolCalls: '工具调用数',
toolWallTime: '工具执行时长',
runOutputSize: '输出大小',
processMemory: '内存',
processCpu: 'CPU',
processCount: '进程数',
};
// Logical groupings for higher information density.
const GROUPS: { title: string; dims: CapacityDimension[] }[] = [
{ title: 'HTTP 接入', dims: ['requestRate', 'requestBodySize'] },
{ title: '智能体运行', dims: ['agentConcurrency', 'runWallTime', 'runTurns', 'runToolCalls', 'toolWallTime', 'runOutputSize'] },
{ title: '接纳队列', dims: ['admissionQueueLength', 'admissionQueueWait'] },
{ title: '附件与存储', dims: ['fileSize', 'attachmentCount', 'archiveExpansion', 'projectStorage', 'organizationStorage'] },
{ title: '组织配额', dims: ['memberCount', 'projectCount', 'teamCount', 'folderCount', 'sessionCount'] },
{ title: '进程资源', dims: ['processMemory', 'processCpu', 'processCount'] },
];
let view = $state<CapacityPolicyView | null>(null);
let drafts = $state<Partial<Record<CapacityDimension, string | number>>>({});
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
view = await api.capacityPolicy(slug);
drafts = {};
for (const row of view.dimensions) {
drafts[row.dimension] = row.organizationLimit === null ? '' : String(row.organizationLimit);
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function draftText(dim: CapacityDimension): string {
const raw = drafts[dim];
if (raw === undefined || raw === null) return '';
return String(raw).trim();
}
// Inline validation: surfaced as the user edits, not on submit. Returns null
// when the draft is empty (means "use platform ceiling") or valid.
function draftError(row: CapacityDimensionRow): string | null {
const text = draftText(row.dimension);
if (text === '') return null;
const n = Number.parseInt(text, 10);
if (!Number.isFinite(n) || n < 1) return '需为正整数';
if (row.platformCeiling !== null && n > row.platformCeiling) return `不得超过 ${row.platformCeiling}`;
return null;
}
// Live effective value: min(platform ceiling, draft). Reflects the draft
// before save so the user sees the outcome as they type.
function liveEffective(row: CapacityDimensionRow): number | null {
if (row.platformCeiling === null) return null;
const text = draftText(row.dimension);
if (text === '') return row.platformCeiling;
const n = Number.parseInt(text, 10);
if (!Number.isFinite(n) || n < 1) return row.platformCeiling;
return Math.min(row.platformCeiling, n);
}
function isLowered(row: CapacityDimensionRow): boolean {
const eff = liveEffective(row);
return eff !== null && eff < row.platformCeiling!;
}
const hasErrors = $derived(
view?.dimensions.some((row) => draftError(row) !== null) ?? false,
);
async function save() {
if (!view || hasErrors) return;
saving = true;
const limits: Partial<Record<CapacityDimension, number | null>> = {};
for (const row of view.dimensions) {
const text = draftText(row.dimension);
limits[row.dimension] = text === '' ? null : Number.parseInt(text, 10);
}
try {
view = await api.setCapacityPolicy(slug, { limits });
drafts = {};
for (const row of view.dimensions) {
drafts[row.dimension] = row.organizationLimit === null ? '' : String(row.organizationLimit);
}
toastSuccess('容量策略已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="容量策略"
description="平台上限不可突破。组织可在此设更低限制,未设置时按平台上限执行。"
>
{#snippet actions()}
<button class="saas-btn-primary" disabled={saving || !view || hasErrors} onclick={save}>
{saving ? '保存中…' : '保存'}
</button>
{/snippet}
</PageHeader>
<p class="saas-muted mb-6">
未配置平台上限的维度暂不可设置组织限制。输入框留空即沿用平台上限。
</p>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if view}
{#if view.dimensions.length === 0}
<EmptyState title="暂无容量维度" description="容量维度由平台定义。" />
{:else}
{@const policy = view}
<div class="grid gap-4 md:grid-cols-2">
{#each GROUPS as group}
{@const rows = group.dims
.map((d) => policy.dimensions.find((r) => r.dimension === d))
.filter((r): r is CapacityDimensionRow => r !== undefined)}
{#if rows.length > 0}
<section class="saas-card">
<header class="group-header">
<h3 class="group-title">{group.title}</h3>
<span class="group-count">{rows.length}</span>
</header>
<div class="dim-list">
{#each rows as row (row.dimension)}
{@const err = draftError(row)}
{@const eff = liveEffective(row)}
{@const lowered = isLowered(row)}
{@const disabled = row.platformCeiling === null}
<div class="dim-row" class:dim-row-error={err !== null}>
<div class="dim-label">{DIMENSION_LABELS[row.dimension] ?? row.dimension}</div>
<div class="dim-ceiling">
{#if disabled}
<span class="text-surface-400">未配置</span>
{:else}
<span class="tabular-nums">平台 ≤ {row.platformCeiling}</span>
{/if}
</div>
<input
class="saas-input dim-input"
type="number"
placeholder="用平台值"
disabled={disabled}
bind:value={drafts[row.dimension]}
/>
<div class="dim-effective">
{#if eff === null}
<span class="text-surface-400"></span>
{:else if lowered}
<span class="saas-badge saas-badge-primary tabular-nums">有效 {eff}</span>
{:else}
<span class="saas-badge saas-badge-neutral tabular-nums">有效 {eff}</span>
{/if}
</div>
{#if err !== null}
<div class="dim-error">{err}</div>
{/if}
</div>
{/each}
</div>
</section>
{/if}
{/each}
</div>
{/if}
{:else}
<EmptyState title="容量数据不可用" description="无法加载容量策略。" />
{/if}
<style>
.group-header {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-surface-200);
background: var(--color-surface-100);
}
.group-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-surface-800);
letter-spacing: 0.02em;
}
.group-count {
font-size: 0.6875rem;
color: var(--color-surface-500);
}
.dim-list {
display: flex;
flex-direction: column;
}
.dim-row {
display: grid;
grid-template-columns: minmax(5rem, 1fr) auto minmax(7rem, 8.5rem) 6.5rem;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--color-surface-100);
}
.dim-row:last-child {
border-bottom: none;
}
.dim-row-error {
background: color-mix(in oklab, var(--color-error-100) 40%, transparent);
}
.dim-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-surface-900);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dim-ceiling {
font-size: 0.6875rem;
color: var(--color-surface-500);
white-space: nowrap;
}
.dim-input {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
text-align: right;
}
.dim-effective {
display: flex;
justify-content: flex-end;
}
.dim-effective > .saas-badge {
width: 100%;
justify-content: center;
}
.dim-error {
grid-column: 1 / -1;
font-size: 0.6875rem;
color: var(--color-error-700);
padding-bottom: 0.2rem;
}
@media (max-width: 480px) {
.dim-row {
grid-template-columns: 1fr 5rem;
grid-template-rows: auto auto auto;
row-gap: 0.25rem;
}
.dim-label {
grid-column: 1;
}
.dim-ceiling {
grid-column: 2;
text-align: right;
}
.dim-input {
grid-column: 1 / -1;
}
.dim-effective {
grid-column: 1 / -1;
justify-content: flex-start;
}
.dim-effective > .saas-badge {
width: auto;
justify-content: flex-start;
}
}
</style>
@@ -0,0 +1,179 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type FeishuApplicationConnection } 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 ?? '');
let connection = $state<FeishuApplicationConnection | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let appId = $state('');
let appSecret = $state('');
let botOpenId = $state('');
let verificationToken = $state('');
let encryptKey = $state('');
let saving = $state(false);
let disabling = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.feishuApplication(slug);
connection = res.connection;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function resetForm() {
appId = '';
appSecret = '';
botOpenId = '';
verificationToken = '';
encryptKey = '';
}
async function save() {
const id = appId.trim();
const secret = appSecret.trim();
const bot = botOpenId.trim();
if (id === '' || secret === '' || bot === '') {
toastError('App ID、App Secret、Bot Open ID 均为必填');
return;
}
saving = true;
const body: {
appId: string;
appSecret: string;
botOpenId: string;
verificationToken?: string;
encryptKey?: string;
} = { appId: id, appSecret: secret, botOpenId: bot };
const vt = verificationToken.trim();
if (vt !== '') body.verificationToken = vt;
const ek = encryptKey.trim();
if (ek !== '') body.encryptKey = ek;
try {
connection = await api.rotateFeishuApplication(slug, body);
resetForm();
toastSuccess('飞书应用凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable() {
if (!connection) return;
if (!confirm('停用后该组织将无法收发飞书消息,确定停用?')) return;
disabling = true;
try {
connection = await api.disableFeishuApplication(slug);
toastSuccess('已停用飞书应用连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="飞书应用"
description="本组织绑定的飞书应用凭据(ADR-0021:组织与应用 1:1)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
{#if connection}
<div class="saas-card-pad mb-6">
<p class="saas-section-title mb-3">当前连接</p>
<dl class="space-y-2.5 text-sm">
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">状态</dt>
<dd><span class="saas-badge-success">{connection.status}</span></dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">App 指纹</dt>
<dd class="font-mono text-xs text-surface-800">{connection.appFingerprint}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">版本</dt>
<dd class="tabular-nums">{connection.activeVersion ?? '—'}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">更新于</dt>
<dd class="text-surface-600">{fmtDate(connection.updatedAt)}</dd>
</div>
</dl>
{#if connection.status !== 'DISABLED'}
<div class="mt-5 flex items-center justify-end gap-3 border-t border-surface-100 pt-4">
<button class="saas-btn-ghost" onclick={disable} disabled={disabling}>
{disabling ? '停用中…' : '停用连接'}
</button>
</div>
{/if}
</div>
{:else}
<div class="saas-card-pad mb-6">
<p class="text-sm text-surface-700">本组织尚未绑定飞书应用。填写下方凭据以创建连接。</p>
</div>
{/if}
<div class="saas-card-pad">
<h3 class="saas-section-title mb-1">{connection ? '轮换凭据' : '创建连接'}</h3>
<p class="saas-muted mb-4">
密钥仅写入新版本,旧版本归档。{#if connection}App ID 不可变更,须与现有应用一致。{/if}
</p>
<div class="grid gap-5">
<div>
<Label.Root class="saas-label" for="app-id">App ID</Label.Root>
<input id="app-id" class="saas-input font-mono text-sm" bind:value={appId} />
</div>
<div>
<Label.Root class="saas-label" for="app-secret">App Secret</Label.Root>
<input id="app-secret" class="saas-input" type="password" bind:value={appSecret} />
</div>
<div>
<Label.Root class="saas-label" for="bot-open-id">Bot Open ID</Label.Root>
<input id="bot-open-id" class="saas-input font-mono text-sm" bind:value={botOpenId} />
</div>
<div>
<Label.Root class="saas-label" for="verification-token">Verification Token(可选)</Label.Root>
<input id="verification-token" class="saas-input" type="password" bind:value={verificationToken} />
</div>
<div>
<Label.Root class="saas-label" for="encrypt-key">Encrypt Key(可选)</Label.Root>
<input id="encrypt-key" class="saas-input" type="password" bind:value={encryptKey} />
</div>
</div>
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
@@ -0,0 +1,167 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type OrgMember } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate } from '$lib/format';
import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants';
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 SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
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 permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / ');
let members = $state<OrgMember[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newOpenId = $state('');
let newName = $state('');
let newRole = $state<string>('MEMBER');
let adding = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.members(slug);
members = res.members;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function addMember() {
if (!newOpenId.trim()) return;
adding = true;
try {
const m = await api.addMember(slug, {
feishuOpenId: newOpenId.trim(),
role: newRole,
...(newName.trim() ? { displayName: newName.trim() } : {}),
});
members = [...members, m].sort((a, b) => a.role.localeCompare(b.role) || a.createdAt.localeCompare(b.createdAt));
newOpenId = '';
newName = '';
toastSuccess('成员已添加');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
async function changeRole(m: OrgMember, role: string) {
try {
await api.setMemberRole(slug, m.userId, role);
await load();
toastSuccess('角色已更新');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function revoke(m: OrgMember) {
if (!confirm(`移除成员 ${m.displayName} 出本组织?`)) return;
try {
await api.revokeMember(slug, m.userId);
await load();
toastSuccess('成员已移除');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader title="成员与权限" description="管理组织角色:所有者与管理员可访问本后台,成员不可。" />
{#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 md:grid-cols-[1.2fr_1fr_12rem_auto]">
<input class="saas-input" placeholder="飞书 open_id" bind:value={newOpenId} />
<input class="saas-input" placeholder="显示名(可选)" bind:value={newName} />
<SelectField items={roleItems} bind:value={newRole} />
<button class="saas-btn-primary" onclick={addMember} disabled={adding}>
{adding ? '添加中…' : '添加成员'}
</button>
</div>
</div>
<div class="saas-card overflow-hidden">
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
<h2 class="text-sm font-semibold">成员列表</h2>
<span class="saas-badge-neutral">{members.length}</span>
</div>
{#if members.length === 0}
<EmptyState title="暂无成员" description="使用上方表单按飞书 open_id 添加成员。" />
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>用户</th>
<th>open_id</th>
<th>组织角色</th>
<th>加入时间</th>
<th></th>
</tr>
</thead>
<tbody>
{#each members as m}
<tr>
<td>
<div class="flex items-center gap-2.5">
{#if m.avatarUrl}
<img src={m.avatarUrl} alt="" class="h-7 w-7 border border-surface-300 object-cover" />
{:else}
<div
class="flex h-7 w-7 items-center justify-center border border-primary-300 bg-primary-100 text-xs font-semibold text-primary-800"
>
{m.displayName.slice(0, 1)}
</div>
{/if}
<span class="font-medium">{m.displayName}</span>
</div>
</td>
<td class="font-mono text-xs text-surface-700">{m.feishuOpenId}</td>
<td class="min-w-36">
<SelectField
items={roleItems}
value={m.role}
onchange={(role) => {
if (role !== m.role) changeRole(m, role);
}}
/>
</td>
<td class="text-surface-700">{fmtDate(m.createdAt)}</td>
<td class="text-right">
<button class="saas-btn-danger py-1! text-sm" onclick={() => revoke(m)}>移除</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<p class="border-t border-surface-300 px-5 py-3 text-xs text-surface-600">
组织角色控制后台访问;项目级权限由「项目」页团队授权({permHint})决定。
</p>
</div>
{/if}
@@ -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>
@@ -0,0 +1,223 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import FolderTree from '$lib/components/FolderTree.svelte';
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 { Label } from 'bits-ui';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { fmtDate } from '$lib/format';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
let data = $state<ExplorerData | null>(null);
let myProjects = $state<ExplorerProject[] | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let showFolderModal = $state(false);
let folderName = $state('');
let folderParent = $state('');
let showProjectModal = $state(false);
let projectName = $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() {
loading = true;
error = null;
try {
if (isAdmin) {
data = await api.explorer(slug);
myProjects = null;
} else {
myProjects = (await api.myProjects(slug)).projects;
data = null;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function createFolder() {
if (!folderName.trim()) return;
try {
await api.createFolder(slug, {
name: folderName.trim(),
...(folderParent ? { parentId: folderParent } : {}),
});
folderName = '';
folderParent = '';
showFolderModal = false;
await load();
toastSuccess('文件夹已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function createProject() {
if (!projectName.trim()) return;
try {
const res = await api.createProject(slug, {
name: projectName.trim(),
...(projectFolder ? { folderId: projectFolder } : {}),
});
projectName = '';
projectFolder = '';
showProjectModal = false;
window.location.href = `/admin/projects/${res.id}`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
function folderPath(f: ExplorerFolder): string {
if (!data) return f.name;
const parts: string[] = [f.name];
let cur: ExplorerFolder | undefined = f;
while (cur?.parentId) {
const parent = data.folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
function folderItems() {
if (!data) return [{ value: '', label: '(根)' }];
return [{ value: '', label: '(根)' }, ...data.folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
$effect(() => {
if (slug && org) load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if isAdmin && data}
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
{#snippet actions()}
<button class="saas-btn-secondary" onclick={() => openFolderModal('')}>新建文件夹</button>
<button class="saas-btn-primary" onclick={() => openProjectModal('')}>新建项目</button>
{/snippet}
</PageHeader>
<div class="saas-card p-2 sm:p-3">
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
{:else}
<FolderTree
folders={data.folders}
projects={data.projects}
parentId={null}
{slug}
onCreateFolder={openFolderModal}
onCreateProject={openProjectModal}
/>
{/if}
</div>
<Modal bind:open={showFolderModal} title="新建文件夹">
<Label.Root class="saas-label" for="folder-name">名称</Label.Root>
<input
id="folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') createFolder();
}}
/>
{#if data && data.folders.length > 0}
<p class="saas-label">父文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={folderParent} />
</div>
{/if}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={createFolder}>创建</button>
</div>
</Modal>
<Modal bind:open={showProjectModal} title="新建项目">
<Label.Root class="saas-label" for="project-name">项目名</Label.Root>
<input
id="project-name"
class="saas-input mb-4"
bind:value={projectName}
onkeydown={(e) => {
if (e.key === 'Enter') createProject();
}}
/>
{#if data && data.folders.length > 0}
<p class="saas-label">文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={projectFolder} />
</div>
{/if}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showProjectModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={createProject}>创建</button>
</div>
</Modal>
{:else if myProjects !== null}
<PageHeader title="我的项目" description="你拥有访问授权的项目。">
{#snippet actions()}
<span class="saas-badge-neutral">仅显示已授权项目</span>
{/snippet}
</PageHeader>
<div class="saas-card overflow-hidden">
{#if myProjects.length === 0}
<EmptyState title="暂无可访问项目" description="当团队被授予项目访问权限时,项目会出现在这里。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>飞书群</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
{#each myProjects as p}
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/projects/${p.id}`)}>
<td class="font-medium">{p.name}</td>
<td class="font-mono text-xs">{p.binding ? ` ${p.binding.chatId}` : '—'}</td>
<td class="text-surface-700">{fmtDate(p.createdAt)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{:else}
<EmptyState title="项目数据不可用" description="无法加载项目列表。" />
{/if}
@@ -0,0 +1,390 @@
<script lang="ts">
import { page } from '$app/state';
import {
api,
type ProjectDetail,
type TeamAccessEntry,
type TeamRow,
type SessionSummary,
type ExplorerData,
type ProjectUsageReport,
} from '$lib/api';
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 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 SelectField from '$lib/components/SelectField.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const projectId = $derived(page.params.projectId ?? '');
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}`;
let proj = $state<ProjectDetail | null>(null);
let access = $state<TeamAccessEntry[]>([]);
let sessions = $state<SessionSummary[]>([]);
let projectUsage = $state<ProjectUsageReport | null>(null);
let teams = $state<TeamRow[]>([]);
let explorer = $state<ExplorerData | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let grantTeam = $state('');
let grantRole = $state<string>('EDIT');
let moveFolder = $state('');
const actorIsOrgAdmin = $derived(proj?.actorIsOrgAdmin ?? false);
const actorCanManage = $derived(proj?.actorCanManageProject ?? false);
async function load() {
loading = true;
error = null;
try {
// Project detail + team-access list are gated to project read/oversight.
const [p, a] = await Promise.all([api.project(slug, projectId), api.teamAccess(slug, projectId)]);
proj = p;
access = a.access;
// Team list is needed for grant UI whenever the actor has project MANAGE
// (org admin or member). Sessions/explorer stay org-admin oversight only.
const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true;
const [s, t, e, u] = await Promise.all([
p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }),
needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }),
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;
teams = t.teams;
explorer = e;
projectUsage = u;
if (p.actorIsOrgAdmin) {
moveFolder = p.folderId ?? '';
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function rename() {
if (!proj) return;
const name = prompt('新名称', proj.name);
if (!name) return;
try {
await api.renameProject(slug, projectId, name);
await load();
toastSuccess('已重命名');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function archiveBinding() {
if (!confirm('解绑当前飞书群? 用户将无法通过该群触发智能体。')) return;
try {
await api.archiveBinding(slug, projectId);
await load();
toastSuccess('已解绑飞书群');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function archiveProject() {
if (!confirm(`归档项目 ${proj?.name}?`)) return;
try {
await api.archiveProject(slug, projectId);
window.location.href = `/admin/projects`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function move() {
try {
await api.moveProject(slug, projectId, moveFolder || null);
await load();
toastSuccess('已移动');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function grant() {
if (!grantTeam) return;
try {
await api.grantTeamAccess(slug, projectId, { teamId: grantTeam, role: grantRole });
grantTeam = '';
await load();
toastSuccess('已授权');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function revoke(t: TeamAccessEntry) {
if (!confirm(`撤销 ${t.teamName} 对此项目的授权?`)) return;
try {
await api.revokeTeamAccess(slug, projectId, t.teamId);
await load();
toastSuccess('已撤销授权');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
function folderItems() {
const items = [{ value: '', label: '(根)' }];
if (!explorer) return items;
return [...items, ...explorer.folders.map((f) => ({ value: f.id, label: f.name }))];
}
function teamItems() {
return [{ value: '', label: '选择团队…' }, ...teams.map((t) => ({ value: t.id, label: `${t.name}${t.slug}` }))];
}
$effect(() => {
if (slug && projectId) load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if proj}
<div class="mb-2">
<a
href={`/admin/projects`}
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>
</div>
{@const detail = proj}
<PageHeader title={detail.name} description={`项目是权限边界;通过团队授予 ${roleChain}。`}>
{#snippet actions()}
{#if actorIsOrgAdmin}
<button class="saas-btn-secondary py-1.5! text-sm" onclick={rename}>重命名</button>
{#if detail.binding}
<button class="saas-btn-secondary py-1.5! text-sm" onclick={archiveBinding}>解绑飞书群</button>
{/if}
<button class="saas-btn-danger py-1.5! text-sm" onclick={archiveProject}>归档</button>
{/if}
{/snippet}
</PageHeader>
<div class="saas-card-pad mb-6">
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-600">工作区路径</dt>
<dd class="mt-0.5 break-all font-mono text-xs text-surface-700">{proj.workspaceDir}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-600">创建者</dt>
<dd class="mt-0.5 text-surface-800">
{proj.createdBy ? `${proj.createdBy.displayName} (${proj.createdBy.feishuOpenId})` : '—'}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-600">文件夹</dt>
<dd class="mt-0.5 text-surface-800">{proj.folder ? proj.folder.name : '(根)'}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-600">飞书群</dt>
<dd class="mt-0.5 text-surface-800">
{#if proj.binding}
<span class="saas-badge-success mr-1">已绑定</span>
<span class="font-mono text-xs">{proj.binding.chatId}</span>
<span class="text-surface-600"> · {fmtDate(proj.binding.createdAt)}</span>
{:else}
<span class="saas-badge-neutral">未绑定</span>
{/if}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-surface-600">创建时间</dt>
<dd class="mt-0.5 text-surface-800">{fmtDate(proj.createdAt)}</dd>
</div>
</dl>
{#if explorer}
<div class="mt-5 flex flex-wrap items-end gap-2 border-t border-surface-100 pt-4">
<div class="min-w-48 flex-1">
<p class="saas-label">移动到文件夹</p>
<SelectField items={folderItems()} bind:value={moveFolder} />
</div>
<button class="saas-btn-secondary" onclick={move}>移动</button>
</div>
{/if}
</div>
<div class="saas-card-pad mb-6">
<h3 class="saas-section-title mb-1">团队授权</h3>
<p class="saas-muted mb-4">通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。</p>
{#if actorCanManage}
<div class="mb-4 grid gap-2 sm:grid-cols-[1fr_10rem_auto]">
<SelectField items={teamItems()} bind:value={grantTeam} />
<SelectField items={roleItems} bind:value={grantRole} />
<button class="saas-btn-primary" onclick={grant}>授权</button>
</div>
{:else}
<p class="mb-4 text-xs text-surface-600">需要项目 MANAGE 授权才能增删团队访问。</p>
{/if}
{#if access.length === 0}
<EmptyState title="暂无团队授权" description="选择团队并授予角色以开放项目访问。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>团队</th>
<th>标识</th>
<th>角色</th>
<th></th>
</tr>
</thead>
<tbody>
{#each access as g}
<tr>
<td class="font-medium">{g.teamName}</td>
<td class="font-mono text-xs">/{g.teamSlug}</td>
<td><span class="saas-badge-primary">{permissionRoleLabel(g.role)}</span></td>
<td class="text-right">
{#if actorCanManage}
<button class="saas-btn-danger py-1! text-xs" onclick={() => revoke(g)}>撤销</button>
{:else}
<span class="text-xs text-surface-500"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{#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="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold">智能体会话</h3>
<p class="saas-muted mt-0.5 text-xs">点进会话可查看每次 run 的 UsageFact 分账(模型 / 外部能力)。</p>
</div>
{#if sessions.length === 0}
<EmptyState title="暂无会话" description="飞书侧触发智能体后会显示在此。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>供应方 / 角色</th>
<th>模型</th>
<th>运行次数</th>
<th>更新</th>
<th></th>
</tr>
</thead>
<tbody>
{#each sessions as s}
<tr>
<td class="font-mono text-xs">{s.provider} / {s.roleId}</td>
<td class="font-mono text-xs">{s.model}</td>
<td class="tabular-nums">{s.runCount}</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>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
{:else}
<div class="saas-empty">
<p class="text-sm text-surface-600">项目数据不可用</p>
</div>
{/if}
@@ -0,0 +1,172 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ProviderConnectionRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { fmtDate, providerModeLabel } 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 ?? '');
let connections = $state<ProviderConnectionRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let providerId = $state('');
let baseUrl = $state('');
let authToken = $state('');
let anthropicApiKey = $state('');
let saving = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.providerConnections(slug);
connections = res.connections;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function startRotate(row: ProviderConnectionRow) {
providerId = row.providerId;
baseUrl = '';
authToken = '';
anthropicApiKey = '';
}
function resetForm() {
providerId = '';
baseUrl = '';
authToken = '';
anthropicApiKey = '';
}
async function save() {
const id = providerId.trim();
if (id === '') {
toastError('请填写供应方 ID');
return;
}
const url = baseUrl.trim();
const token = authToken.trim();
if (url === '' || token === '') {
toastError('接口地址与访问令牌均为必填');
return;
}
saving = true;
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
baseUrl: url,
authToken: token,
};
const key = anthropicApiKey.trim();
if (key !== '') body.anthropicApiKey = key;
try {
await api.rotateProviderConnection(slug, id, body);
toastSuccess('凭据已轮换');
resetForm();
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="模型供应方"
description="本组织的模型供应方连接。BYOK 由组织所有者/管理员轮换;平台托管连接由平台管理员配置。凭据按组织隔离,缺失或校验失败即拒绝运行(fail-closed)。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<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>
</div>
{#if connections.length === 0}
<div class="saas-empty"><p class="text-sm text-surface-600">尚无供应方连接</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>
</tr>
</thead>
<tbody>
{#each connections as row}
<tr>
<td class="font-mono text-sm">{row.providerId}</td>
<td>{providerModeLabel(row.mode)}</td>
<td>{row.status}</td>
<td class="tabular-nums">{row.activeVersion ?? '—'}</td>
<td class="text-surface-600">{fmtDate(row.updatedAt)}</td>
<td>
{#if row.mode === 'BYOK'}
<button class="saas-btn-ghost px-2! py-1! text-xs" onclick={() => startRotate(row)}>轮换</button>
{:else}
<span class="text-xs text-surface-500">平台管理</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="saas-card-pad">
<h3 class="saas-section-title mb-1">轮换 BYOK 凭据</h3>
<p class="saas-muted mb-4">
密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。
</p>
<div class="grid gap-5">
<div>
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
<input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} placeholder="openrouter" />
</div>
<div>
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
<input id="base-url" class="saas-input" placeholder="https://openrouter.ai/api" bind:value={baseUrl} />
</div>
<div>
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
<input id="auth-token" class="saas-input" type="password" bind:value={authToken} />
</div>
<div>
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root>
<input id="anthropic-key" class="saas-input" type="password" bind:value={anthropicApiKey} />
</div>
</div>
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
{/if}
@@ -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}
@@ -0,0 +1,236 @@
<script lang="ts">
import { Collapsible } from 'bits-ui';
import { page } from '$app/state';
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 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 { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let teams = $state<TeamRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newSlug = $state('');
let newName = $state('');
let newDesc = $state('');
let adding = $state(false);
let expandedId = $state<string | null>(null);
let teamMembers = $state<TeamMemberRow[]>([]);
let memberInput = $state('');
let loadingMembers = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.teams(slug);
teams = res.teams;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
async function createTeam() {
if (!newSlug.trim() || !newName.trim()) return;
const teamSlug = newSlug.trim().toLowerCase();
if (!SLUG_RE.test(teamSlug)) {
toastError('标识须为小写字母数字,可用连字符连接');
return;
}
adding = true;
try {
await api.createTeam(slug, {
slug: teamSlug,
name: newName.trim(),
...(newDesc.trim() ? { description: newDesc.trim() } : {}),
});
newSlug = '';
newName = '';
newDesc = '';
await load();
toastSuccess('团队已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
adding = false;
}
}
async function archiveTeam(t: TeamRow) {
if (!confirm(`归档团队 ${t.name}? 归档后该团队不再解析为项目授权主体。`)) return;
try {
await api.archiveTeam(slug, t.id);
if (expandedId === t.id) expandedId = null;
await load();
toastSuccess('团队已归档');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function openMembers(t: TeamRow) {
if (expandedId === t.id) return;
expandedId = t.id;
loadingMembers = true;
memberInput = '';
try {
const res = await api.teamMembers(slug, t.id);
teamMembers = res.members;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
loadingMembers = false;
}
}
function onExpandChange(t: TeamRow, open: boolean) {
if (open) void openMembers(t);
else if (expandedId === t.id) expandedId = null;
}
async function addMember(t: TeamRow) {
if (!memberInput.trim()) return;
const v = memberInput.trim();
try {
await api.addTeamMember(slug, t.id, v.startsWith('ou') ? { feishuOpenId: v } : { userId: v });
memberInput = '';
const res = await api.teamMembers(slug, t.id);
teamMembers = res.members;
await load();
toastSuccess('已加入团队');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function revokeMember(t: TeamRow, m: TeamMemberRow) {
if (!confirm(`将 ${m.displayName} 移出团队 ${t.name}?`)) return;
try {
await api.revokeTeamMember(slug, t.id, m.userId);
const res = await api.teamMembers(slug, t.id);
teamMembers = res.members;
await load();
toastSuccess('已移出团队');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader title="团队" description="团队是项目授权主体,可被授予只读、编辑或管理权限。" />
{#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 md:grid-cols-[1fr_1fr_1.2fr_auto]">
<input class="saas-input" placeholder="标识(如 math-g7" bind:value={newSlug} />
<input class="saas-input" placeholder="名称" bind:value={newName} />
<input class="saas-input" placeholder="描述(可选)" bind:value={newDesc} />
<button class="saas-btn-primary" onclick={createTeam} disabled={adding}>新建</button>
</div>
</div>
{#if teams.length === 0}
<div class="saas-card">
<EmptyState title="暂无团队" description="创建团队后,可在项目页授权项目访问。" />
</div>
{:else}
<div class="space-y-3">
{#each teams as t (t.id)}
<Collapsible.Root
class="saas-card p-5"
open={expandedId === t.id}
onOpenChange={(open) => onExpandChange(t, open)}
>
<div class="flex flex-wrap items-center gap-2.5">
<span class="text-base font-semibold text-surface-900">{t.name}</span>
<span class="font-mono text-xs text-surface-600">/{t.slug}</span>
<span class="saas-badge-neutral">{t.memberCount} 成员</span>
<div class="ml-auto flex flex-wrap gap-2">
<Collapsible.Trigger class="saas-btn-secondary py-1.5! text-sm">
{expandedId === t.id ? '收起' : '管理成员'}
</Collapsible.Trigger>
<button type="button" class="saas-btn-danger py-1.5! text-sm" onclick={() => archiveTeam(t)}>
归档
</button>
</div>
</div>
{#if t.description}
<p class="mt-1.5 text-sm text-surface-700">{t.description}</p>
{/if}
<p class="mt-1 text-xs text-surface-600">创建于 {fmtDate(t.createdAt)}</p>
<Collapsible.Content>
<div class="mt-4 border-t border-surface-200 pt-4">
{#if loadingMembers && expandedId === t.id}
<p class="text-sm text-surface-600">加载中…</p>
{:else if expandedId === t.id}
<div class="mb-4 flex gap-2">
<input
class="saas-input"
placeholder="飞书 open_id 或用户 id"
bind:value={memberInput}
onkeydown={(e) => {
if (e.key === 'Enter') addMember(t);
}}
/>
<button class="saas-btn-secondary shrink-0" onclick={() => addMember(t)}>加入</button>
</div>
{#if teamMembers.length === 0}
<p class="py-3 text-center text-sm text-surface-600">团队暂无成员</p>
{:else}
<table class="data-table">
<thead>
<tr>
<th>成员</th>
<th>open_id</th>
<th>加入时间</th>
<th></th>
</tr>
</thead>
<tbody>
{#each teamMembers as m}
<tr>
<td class="font-medium">{m.displayName}</td>
<td class="font-mono text-xs">{m.feishuOpenId}</td>
<td class="text-surface-700">{fmtDate(m.createdAt)}</td>
<td class="text-right">
<button class="saas-btn-danger py-1! text-xs" onclick={() => revokeMember(t, m)}>
移除
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/each}
</div>
{/if}
<p class="mt-4 text-xs text-surface-600">归档团队会同步撤销其活跃的项目授权。</p>
{/if}
@@ -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}
+692
View File
@@ -0,0 +1,692 @@
@import 'tailwindcss';
@import '@skeletonlabs/skeleton';
@import '@skeletonlabs/skeleton/themes/hamlindigo';
@source './**/*.{html,js,svelte,ts}';
@source '../lib/**/*.{html,js,svelte,ts}';
/* Flat industrial: zero radius, higher-contrast surfaces, CJK-first type */
@theme {
--font-sans:
'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Inter', ui-sans-serif, system-ui,
-apple-system, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--radius-none: 0;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
--radius-3xl: 0;
--radius-full: 0;
--radius: 0;
}
@layer base {
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings:
'kern' 1,
'liga' 1;
/* Prefer readable CJK metrics over Latin optical sizing */
text-size-adjust: 100%;
}
body {
min-height: 100%;
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.7;
letter-spacing: 0.01em;
/* Slightly cooler industrial surface */
background: var(--color-surface-100);
color: var(--color-surface-950, var(--color-surface-900));
font-variant-east-asian: proportional-width;
}
/* CJK headings: no negative tracking, breathing line-height */
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
line-height: 1.45;
letter-spacing: 0;
color: var(--color-surface-950, var(--color-surface-900));
}
p {
line-height: 1.75;
}
/* Harder focus ring for industrial UI */
:focus-visible {
outline: 2px solid var(--color-primary-600);
outline-offset: 2px;
}
::selection {
background: color-mix(in oklab, var(--color-primary-600) 35%, transparent);
color: var(--color-surface-950, var(--color-surface-900));
}
/* Tables: high-contrast grid, flat */
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
line-height: 1.6;
}
table.data-table thead th {
padding: 0.625rem 0.75rem;
text-align: left;
font-weight: 600;
color: var(--color-surface-700);
background: var(--color-surface-100);
border-bottom: 1px solid var(--color-surface-300);
white-space: nowrap;
letter-spacing: 0.02em;
}
table.data-table tbody td {
padding: 0.75rem;
border-bottom: 1px solid var(--color-surface-200);
vertical-align: middle;
color: var(--color-surface-900);
}
table.data-table tbody tr:hover td {
background: var(--color-surface-100);
}
table.data-table tbody tr:last-child td {
border-bottom: none;
}
}
@layer components {
.saas-card {
border-radius: 0;
border: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
box-shadow: none;
}
.saas-card-pad {
border-radius: 0;
border: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
box-shadow: none;
padding: 1.25rem;
}
.saas-page-title {
font-size: 1.375rem;
line-height: 1.4;
font-weight: 700;
letter-spacing: 0;
color: var(--color-surface-950, var(--color-surface-900));
}
.saas-section-title {
font-size: 1rem;
line-height: 1.5;
font-weight: 600;
letter-spacing: 0;
color: var(--color-surface-950, var(--color-surface-900));
}
.saas-muted {
font-size: 0.875rem;
line-height: 1.65;
color: var(--color-surface-700);
}
.saas-label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.875rem;
line-height: 1.5;
font-weight: 600;
color: var(--color-surface-800);
}
.saas-help {
margin-top: 0.375rem;
font-size: 0.8125rem;
line-height: 1.6;
color: var(--color-surface-600);
}
.saas-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.saas-stat {
border-radius: 0;
border: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
padding: 1rem;
box-shadow: none;
}
.saas-stat-label {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-surface-600);
}
.saas-stat-value {
margin-top: 0.25rem;
font-size: 1.5rem;
line-height: 1.3;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
color: var(--color-surface-950, var(--color-surface-900));
}
.saas-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 3rem 1rem;
text-align: center;
line-height: 1.7;
}
.saas-shell {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--color-surface-100);
}
.saas-sidebar {
display: flex;
width: 16rem;
flex-shrink: 0;
flex-direction: column;
border-right: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
}
.saas-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.saas-topbar {
display: flex;
height: 3.5rem;
flex-shrink: 0;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--color-surface-300);
background: var(--color-surface-50);
padding: 0 1.5rem;
/* flat: no glass */
backdrop-filter: none;
}
.saas-content {
flex: 1;
overflow-y: auto;
}
.saas-content-inner {
margin-inline: auto;
width: 100%;
max-width: 72rem;
padding: 1.5rem;
}
@media (min-width: 768px) {
.saas-content-inner {
padding: 2rem;
}
}
.saas-nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
border-radius: 0;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.5;
font-weight: 500;
color: var(--color-surface-700);
border-left: 2px solid transparent;
transition:
color 0.1s,
background-color 0.1s,
border-color 0.1s;
}
.saas-nav-item:hover {
background: var(--color-surface-100);
color: var(--color-surface-950, var(--color-surface-900));
}
.saas-nav-item[data-active='true'] {
background: var(--color-primary-50, var(--color-primary-100));
color: var(--color-primary-800);
border-left-color: var(--color-primary-600);
font-weight: 600;
}
.saas-badge {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
letter-spacing: 0.02em;
}
.saas-badge-neutral {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
border: 1px solid var(--color-surface-300);
background: var(--color-surface-100);
color: var(--color-surface-800);
}
.saas-badge-primary {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
border: 1px solid var(--color-primary-300);
background: var(--color-primary-100);
color: var(--color-primary-800);
}
.saas-badge-success {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
border: 1px solid var(--color-success-300);
background: var(--color-success-100);
color: var(--color-success-800);
}
.saas-badge-warning {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
border: 1px solid var(--color-warning-300);
background: var(--color-warning-100);
color: var(--color-warning-900);
}
.saas-badge-error {
display: inline-flex;
align-items: center;
border-radius: 0;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 600;
border: 1px solid var(--color-error-300);
background: var(--color-error-100);
color: var(--color-error-800);
}
.saas-input,
.saas-select,
.saas-textarea {
width: 100%;
border-radius: 0;
border: 1px solid var(--color-surface-400);
background: var(--color-surface-50);
color: var(--color-surface-950, var(--color-surface-900));
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.5;
outline: none;
transition:
border-color 0.1s,
box-shadow 0.1s;
}
.saas-input::placeholder,
.saas-textarea::placeholder {
color: var(--color-surface-500);
}
.saas-input:focus,
.saas-select:focus,
.saas-textarea:focus {
border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600);
}
.saas-textarea {
font-family: var(--font-mono);
line-height: 1.55;
resize: vertical;
}
.saas-select-trigger {
display: inline-flex;
width: 100%;
align-items: center;
border-radius: 0;
border: 1px solid var(--color-surface-400);
background: var(--color-surface-50);
color: var(--color-surface-950, var(--color-surface-900));
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.5;
outline: none;
transition:
border-color 0.1s,
box-shadow 0.1s;
cursor: pointer;
text-align: left;
}
.saas-select-trigger:focus-visible,
.saas-select-trigger[data-state='open'] {
border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600);
}
.saas-select-trigger:disabled,
.saas-select-trigger[data-disabled] {
cursor: not-allowed;
opacity: 0.55;
}
.saas-select-trigger [data-placeholder] {
color: var(--color-surface-500);
}
.saas-select-content {
z-index: 70;
max-height: min(18rem, var(--bits-select-content-available-height, 18rem));
width: var(--bits-select-anchor-width);
min-width: var(--bits-select-anchor-width);
overflow: hidden;
border-radius: 0;
border: 1px solid var(--color-surface-400);
background: var(--color-surface-50);
box-shadow: 4px 4px 0 rgb(15 23 42 / 0.12);
outline: none;
}
.saas-select-item {
display: flex;
align-items: center;
border-radius: 0;
padding: 0.45rem 0.65rem;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-surface-900);
cursor: pointer;
outline: none;
user-select: none;
}
.saas-select-item[data-highlighted] {
background: var(--color-primary-100);
color: var(--color-primary-900);
}
.saas-select-item[data-selected] {
color: var(--color-primary-900);
font-weight: 600;
}
.saas-select-item[data-disabled] {
cursor: not-allowed;
opacity: 0.45;
}
.saas-btn-primary,
.saas-btn-secondary,
.saas-btn-ghost,
.saas-btn-danger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
border-radius: 0;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.4;
letter-spacing: 0.01em;
border: 1px solid transparent;
cursor: pointer;
transition:
background-color 0.1s,
color 0.1s,
border-color 0.1s,
opacity 0.1s;
}
.saas-btn-primary:disabled,
.saas-btn-secondary:disabled,
.saas-btn-ghost:disabled,
.saas-btn-danger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.saas-btn-primary {
background: var(--color-primary-600);
border-color: var(--color-primary-700);
color: var(--color-primary-contrast-500, white);
}
.saas-btn-primary:hover:not(:disabled) {
background: var(--color-primary-700);
border-color: var(--color-primary-800);
}
.saas-btn-secondary {
background: var(--color-surface-100);
border-color: var(--color-surface-400);
color: var(--color-surface-900);
}
.saas-btn-secondary:hover:not(:disabled) {
background: var(--color-surface-200);
border-color: var(--color-surface-500);
}
.saas-btn-ghost {
background: transparent;
border-color: transparent;
color: var(--color-surface-800);
}
.saas-btn-ghost:hover:not(:disabled) {
background: var(--color-surface-200);
color: var(--color-surface-950, var(--color-surface-900));
}
.saas-btn-danger {
background: var(--color-error-100);
border-color: var(--color-error-400);
color: var(--color-error-800);
}
.saas-btn-danger:hover:not(:disabled) {
background: var(--color-error-200);
border-color: var(--color-error-500);
}
.saas-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
border-radius: 0;
border: 1px solid var(--color-surface-500);
background: var(--color-surface-50);
color: white;
cursor: pointer;
transition:
background 0.1s,
border-color 0.1s;
}
.saas-checkbox[data-state='checked'] {
background: var(--color-primary-700);
border-color: var(--color-primary-700);
}
.saas-checkbox:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--color-surface-50),
0 0 0 4px var(--color-primary-600);
}
.saas-checkbox[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
/* Square industrial switch (no pill) */
.saas-switch {
position: relative;
display: inline-flex;
width: 2.5rem;
height: 1.35rem;
flex-shrink: 0;
align-items: center;
border-radius: 0;
border: 1px solid var(--color-surface-500);
background: var(--color-surface-300);
padding: 0.125rem;
cursor: pointer;
transition:
background 0.1s,
border-color 0.1s;
}
.saas-switch[data-state='checked'] {
background: var(--color-primary-600);
border-color: var(--color-primary-700);
}
.saas-switch:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--color-surface-50),
0 0 0 4px var(--color-primary-600);
}
.saas-switch[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
.saas-switch-thumb {
display: block;
width: 1rem;
height: 1rem;
border-radius: 0;
background: white;
border: 1px solid var(--color-surface-400);
box-shadow: none;
transition: transform 0.1s;
transform: translateX(0);
}
.saas-switch[data-state='checked'] .saas-switch-thumb,
.saas-switch-thumb[data-state='checked'] {
transform: translateX(1.1rem);
border-color: var(--color-primary-800);
}
.saas-modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: rgb(2 6 23 / 0.55);
backdrop-filter: none;
}
.saas-modal {
position: fixed;
left: 50%;
top: 50%;
z-index: 51;
width: calc(100% - 2rem);
max-width: 28rem;
transform: translate(-50%, -50%);
border-radius: 0;
border: 1px solid var(--color-surface-400);
background: var(--color-surface-50);
padding: 1.5rem;
box-shadow: 6px 6px 0 rgb(15 23 42 / 0.15);
outline: none;
}
.saas-status-panel {
display: flex;
min-height: 100vh;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--color-surface-100);
padding: 1rem;
}
.saas-status-card {
width: 100%;
max-width: 28rem;
border-radius: 0;
border: 1px solid var(--color-surface-400);
background: var(--color-surface-50);
padding: 2rem;
text-align: center;
box-shadow: 4px 4px 0 rgb(15 23 42 / 0.1);
line-height: 1.7;
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#4F46E5"/>
<path d="M8 10.5h7.5a3.5 3.5 0 0 1 0 7H11v4H8v-11Zm3 4.5h4.5a1.5 1.5 0 0 0 0-3H11v3Z" fill="white"/>
<path d="M20.5 21.5c1.93 0 3.5-1.34 3.5-3s-1.57-3-3.5-3S17 16.84 17 18.5s1.57 3 3.5 3Z" fill="white" opacity=".9"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+18
View File
@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
},
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+17
View File
@@ -0,0 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
'/api': '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',
},
},
});
+26
View File
@@ -11,6 +11,32 @@ See [NEW_SILO_RUNBOOK.md](NEW_SILO_RUNBOOK.md). The remainder of this document
describes the individual installer and maintenance primitives used by the
generated bundle.
## CI: roll Hub + admin SPA by fleet (`educraft` / `educraft-dev`)
`.gitea/workflows/deploy-admin.yml` deploys Hub releases (including the org-admin
SPA) to the managed Alpha host via `deploy_fleet_release.sh`. Fleets share the
host and are selected by the middle DNS label of `HUB_PUBLIC_BASE_URL`:
| Fleet | Domain | CI trigger |
|-------|--------|------------|
| **dev** | `https://<slug>.educraft-dev.paradigm-edu.net` | every push + PR (paths under `hub/`) |
| **prod** | `https://<slug>.educraft.paradigm-edu.net` | push to `main` + tags |
Example prod tenant:
`https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100`.
Required Gitea secret: `DEPLOY_SSH_KEY` (private key for `root@39.107.254.4`).
Optional: `DEPLOY_HOST` / `DEPLOY_USER` / `DEPLOY_SSH_PORT` / `DEPLOY_BASE`.
Set repository var `ALLOW_EMPTY_FLEET=1` until the first silo exists for a fleet.
Local dry-run against the host:
```sh
CPH_FLEET=dev \
PLATFORM_DEPLOY_SSH_KEY=$HOME/.ssh/id_ed25519 \
bash hub/deploy/deploy_fleet_release.sh
```
The supervised alpha runs one Organization per named Silo. The supported host
has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`,
bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`.
+4 -1
View File
@@ -49,7 +49,10 @@ async function findProjectFiles(directory) {
for (const entry of entries) {
if (entry.name === ".trash") continue;
const path = resolve(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error(`legacy manifest rejects symbolic link: ${path}`);
if (entry.isSymbolicLink()) {
process.stderr.write(`[legacy-manifest] skip untracked symbolic link: ${path}\n`);
continue;
}
if (entry.isDirectory()) found.push(...await findProjectFiles(path));
}
return found;
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env bash
# Publish one immutable Hub release (tsc + admin-web SPA) to the managed host,
# then repoint every Silo in the chosen fleet to that release and restart it.
#
# Fleet is selected by the middle DNS label of each Silo's HUB_PUBLIC_BASE_URL:
#
# prod → https://<slug>.educraft.paradigm-edu.net
# dev → https://<slug>.educraft-dev.paradigm-edu.net
#
# Example tenant (prod):
# https://para-26071100.educraft.paradigm-edu.net/auth/feishu/para-26071100
#
# Configure (CI secrets / env):
# CPH_FLEET required: dev | prod
# PLATFORM_DEPLOY_HOST default 39.107.254.4
# PLATFORM_DEPLOY_USER default root
# PLATFORM_DEPLOY_SSH_KEY required (path to private key)
# PLATFORM_DEPLOY_PORT default 22
# PLATFORM_DEPLOY_BASE default /srv/curriculum-project-hub
# PLATFORM_DEPLOY_RELEASE default git HEAD (must be safe for paths)
# ALLOW_EMPTY_FLEET optional: if 1, succeed when no silo matches
#
# This is a code-roll for existing silos. It does not create databases, domains,
# or Feishu apps — use new_silo.sh / apply_new_silo.sh for that.
set -euo pipefail
FLEET="${CPH_FLEET:?CPH_FLEET required (dev|prod)}"
case "$FLEET" in
dev) FLEET_DOMAIN_SUFFIX="educraft-dev.paradigm-edu.net" ;;
prod) FLEET_DOMAIN_SUFFIX="educraft.paradigm-edu.net" ;;
*)
echo "CPH_FLEET must be dev or prod, got: $FLEET" >&2
exit 1
;;
esac
HOST="${PLATFORM_DEPLOY_HOST:-39.107.254.4}"
DEPLOY_USER="${PLATFORM_DEPLOY_USER:-root}"
PORT="${PLATFORM_DEPLOY_PORT:-22}"
SSH_KEY="${PLATFORM_DEPLOY_SSH_KEY:?PLATFORM_DEPLOY_SSH_KEY required}"
BASE="${PLATFORM_DEPLOY_BASE:-/srv/curriculum-project-hub}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}"
[[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || {
echo "invalid PLATFORM_DEPLOY_RELEASE: $RELEASE_ID" >&2
exit 1
}
ALLOW_EMPTY_FLEET="${ALLOW_EMPTY_FLEET:-0}"
HUB_DIR="$BASE/releases/$RELEASE_ID/hub"
RELEASE_DIR="$BASE/releases/$RELEASE_ID"
SSH_OPTS=(-i "$SSH_KEY" -p "$PORT" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
echo "[fleet] fleet=$FLEET domain=*.$FLEET_DOMAIN_SUFFIX host=$DEPLOY_USER@$HOST release=$RELEASE_ID"
# --- 1. Publish immutable release (shared; flock so parallel fleet jobs do not race) ---
release_ready=false
if ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "test -f '$RELEASE_DIR/.complete'"; then
release_ready=true
echo "[fleet] release already complete: $RELEASE_DIR"
fi
if [ "$release_ready" = false ]; then
# Drop a prior incomplete tree for this release id (failed CI leave leftovers).
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
set -euo pipefail
if [ -f "$RELEASE_DIR/.complete" ]; then
exit 0
fi
if [ -e "$RELEASE_DIR" ]; then
echo "[fleet] removing incomplete release: $RELEASE_DIR"
rm -rf "$RELEASE_DIR"
fi
mkdir -p "$HUB_DIR"
'
REMOTE
echo "[fleet] rsync hub → $HUB_DIR"
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
--exclude filelib-web/node_modules --exclude filelib-web/build --exclude filelib-web/.svelte-kit \
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web & filelib-web SPAs)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
set -euo pipefail
if [ -f "$RELEASE_DIR/.complete" ]; then
echo "[fleet] release completed by another job"
exit 0
fi
cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web
npm ci --prefix filelib-web
npm run audit:production
npm run build
test -f admin-web/build/index.html
test -f filelib-web/build/index.html
touch "$RELEASE_DIR/.complete"
'
REMOTE
fi
# --- 2. Discover silos in this fleet and roll them onto the release ---
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
env BASE="$BASE" HUB_DIR="$HUB_DIR" FLEET="$FLEET" \
FLEET_DOMAIN_SUFFIX="$FLEET_DOMAIN_SUFFIX" ALLOW_EMPTY_FLEET="$ALLOW_EMPTY_FLEET" \
bash -s <<'REMOTE'
set -euo pipefail
fleet_match() {
local public_url="$1"
local host="${public_url#https://}"
host="${host#http://}"
host="${host%%/*}"
host="${host%%:*}"
case "$FLEET" in
prod)
[[ "$host" == *."$FLEET_DOMAIN_SUFFIX" || "$host" == "$FLEET_DOMAIN_SUFFIX" ]] || return 1
# Avoid matching educraft-dev when suffix is educraft.…
[[ "$host" != *.educraft-dev.paradigm-edu.net && "$host" != educraft-dev.paradigm-edu.net ]] || return 1
return 0
;;
dev)
[[ "$host" == *."$FLEET_DOMAIN_SUFFIX" || "$host" == "$FLEET_DOMAIN_SUFFIX" ]]
return
;;
*)
return 1
;;
esac
}
env_value() {
local file="$1"
local key="$2"
# Do not source secrets — only read the KEY=value line.
local line
line="$(grep -E "^${key}=" "$file" | tail -n1 || true)"
printf '%s' "${line#"${key}="}"
}
matched=0
shopt -s nullglob
for env_file in "$BASE"/.secrets/*/platform.env; do
instance_id="$(basename "$(dirname "$env_file")")"
public_url="$(env_value "$env_file" HUB_PUBLIC_BASE_URL)"
if [ -z "$public_url" ]; then
echo "[fleet] skip $instance_id: HUB_PUBLIC_BASE_URL empty"
continue
fi
if ! fleet_match "$public_url"; then
echo "[fleet] skip $instance_id: $public_url (not $FLEET)"
continue
fi
port="$(env_value "$env_file" PORT)"
workspace="$(env_value "$env_file" HUB_PROJECT_WORKSPACE_ROOT)"
unit="cph-hub-${instance_id}.service"
if [ ! -f "/etc/systemd/system/$unit" ]; then
echo "[fleet] skip $instance_id: unit missing ($unit)" >&2
continue
fi
# Prefer unit file ceilings (MemoryMax=16G) over systemctl's byte form.
memory_max="$(sed -n 's/^MemoryMax=//p' "/etc/systemd/system/$unit" | tail -n1)"
cpu_quota="$(sed -n 's/^CPUQuota=//p' "/etc/systemd/system/$unit" | tail -n1)"
tasks_max="$(sed -n 's/^TasksMax=//p' "/etc/systemd/system/$unit" | tail -n1)"
if [ -z "$port" ] || [ -z "$workspace" ] || [ -z "$memory_max" ] || [ -z "$cpu_quota" ] || [ -z "$tasks_max" ]; then
echo "[fleet] error: $instance_id missing PORT/workspace/ceilings (port=$port workspace=$workspace memory=$memory_max cpu=$cpu_quota tasks=$tasks_max)" >&2
exit 1
fi
echo "[fleet] roll $instance_id → $HUB_DIR ($public_url port=$port)"
BASE="$BASE" \
HUB_DIR="$HUB_DIR" \
INSTANCE_ID="$instance_id" \
WORKSPACE_ROOT="$workspace" \
PORT="$port" \
MEMORY_MAX="$memory_max" \
CPU_QUOTA="$cpu_quota" \
TASKS_MAX="$tasks_max" \
bash "$HUB_DIR/deploy/install_service.sh"
systemctl restart "$unit"
systemctl is-active --quiet "$unit"
health="http://127.0.0.1:${port}/api/healthz"
ok=false
for _ in $(seq 1 45); do
if curl --fail --silent "$health" >/dev/null 2>&1; then
ok=true
break
fi
sleep 1
done
if [ "$ok" != true ]; then
curl --fail --silent --show-error "$health" >/dev/null
fi
echo "[fleet] healthy $instance_id ($health)"
matched=$((matched + 1))
done
if [ "$matched" -eq 0 ]; then
msg="[fleet] no silos matched *.$FLEET_DOMAIN_SUFFIX under $BASE/.secrets"
if [ "$ALLOW_EMPTY_FLEET" = "1" ]; then
echo "$msg (ALLOW_EMPTY_FLEET=1)"
exit 0
fi
echo "$msg" >&2
exit 1
fi
echo "[fleet] rolled $matched silo(s) on $FLEET"
REMOTE
echo "[fleet] done fleet=$FLEET release=$RELEASE_ID hub=$HUB_DIR"
+4 -2
View File
@@ -60,9 +60,11 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps, audit and build, then atomically mark the release complete.
# 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete.
# `npm run build` → tsc then admin:build + filelib:build → admin-web/build and
# filelib-web/build for registerStaticSpa / registerDatabaseSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && npm ci && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm ci --prefix filelib-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart.
+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,594 @@
<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";
/** 最近打开上报的导航目标(ADR-0031):父组件传入后,本组件跳到对应节点并清除。 */
export interface NavTarget {
readonly nodeId: string;
readonly filePath?: string | undefined;
}
let { navTarget = null, onnavigated }: { navTarget?: NavTarget | null; onnavigated?: () => void } = $props();
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);
/** 最近打开上报(ADR-0031):fire-and-forget,失败静默,不阻塞浏览。 */
function record(nodeId: string, filePath?: string): void {
void api("/database/api/recent", {
method: "POST",
body: { nodeId, ...(filePath !== undefined ? { filePath } : {}) },
}).catch(() => undefined);
}
function refresh(): void {
selected = null;
menu = null;
if (view === "files") void loadFiles();
else void loadChildren();
}
/* ------------------------------------------------------------ 导航 */
function openNode(n: StackItem): void {
selected = null;
record(n.id);
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();
}
/** 跳到任意节点(最近打开入口):breadcrumb 建栈,FOLDER 进子层,PROJECT 进文件视图。 */
async function navigateTo(target: NavTarget): Promise<void> {
try {
const r = await api<{ breadcrumb: Array<{ id: string | null; name: string | null; kind: "FOLDER" | "PROJECT"; role: Role | null }> }>(
`/database/api/nodes/${target.nodeId}/breadcrumb`,
);
const visible = r.breadcrumb.filter(
(e): e is { id: string; name: string; kind: "FOLDER" | "PROJECT"; role: Role | null } =>
e.id !== null && e.name !== null,
);
if (visible.length === 0) return;
const self = visible[visible.length - 1]!;
selected = null;
if (self.kind === "FOLDER") {
view = "nodes";
projectNode = null;
clearSelectedFile();
stack = visible.map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" }));
await loadChildren();
} else {
stack = visible.slice(0, -1).map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" }));
projectNode = await fetchDetail(self.id);
view = "files";
await loadFiles();
if (target.filePath !== undefined) selectedFilePath.set(target.filePath);
}
} catch (e) {
toastErr(errText(e));
}
}
$effect(() => {
if (navTarget === null) return;
const t = navTarget;
void navigateTo(t).finally(() => onnavigated?.());
});
/* ------------------------------------------------------------ 节点操作 */
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 {
if (projectNode !== null) record(projectNode.id, f.path);
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}

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