Compare commits

..

1 Commits

Author SHA1 Message Date
hongjr03 4e7b158ff9 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35
Silo hostname already carries tenancy. Admin SPA routes become /admin/...,
legacy /admin/org/:slug/* bookmarks redirect, login lands on /admin.
2026-07-18 17:36:08 +00:00
186 changed files with 2231 additions and 16060 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
name: checker check
# Builds and lints the Rust implementation crates under crates/ (the rule-based
# lesson checker).
# checker that "stands in Lean's position" at product runtime).
#
# 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.
# 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.
on:
push:
+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.
# This is an INTERNAL gate on the Hub's own
# The Hub is the Feishu-group collaboration + agent runtime half
# (spec/System implementation). This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half.
on:
+20
View File
@@ -0,0 +1,20 @@
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
+4 -2
View File
@@ -1,3 +1,7 @@
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target
**/*.pdf
@@ -15,5 +19,3 @@ 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. Numerical
with the durable decision in ADR-0022 and `Spec.System.Capacity`. 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 health, deploy a clean
repeatable release procedure prove build/test/spec 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
the pinned AuditEntry-to-run relation. Decide durability,
`Spec.System.Audit`'s 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,7 +40,9 @@ 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),
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md).
The pinned semantic invariants are in
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
Exact numeric session/invitation/step-up limits and browser mechanics remain
+16 -12
View File
@@ -1,25 +1,29 @@
# AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
`Spec.System.Organization`)。
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
不接收 org provider credential(见 ADR-0024)。
不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
`Spec.System.Capacity`)。
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023)。
双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
@@ -40,12 +44,12 @@
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+10 -8
View File
@@ -1,23 +1,25 @@
# CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede
3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。
4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
-4
View File
@@ -99,7 +99,3 @@ _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
+23 -16
View File
@@ -2,7 +2,7 @@
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。**
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
## 安装 `cph` 命令行
@@ -33,40 +33,47 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
```
README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源)
CONTEXT.md平台语言词汇表(术语与禁用说法)
docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
spec/ Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md
crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005)
render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 crates/
(exporter/ …) ← 将来的其他部件,平级于 spec/
```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。
## 宪法
4 条是本仓库的协作约定,是一切工作的前提。
5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
1. **角色 —— ADR 是决策真相**
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档
1. **角色 —— Lean 是研发侧的上游参照**
`spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来
2. **对齐机制 —— 人肉承载,无机器兜底**
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。
2. **对齐机制 —— Lean 只做上游参照**
不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
3. **形态 —— 自包含**
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定
3. **资产性 —— 由 review 纪律承载,无机器兜底**
这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档
4. **深度判据 —— 只收录分歧点**
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
4. **形态 —— 它是人机共识的契约**
契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
## 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。
+2 -3
View File
@@ -1,8 +1,7 @@
# crates/
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)
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)
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),
+11 -9
View File
@@ -19,11 +19,13 @@ const DEFAULT_TARGET: &str = "student";
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
///
/// **PINNED to `warning` by ADR-0005:** when a
/// **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
/// `(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
/// fact rather than an inline literal.
/// severity as a const makes "it is a warning, not an error" a greppable,
/// alignable fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]).
@@ -55,12 +57,12 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity.
///
/// **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.
/// **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.
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
+15 -10
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`]
//! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an
//! (mirroring the Lean master), 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,27 +17,32 @@ use serde::Serialize;
/// Severity of a diagnostic.
///
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
///
/// ```text
/// warning | error
/// inductive Severity where
/// | warning
/// | error
/// ```
///
/// This two-valued shape is a **contract decision**, not an accident: the
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
/// 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
/// 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 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.
/// 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. ADR-0010 `warning`.
/// element. Mirrors Lean `Severity.warning`.
Warning,
/// Blocking: the artifact is invalid. ADR-0010 `error`.
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
Error,
}
+38 -25
View File
@@ -3,7 +3,9 @@
//! 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`], where the order of `parts` carries teaching semantics.
//! 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.
//!
//! Scope boundaries (deliberately staying in lane):
//! - It validates **structure** only: manifest shape, element.toml shape, and
@@ -21,7 +23,7 @@ use serde::{Deserialize, Serialize};
/// An ordered, in-memory lesson loaded from an engineering file.
///
/// `parts` is an ordered
/// Mirrors the Lean master's `Lesson = List (Element P)`: `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)]
@@ -84,8 +86,9 @@ 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 ADR-0011's "render
/// coverage is a declaration, not a payload": the declaration keeps *which
/// 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
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
/// while the rendering "how" lives in the template/steps.
///
@@ -99,10 +102,13 @@ pub struct TargetConfig {
/// The artifact an export target produces (ADR-0009/0011).
///
/// **Pinned by ADR-0011** as an ADT with fields:
/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
///
/// ```text
/// Artifact = singleFile (filepath) | fileTree (root, outputs)
/// inductive Artifact where
/// | singleFile (filepath : String)
/// | fileTree (root : String) (outputs : String)
/// ```
///
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
@@ -114,20 +120,20 @@ pub struct TargetConfig {
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
/// [`Artifact::FileTree`].
///
/// 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.
/// 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.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Artifact {
/// One bundled document landing at `filepath` (relative to the engineering
/// root). ADR-0011 `singleFile`. The default artifact shape.
/// root). Mirrors Lean `Artifact.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. ADR-0011
/// `fileTree`.
/// A set of files under `root` matching the `outputs` glob. Mirrors Lean
/// `Artifact.fileTree`.
FileTree {
/// The output directory (relative to the engineering root).
root: PathBuf,
@@ -150,10 +156,14 @@ impl Artifact {
/// One typed build step (ADR-0011).
///
/// **Pinned by ADR-0011** as an ADT:
/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
///
/// ```text
/// Step = typstCompile (template) | shell (run) | assembleMarkdown (field)
/// inductive Step where
/// | typstCompile (template : String)
/// | shell (run : String)
/// | assembleMarkdown (field : String)
/// ```
///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a
@@ -173,20 +183,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. ADR-0011
/// `typstCompile`.
/// artifact; the framework injects the manifest. Mirrors Lean
/// `Step.typstCompile`.
TypstCompile {
/// The template file to compile as main, e.g. `exports/student.typ`.
template: PathBuf,
},
/// Run a shell command — the escape hatch. ADR-0011 `shell`.
/// Run a shell command — the escape hatch. Mirrors Lean `Step.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. ADR-0011
/// `assembleMarkdown` (ADR-0015). Not a typst build — the
/// element's `field` markdown content file in `[[parts]]` order. Mirrors
/// Lean `Step.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`,
@@ -216,11 +226,12 @@ pub struct Project {
/// `[info]` table (passed through to render targets verbatim).
///
/// The *canonical* model whose
/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
/// (`spec/Spec/Courseware/Model/Info.lean`): 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. No
/// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable.
/// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
/// CI gate enforces this alignment (repo constitution); it is kept greppable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Info {
/// Lesson title.
@@ -229,6 +240,7 @@ 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>,
}
@@ -278,7 +290,8 @@ struct RawProject {
name: String,
}
/// The authoring-surface `[info]`: the raw form that exists for
/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
/// `spec/Spec/Courseware/Model/Info.lean`): 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)]
@@ -288,7 +301,7 @@ struct RawInfo {
}
/// On-disk `author`: either a single name (`author = "…"`) or a list
/// (`author = ["…", "…"]`). A fill-in convenience whose
/// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: 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)]
@@ -299,7 +312,7 @@ enum RawAuthor {
}
impl RawAuthor {
/// Flatten to the ordered author list: a single
/// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
/// name becomes a one-element list; a list passes through verbatim.
fn into_vec(self) -> Vec<String> {
match self {
@@ -1,153 +0,0 @@
# 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.
@@ -1,132 +0,0 @@
# 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.
@@ -1,157 +0,0 @@
# ADR 0030: The File Library VersionStore Is a Real Git Repository per Project
## Status
Accepted.
## Context
The file library (`hub/src/database/filelib/`, an independent subsystem that does
not reuse the Hub's own `Folder`/`Project` tree from ADR-0021) stores each project
as a versioned file tree behind the `VersionStore` port (contract C1). Until now
the only implementation was `createInMemoryVersionStore`: a `Map` of per-file
version chains, with `VersionId` as a per-repository monotonic counter
(`v1`, `v2`, …), a hand-written line differ, and an optional JSON snapshot of the
entire storage root written to `<storageRoot>/.version-store.json` so that a
process restart did not lose the demo data.
Two things about the surrounding design were already settled in code and are
confirmed here rather than changed:
- **A `FOLDER` node has no on-disk existence.** `FileLibNode.storageDir` is
`NULL` for folders. The tree is `parentId` plus the `pathIds` materialized path;
nothing in the filesystem mirrors it.
- **Projects are flat under one root, keyed by id.** `storageDir` is
`<storageRoot>/<nodeId>` where `nodeId` is a `randomUUID()`. Names never enter
the path, which is why `renameNode` touches no disk state and does not rewrite
descendant paths.
What was never true is the part the names implied. `HUB_FILELIB_STORAGE_ROOT` was
documented as "the project git repository root" and `fileService` was documented
as observing a "git first, then audit" ordering, but no code in the repository
ever invoked git. `versionStore.init(storageDir)` inserted a `Map` entry; the
directory was never created. Every project's entire content and history lived in
one process-global JSON file. The header comment and `README.md` both marked this
as a placeholder awaiting an npm package from the versioning team.
That package has not arrived, and the in-memory store's properties are not
acceptable for real teacher data: a corrupt or lost `.version-store.json` loses
every project at once, the whole storage root is rewritten on every commit, and
`VersionId` values are meaningless outside the process that minted them.
## Decision
**Each file library project is a real Git repository at
`<storageRoot>/<nodeId>`.** `VersionStore.init` creates the directory and runs
`git init` there. This is the production implementation;
`createInMemoryVersionStore` is retained for tests only.
**`VersionId` is a Git commit hash.** The full 40-hex object name, as printed by
`git rev-parse`. It is no longer a per-repository counter.
**File-level versioning (D16) maps onto commit history as follows.** A write
touches exactly one path and produces exactly one commit. The version of a file is
the hash of the most recent commit that modified that path — `git log -1 --
<path>`. Consequently:
- Two files in one project have independent versions, because a commit that
touches `a.md` does not appear in `git log -- b.md`. This preserves the D16
property that advancing one file does not invalidate another file's
`baseVersion`, even though commits are repository-global objects.
- `baseVersion` checking (S1/S2) compares the caller's id against the current
per-file version. `baseVersion: null` means create, and conflicts if the path
already exists at `HEAD`.
- Reading version `V` of a path means `git show V:<path>`, which is the content as
of that commit, not the content the commit introduced to some other file.
**Deletion is a commit, not a tombstone record.** `remove` runs `git rm` and
commits, so the path is absent from `HEAD` and `list` stops reporting it, while
`git show <olderVersion>:<path>` still resolves. The in-memory store expressed
this as a `deleted: true` chain entry; the observable API semantics are the same.
**Git is invoked as a subprocess, not through a library.** `node:child_process`
`execFile` with an argument array, no new npm dependency. Every invocation is
hardened, and the hardening is load-bearing rather than incidental:
- `-c core.hooksPath=` and `-c commit.gpgsign=false`, plus
`GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null`. A project
repository is *data*, uploaded by teachers. Without this, a committed
`.git/hooks/` entry or a developer's global `gitconfig` would execute or alter
server-side behavior.
- `GIT_LITERAL_PATHSPECS=1` and `--` before every path, so a filename is never
reinterpreted as an option or as pathspec magic (`:(glob)`).
- `GIT_TERMINAL_PROMPT=0`, so a repository never blocks a request waiting on
credentials.
- Author identity is passed per-commit via `GIT_AUTHOR_*`/`GIT_COMMITTER_*`
environment variables, never written into the repository's config. The git
author name is the acting user's `displayName` (falling back to `userId` when
absent), and the email is `<userId>@filelib.paradigm-edu.net`. The email
deliberately keys on `userId` rather than the display name, because nicknames
change and identity attribution must not drift with them. Characters that would
break git's ident line (`<`, `>`, newlines) are stripped from the name.
- `--git-dir=<projectDir>/.git` and `--work-tree=<projectDir>` are pinned on
every invocation, and `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE`/
`GIT_OBJECT_DIRECTORY` are removed from the child environment. Git otherwise
searches *upward* for a `.git`, and the storage root is frequently nested inside
another repository — the local development default `hub/.filelib-repos` sits
inside this very repo. Without pinning, operations on a project directory that
has no repository of its own silently retarget the enclosing repository.
Existence is therefore tested on the filesystem (`<projectDir>/.git`), not with
`git rev-parse --git-dir`, which merely echoes a pinned value back.
**Writes to one repository remain serialized in-process**, as under S4, because
concurrent git invocations contend on `index.lock`. This is a single-process
guarantee only; see Consequences.
## Consequences
- `.version-store.json` is not read or migrated by the new store. Existing
development data under `HUB_FILELIB_STORAGE_ROOT` does not appear in the git
store; those projects report `repo_not_found` until recreated. No production
data exists to migrate, since the in-memory store was never production-viable.
- `VersionId` changes shape in API responses (`GET .../files/*`, history, and the
409 `currentVersion` detail). Clients must keep treating it as an opaque
string; `filelib-web` already does.
- `VersionInfo.author` now comes back as the git author name, which is the acting
user's display name at commit time (or the `userId` when no display name is
known). Commits written without an author carry a fixed `filelib` identity
rather than `undefined`. Display names are point-in-time: renaming a user does
not rewrite existing commits, and the stable identifier stays in the email.
- `VersionStore.commit`/`remove` take a structured `CommitAuthor`
(`{ userId, displayName? }`) rather than a bare author string, so the port can
express both the stable key and the display label. Deletion carries the same
identity as any other commit.
- Serialization is per-process. Two Hub processes sharing a storage root can race
on the same repository and surface a git lock error rather than a clean
conflict. The alpha Silo deployment (ADR-0025) is one process per organization,
so this is not currently reachable; a multi-process deployment needs either a
database advisory lock keyed by project id or a single writer.
- `git` must be present on the host. Absence is a startup-visible failure of
project creation (`provision_failed`), not a silent degradation.
- Repository content is now attacker-influenced data on disk. The path validation
in `fileService.validateFilePath` (rejecting `..`, `.git`, absolute paths,
control characters) moves from hygiene to a security boundary, and
`versionStore` re-checks it rather than trusting callers.
## Alternatives considered
- **`isomorphic-git` or `simple-git`.** Both add a dependency to carry work that
three `execFile` calls do. `isomorphic-git` additionally reimplements the object
layer, so its bugs would be ours to diagnose.
- **One commit per repository state, with the repository head as the version.**
Simpler mapping, but it breaks D16: any write would invalidate every other
file's `baseVersion`, turning independent edits into false conflicts.
- **Keeping the counter as `VersionId` alongside git.** Requires a durable
counter-to-hash mapping outside git, which is the state the decision removes.
- **Bare repositories with a git index-only write path.** Avoids a working tree,
but every read and write becomes plumbing (`hash-object`, `update-index`,
`commit-tree`), for no benefit at this scale.
## Deferred
- Cross-process write serialization (advisory lock keyed by project id).
- Garbage collection and pack maintenance policy for long-lived repositories.
- Whether export builds (`exportService`) should read a git tree directly instead
of going through the `listFiles`/`readFile` port.
- Recovering `provisionStatus=FAILED` projects by re-running `init`; the status
machine records the failure but nothing retries it yet.
@@ -1,43 +0,0 @@
# 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.
@@ -1,58 +0,0 @@
# 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.
@@ -1,30 +0,0 @@
# ADR 0032: Remove The Recent-Visit Module
## Status
Accepted. **Supersedes the "Recent visits" half of ADR-0031** (the recycle-bin half
is unaffected and remains in force).
## Context
ADR-0031 (same day) introduced 最近打开: a `FileLibRecentVisit` table, client-driven
visit recording, and a rail entry in the teacher app. After seeing it live, the product
call is that the module is not wanted — it adds a tracking surface, a table, and rail
noise without a compelling teacher workflow behind it.
## Decision
The recent-visit module is removed end-to-end:
- `FileLibRecentVisit` is dropped (hand-written migration
`20260731090000_drop_filelib_recent_visit`; the table was created the same day and
held no production data).
- `recentService` / `recentRoutes` (`/database/api/recent`) and the `RecentView`
component are deleted; the rail in `/app` keeps only 文件库 / 回收站.
- `GridLibraryView` visit recording and the `navTarget` navigation entry go with it.
- The `role` field added to breadcrumb entries for ADR-0031 is **kept** — it is a
cheap, additive field on an existing API and independent of the removed module.
If recent-visit tracking comes back as a requirement, it is a new decision (and
should then define why client-driven tracking is worth its surface) rather than a
revival of this one.
@@ -1,26 +0,0 @@
# ADR 0033: Restore De-Duplicates The Node Name On Sibling Conflict
## Status
Accepted.
## Context
ADR-0031 defined restore as "clear `deletedAt` on that node only". It did not cover
the case where a same-name sibling was created **after** the deletion: D14's partial
unique index (active siblings, case-insensitive) then rejects the restore with a 409
`conflict`, leaving the entry permanently stuck in the bin — unrecoverable for
non-admin users (who cannot purge) and cryptic for admins.
## Decision
Restore never fails on a name conflict. Before clearing `deletedAt`, the service
checks active siblings; if the node's name is taken, it restores as
`原名(已恢复)`, then `原名(已恢复 2)`, …, first free key wins (suffix is included
in the `NODE_NAME_MAX_LENGTH` budget by truncating the base). The rename is part of
the same transaction and is recorded in the restore audit entry as
`{ name, renamedFrom }`. The API returns the final name so the UI can tell the user.
Rationale: the bin's purpose is recovery; a restore that can deadlock on naming is a
trap, not a safeguard. Users who care about the name can rename afterwards (they have
MANAGE by definition of bin visibility).
-28
View File
@@ -1,28 +0,0 @@
# ADR 0034: Permanent Delete Follows MANAGE, Not Website Administrator
## Status
Accepted. **Supersedes one clause of ADR-0031**: "Permanent delete (彻底删除) is
website-administrator only".
## Context
ADR-0031 gated 彻底删除 to the website administrator as a high-risk-operation
precaution. The product call is that this is inconsistent with the rest of the
permission model: soft delete already requires only MANAGE on the node, and a
MANAGE holder who can delete a node into the bin should also be able to purge it —
the authority that grants deletion grants destruction. Admin-only purge strands
non-admin managers with bins they cannot empty.
## Decision
Permanent delete uses **the same visibility rule as the bin entry itself**: website
administrator, or an actor with an active MANAGE grant on the deleted node (direct
grant, USER or resolved GROUP). Anyone else gets 404 (D8). The double confirmation
in the UI and the `node.purge` audit entry are unchanged.
## Consequences
- Purge auth = restore auth = bin-entry visibility: one rule, three surfaces.
- The operation remains irreversible and audited; no new capability is granted to
anyone who could not already delete the node (soft) and see it in the bin.
@@ -1,20 +0,0 @@
# ADR 0035: Restore Keeps The Original Name; Conflict Is A Clear Error
## Status
Accepted. **Supersedes ADR-0033** (restore de-duplicates the node name on sibling
conflict).
## Context
ADR-0033 made restore auto-rename to `原名(已恢复)` on sibling name conflict so
restore never fails. In practice the suffix is unwanted noise - operators expect the
original name back and prefer to resolve conflicts themselves.
## Decision
Restore clears `deletedAt` and keeps the node's **original name**. If an active
sibling now occupies the same name (D14 partial unique index), the service throws a
`409 name_conflict_on_restore` with a human-readable message ("同名节点已存在,请先
重命名现有节点再恢复") - no silent renaming, no suffix. The restore audit records
the original name only.
+1 -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` |
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` |
| 获取与上传图片或文件 | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
@@ -66,9 +66,6 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
## 4. 配置事件与卡片回调
进入“事件与回调”。
+1 -17
View File
@@ -23,18 +23,12 @@ DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_CONCURRENT_RUNS="1"
HUB_AGENT_MAX_RUN_SECONDS="900"
HUB_HTTP_BODY_LIMIT_BYTES="73400320"
HUB_HTTP_BODY_LIMIT_BYTES="1048576"
HUB_MAX_FILES_PER_MESSAGE="8"
HUB_MAX_FILE_BYTES="26214400"
HUB_HTTP_REQUESTS_PER_MINUTE="120"
HUB_FEISHU_EVENTS_PER_MINUTE="120"
# 文件库单文件上限(缺省 10 MiB)。这两个值是串联的:上传把文件内容放在
# JSON body 里,二进制过 base64 体积涨 4/3。所以有效上限是
# min(本值, HUB_HTTP_BODY_LIMIT_BYTES × 3/4);body limit 太小时本值不可达,
# 且报错是 Fastify 的 413 Payload Too Large 而不是 file_too_large。
HUB_FILELIB_MAX_FILE_BYTES="52428800"
# Persistent system-managed root for project workspaces. Production must use an
# absolute path outside the deployment/release tree; install_service.sh defaults
# to this path and rejects any overlap before installing the unit.
@@ -51,18 +45,8 @@ HUB_SYSTEMD_UNIT="cph-hub-example.service"
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires
# the file to be executable and `cph --version` to succeed.
#
# Always set this explicitly. `cph` is also the command name of the unrelated
# PyPI package conda-package-handling, so on any host with miniconda on PATH a
# bare `cph` resolves to the wrong tool and exports fail with an argparse
# "invalid choice: 'build'" that gives no hint about the name collision.
CPH_BIN="/usr/local/bin/cph"
# The `cph-render` typst package directory (the folder holding lib.typ /
# typst.toml). Needed by PDF export: when unset, cph falls back to resolving the
# repo-relative `render/`, which does not exist in a deployed layout.
CPH_RENDER_DIR="/opt/curriculum-project-hub/render"
# Hub bind address and port. Production defaults to loopback for a local TLS
# reverse proxy; both values are validated and honored by the HTTP server.
HOST="127.0.0.1"
-7
View File
@@ -5,13 +5,6 @@ dist/
.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/
+1 -4
View File
@@ -82,11 +82,10 @@ REMOTE
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
--exclude filelib-web/node_modules --exclude filelib-web/build --exclude filelib-web/.svelte-kit \
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web & filelib-web SPAs)"
echo "[fleet] npm ci + build (tsc + admin-web SPA)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
@@ -98,11 +97,9 @@ flock /var/lock/cph-hub-release-publish bash -c '
cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web
npm ci --prefix filelib-web
npm run audit:production
npm run build
test -f admin-web/build/index.html
test -f filelib-web/build/index.html
touch "$RELEASE_DIR/.complete"
'
REMOTE
+3 -4
View File
@@ -60,11 +60,10 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 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.
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"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'"
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart.
-2013
View File
File diff suppressed because it is too large Load Diff
-35
View File
@@ -1,35 +0,0 @@
{
"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"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/language": "^6.12.4",
"codemirror": "^6.0.2",
"codemirror-lang-typst": "^0.4.0",
"vite-plugin-wasm": "^3.6.0"
}
}
-263
View File
@@ -1,263 +0,0 @@
@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
@@ -1,12 +0,0 @@
// 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
@@ -1,16 +0,0 @@
<!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
@@ -1,31 +0,0 @@
<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}
-105
View File
@@ -1,105 +0,0 @@
<script lang="ts">
/**
* 回收站(ADR-0031):祖先全活跃的已删节点顶;恢复只清本节点 deletedAt;
* 彻底删除(整支硬删)仅网站管理员,二次确认。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { 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 {
const r = await api<{ name: string }>(
`/database/api/bin/${encodeURIComponent(e.id)}/restore`,
{ method: "POST" },
);
toastOk(`已恢复「${r.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>
<!-- 彻底删除与条目可见性同权(ADR-0034):能在回收站看到,就能清空 -->
<button
class="btn btn-sm btn-danger disabled:opacity-50"
onclick={() => void purge(e)}
disabled={busyId === e.id}
>
<Icon name="trash" size={12} /> 彻底删除
</button>
</div>
{/each}
</div>
{/if}
</div>
-121
View File
@@ -1,121 +0,0 @@
<script lang="ts">
/**
* CodeMirror 6 编辑器封装。自动根据文件扩展名选择语法高亮。
* 只在浏览器 mount 后创建 EditorView(CodeMirror 依赖 DOM)。
*/
import { onMount, onDestroy } from "svelte";
import { EditorView, basicSetup } from "codemirror";
import { EditorState } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { typst } from "codemirror-lang-typst";
import type { Extension } from "@codemirror/state";
let { value = "", readonly = false, filename = "", onchange }: {
value?: string;
readonly?: boolean;
filename?: string;
onchange?: (value: string) => void;
} = $props();
let container = $state<HTMLDivElement | null>(null);
let view: EditorView | null = null;
/** 根据文件名后缀选语言扩展 */
function langExtension(name: string): Extension[] {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
switch (ext) {
case "md":
case "markdown":
return [markdown()];
case "js":
case "mjs":
case "cjs":
return [javascript()];
case "ts":
case "mts":
case "cts":
return [javascript({ typescript: true })];
case "jsx":
return [javascript({ jsx: true })];
case "tsx":
return [javascript({ jsx: true, typescript: true })];
case "json":
case "jsonc":
return [json()];
case "html":
case "htm":
case "svelte":
case "vue":
return [html()];
case "css":
case "scss":
return [css()];
case "typ":
case "typst":
return [typst()];
default:
return [];
}
}
onMount(() => {
if (!container) return;
const extensions: Extension[] = [
basicSetup,
...langExtension(filename),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onchange?.(update.state.doc.toString());
}
}),
];
if (readonly) extensions.push(EditorState.readOnly.of(true));
view = new EditorView({
state: EditorState.create({ doc: value, extensions }),
parent: container,
});
});
onDestroy(() => {
view?.destroy();
view = null;
});
// 外部 value 变化时(如冲突载入最新),替换编辑器内容。
$effect(() => {
if (view && view.state.doc.toString() !== value) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
}
});
</script>
<div bind:this={container} class="code-editor-wrapper"></div>
<style>
.code-editor-wrapper {
border: 1px solid var(--color-line);
border-radius: 8px;
overflow: hidden;
font-size: 13px;
line-height: 1.65;
}
.code-editor-wrapper :global(.cm-editor) {
height: 100%;
max-height: 60vh;
overflow: auto;
}
.code-editor-wrapper :global(.cm-editor.cm-focused) {
outline: none;
}
.code-editor-wrapper :global(.cm-scroller) {
overflow: auto;
}
</style>
@@ -1,55 +0,0 @@
<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>
-206
View File
@@ -1,206 +0,0 @@
<script lang="ts">
/**
* 文件编辑器(模态框形式)。打开后加载文件内容并使用 CodeMirror 编辑,
* 支持语法高亮、版本冲突处理、历史查看与删除。
*/
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";
import CodeEditor from "./CodeEditor.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");
const filename = $derived(path.split("/").pop() ?? "");
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;
// 内容未变化时不提交,避免产生空 commit。
if (draft === file.content) {
toastOk("内容无变化,未提交");
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();
onclose();
} 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>
<!-- 主编辑器模态框:宽屏 overlay -->
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/30 p-4"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<div class="flex h-[85vh] w-full max-w-[900px] flex-col rounded-2xl border border-line-soft bg-panel shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<!-- 顶栏 -->
<div class="flex shrink-0 items-center justify-between border-b border-line-soft px-6 py-4">
<div class="flex min-w-0 items-center gap-3">
<span class="text-[15px] font-semibold text-ink truncate">{filename}</span>
</div>
<div class="flex items-center gap-1.5">
{#if file}
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>
<Icon name="download" size={13} /> 下载
</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}
<button class="btn !px-2.5" onclick={onclose} title="关闭" aria-label="关闭编辑器"></button>
</div>
</div>
<!-- 编辑器主体 -->
<div class="flex-1 overflow-y-auto px-6 py-4">
{#if loadError}
<div class="text-xs text-danger">{loadError}</div>
{:else if file === null}
<div class="quiet">加载中…</div>
{:else if file.encoding === "base64"}
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
{:else}
<CodeEditor value={draft} filename={path} readonly={!canEdit} onchange={(v) => (draft = v)} />
{/if}
{#if conflict}
<div class="mt-4 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>
<!-- 底栏 -->
{#if canEdit && file && file.encoding !== "base64"}
<div class="flex shrink-0 justify-end border-t border-line-soft px-6 py-3">
<button class="btn btn-primary" onclick={save}>提交修改</button>
</div>
{/if}
</div>
</div>
{#if showHistory}
<Modal title="版本历史" onclose={() => (showHistory = false)}>
<div class="max-h-80 overflow-y-auto">
{#each history as v (v.version)}
<div class="flex items-center gap-2.5 border-t border-line-soft py-2.5 first:border-t-0">
<span
class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white"
aria-hidden="true"
>{(v.author ?? "?").slice(0, 1).toUpperCase()}</span>
<div class="min-w-0 flex-1">
<p class="text-[12.5px] text-ink">{v.message}</p>
<p class="text-[11px] text-ink-3">
{#if v.author}<span>{v.author}</span> · {/if}{new Date(v.committedAt).toLocaleString("zh-CN")}
</p>
</div>
</div>
{/each}
</div>
<div class="mt-3 flex justify-end">
<button class="btn" onclick={() => (showHistory = false)}>关闭</button>
</div>
</Modal>
{/if}
-375
View File
@@ -1,375 +0,0 @@
<script lang="ts">
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { selectedFilePath, filesVersion } from "./browser.js";
import { loadConfig } from "./config.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("");
let newMessage = $state("");
// 上传弹窗:选完文件先暂存,等用户确认路径与 commit 信息再传。
let pendingFile = $state<File | null>(null);
let uploadPath = $state("");
let uploadMessage = $state("");
let uploading = $state(false);
// bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。
let uploadInput = $state<HTMLInputElement | null>(null);
/** 上传上限由 /database/config 下发(后端 HUB_FILELIB_MAX_FILE_BYTES)。 */
let maxFileBytes = $state<number | null>(null);
const maxLabel = $derived(
maxFileBytes === null ? "" : `${(maxFileBytes / 1024 / 1024).toFixed(maxFileBytes % (1024 * 1024) === 0 ? 0 : 1)}MB`,
);
const canEdit = $derived(node.role !== "VIEW");
/**
* 当前浏览目录("" = 根,否则以 "/" 结尾)。文件夹是从扁平 path 列表派生的
* 虚拟层(ADR-0030 —— git 版本存储里只有文件,没有目录对象),不对应任何
* 独立的后端资源,纯前端按 "/" 分段分组即可,无需新增接口。
*/
let currentDir = $state("");
let viewMode = $state<"list" | "grid">(loadViewMode());
function restoreCurrentDir(nodeId: string): string {
try {
return sessionStorage.getItem(`filelib.dir.${nodeId}`) ?? "";
} catch {
return "";
}
}
function loadViewMode(): "list" | "grid" {
try {
return localStorage.getItem("filelib.viewMode") === "grid" ? "grid" : "list";
} catch {
return "list";
}
}
function setViewMode(mode: "list" | "grid"): void {
viewMode = mode;
try {
localStorage.setItem("filelib.viewMode", mode);
} catch {
// 隐私模式等场景下 localStorage 可能不可用;视图切换仍在当前会话内生效,只是不跨会话记忆。
}
}
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();
});
// 切换项目时恢复对应项目的目录位置。
$effect(() => {
void node.id;
currentDir = restoreCurrentDir(node.id);
});
// currentDir 变化时持久化。
$effect(() => {
try {
sessionStorage.setItem(`filelib.dir.${node.id}`, currentDir);
} catch { /* ignore */ }
});
$effect(() => {
void loadConfig()
.then((c) => (maxFileBytes = c.maxFileBytes))
.catch(() => (maxFileBytes = null));
});
interface FolderRow {
readonly kind: "folder";
readonly name: string;
readonly path: string;
}
interface FileRow {
readonly kind: "file";
readonly name: string;
readonly path: string;
readonly size: number;
}
/** 按当前目录分组:落在 currentDir 前缀下、第一段之后还有 "/" 的算子文件夹,否则是本层文件。 */
const rows = $derived.by((): { folders: FolderRow[]; files: FileRow[] } | null => {
if (files === null) return null;
const folderNames = new Set<string>();
const fileRows: FileRow[] = [];
for (const f of files) {
if (!f.path.startsWith(currentDir)) continue;
const rest = f.path.slice(currentDir.length);
const slash = rest.indexOf("/");
if (slash === -1) fileRows.push({ kind: "file", name: rest, path: f.path, size: f.size });
else folderNames.add(rest.slice(0, slash));
}
const folders = [...folderNames]
.sort((a, b) => a.localeCompare(b))
.map((name): FolderRow => ({ kind: "folder", name, path: `${currentDir}${name}/` }));
fileRows.sort((a, b) => a.name.localeCompare(b.name));
return { folders, files: fileRows };
});
const breadcrumbSegs = $derived(currentDir === "" ? [] : currentDir.slice(0, -1).split("/"));
function enterFolder(path: string): void {
currentDir = path;
}
function goUp(): void {
if (currentDir === "") return;
const segs = currentDir.slice(0, -1).split("/");
segs.pop();
currentDir = segs.length === 0 ? "" : `${segs.join("/")}/`;
}
function gotoBreadcrumb(index: number): void {
currentDir = index < 0 ? "" : `${breadcrumbSegs.slice(0, index + 1).join("/")}/`;
}
async function submitNewFile(): Promise<void> {
const path = newPath.trim();
if (path === "") return;
const message = newMessage.trim();
try {
await api(`/database/api/projects/${node.id}/file`, {
method: "PUT",
// message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
body: message === "" ? { path, content: newContent } : { path, content: newContent, message },
});
toastOk("已创建");
showNewFile = false;
newPath = ""; newContent = ""; newMessage = "";
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);
}
/** 选文件只负责暂存与预填;真正上传在弹窗确认后。 */
function pickFile(e: Event): void {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
// 上限取后端值;拉不到就不在前端拦 —— 后端横竖会以 413 file_too_large 兼底,
// 前端这道只是省一次往返。
if (maxFileBytes !== null && file.size > maxFileBytes) {
toastErr(`文件超过 ${maxLabel} 上限`);
return;
}
pendingFile = file;
uploadPath = `${currentDir}${file.name}`;
uploadMessage = "";
}
function cancelUpload(): void {
pendingFile = null;
uploadPath = "";
uploadMessage = "";
}
async function submitUpload(): Promise<void> {
const file = pendingFile;
const targetPath = uploadPath.trim();
if (file === null || targetPath === "") return;
uploading = true;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const isBinary = bytes.includes(0);
const message = uploadMessage.trim();
const body: Record<string, string> = isBinary
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
// message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
if (message !== "") body["message"] = message;
await api(`/database/api/projects/${node.id}/file`, { method: "PUT", body });
toastOk(`已上传 ${file.name}`);
cancelUpload();
await loadFiles();
} catch (err) {
toastErr(err instanceof Error ? err.message : String(err));
} finally {
uploading = false;
}
}
</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={() => { newPath = currentDir; 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={pickFile} />
</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 rows}
<!-- 地址栏:上级 + 面包屑,与视图切换同一行,windows 资源管理器的标准布局 -->
<div class="mb-2 flex items-center justify-between gap-2 border-b border-line-soft pb-2">
<div class="flex min-w-0 items-center gap-0.5 overflow-x-auto text-[12.5px] text-ink-3">
<button
class="mr-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-ink-3 disabled:opacity-30 {currentDir !== '' ? 'hover:bg-hover hover:text-ink' : ''}"
onclick={goUp}
disabled={currentDir === ""}
title="返回上级"
aria-label="返回上级"
><Icon name="arrowUp" size={13} /></button>
<button class="shrink-0 rounded-md px-1.5 py-0.5 hover:bg-hover hover:text-ink" onclick={() => gotoBreadcrumb(-1)}>根目录</button>
{#each breadcrumbSegs as seg, i (i)}
<span class="shrink-0 text-line">/</span>
<button class="shrink-0 truncate rounded-md px-1.5 py-0.5 hover:bg-hover hover:text-ink" onclick={() => gotoBreadcrumb(i)}>{seg}</button>
{/each}
</div>
<div class="flex shrink-0 gap-1">
<button
class="flex h-[26px] w-[26px] items-center justify-center rounded-md {viewMode === 'list' ? 'bg-selected text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink'}"
onclick={() => setViewMode("list")}
title="列表视图"
aria-label="列表视图"
aria-pressed={viewMode === "list"}
><Icon name="viewList" size={14} /></button>
<button
class="flex h-[26px] w-[26px] items-center justify-center rounded-md {viewMode === 'grid' ? 'bg-selected text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink'}"
onclick={() => setViewMode("grid")}
title="大图标视图"
aria-label="大图标视图"
aria-pressed={viewMode === "grid"}
><Icon name="viewGrid" size={14} /></button>
</div>
</div>
{#if rows.folders.length === 0 && rows.files.length === 0}
<div class="quiet py-5 text-center">此文件夹为空</div>
{:else if viewMode === "list"}
<table class="list">
<tbody>
{#each rows.folders as folder (folder.path)}
<tr
class="cursor-pointer hover:bg-hover"
role="button"
tabindex="0"
onclick={() => enterFolder(folder.path)}
onkeydown={(e) => e.key === "Enter" && enterFolder(folder.path)}
>
<td><span class="inline-flex items-center gap-2 text-[12.5px] text-ink"><span class="text-ink-3"><Icon name="folder" size={15} /></span>{folder.name}</span></td>
<td class="file-meta text-right"></td>
</tr>
{/each}
{#each rows.files as f (f.path)}
<tr
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
onclick={() => selectedFilePath.set(f.path)}
>
<td><span class="inline-flex items-center gap-2 font-mono text-[12.5px] text-ink"><span class="text-ink-3"><Icon name="file" size={14} /></span>{f.name}</span></td>
<td class="file-meta text-right">{f.size} B</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<div class="grid grid-cols-[repeat(auto-fill,minmax(84px,1fr))] gap-1 py-1">
{#each rows.folders as folder (folder.path)}
<button
class="flex flex-col items-center gap-1.5 rounded-lg p-2.5 text-center hover:bg-hover"
onclick={() => enterFolder(folder.path)}
>
<span class="text-ink-3"><Icon name="folder" size={34} /></span>
<span class="line-clamp-2 w-full break-all text-[11.5px] text-ink">{folder.name}</span>
</button>
{/each}
{#each rows.files as f (f.path)}
<button
class="flex flex-col items-center gap-1.5 rounded-lg p-2.5 text-center {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
onclick={() => selectedFilePath.set(f.path)}
>
<span class="text-ink-3"><Icon name="file" size={34} /></span>
<span class="line-clamp-2 w-full break-all text-[11.5px] text-ink">{f.name}</span>
</button>
{/each}
</div>
{/if}
{/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="form-row">
<label class="form-label" for="nf-msg">提交信息(可选)</label>
<input id="nf-msg" class="input" bind:value={newMessage} placeholder="留空则自动生成" />
</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}
{#if pendingFile}
<Modal title="上传文件" onclose={cancelUpload}>
<div class="form-row">
<span class="form-label">已选文件</span>
<p class="font-mono text-[12.5px] text-ink">{pendingFile.name}<span class="quiet"> · {pendingFile.size} B</span></p>
</div>
<div class="form-row">
<label class="form-label" for="up-path">保存到路径</label>
<input id="up-path" class="input font-mono" bind:value={uploadPath} placeholder="材料/课件.pptx" />
</div>
<div class="form-row">
<label class="form-label" for="up-msg">提交信息(可选)</label>
<input id="up-msg" class="input" bind:value={uploadMessage} placeholder="留空则自动生成" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={cancelUpload} disabled={uploading}>取消</button>
<button class="btn btn-primary" onclick={submitUpload} disabled={uploading || uploadPath.trim() === ""}>
{uploading ? "上传中…" : "上传"}
</button>
</div>
</Modal>
{/if}
-381
View File
@@ -1,381 +0,0 @@
<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
@@ -1,47 +0,0 @@
<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(); e.stopPropagation(); oncontextmenu(e.clientX, e.clientY); }}
title={name}
>
<span class="flex h-20 w-20 items-center justify-center">
{#if kind === "FOLDER"}
<svg width="72" height="72" 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="66" height="66" 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="60" height="60" 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>
@@ -1,527 +0,0 @@
<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 { selectedFilePath, clearSelectedFile } from "./browser.js";
import { ROLE_LABEL } from "./labels.js";
import type { FileEntry, NodeChild, NodeDetail, Role } from "./types.js";
import Icon from "./Icon.svelte";
import Modal from "./Modal.svelte";
import ContextMenu, { type MenuItem } from "./ContextMenu.svelte";
import GridCard from "./GridCard.svelte";
import FileEditor from "./FileEditor.svelte";
import GrantsPanel from "./GrantsPanel.svelte";
import OverviewPanel from "./OverviewPanel.svelte";
const RANK: Record<Role, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min];
type View = "nodes" | "files";
type StackItem = Pick<NodeChild, "id" | "name" | "kind" | "role">;
let view = $state<View>("nodes");
/** 下钻栈(均为 FOLDER;根层为空栈)。 */
let stack = $state<StackItem[]>([]);
let children = $state<NodeChild[] | null>(null);
let nodesError = $state<string | null>(null);
/** 文件视图:当前项目详情 + 文件列表。 */
let projectNode = $state<NodeDetail | null>(null);
let files = $state<FileEntry[] | null>(null);
let filesError = $state<string | null>(null);
let selected = $state<string | null>(null);
let menu = $state<{ x: number; y: number; items: readonly MenuItem[] } | null>(null);
// 弹窗:create(新建文件夹/项目)/ rename / grants / detail / newFile
let modal = $state<"create" | "rename" | "grants" | "detail" | "newFile" | null>(null);
let createKind = $state<"FOLDER" | "PROJECT">("FOLDER");
let createParentId = $state<string | null>(null);
let formName = $state("");
let formDesc = $state("");
let renameTarget = $state<StackItem | null>(null);
let detailNode = $state<NodeDetail | null>(null);
let newPath = $state("");
let newContent = $state("");
let saving = $state(false);
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
const currentFolder = $derived(stack.length === 0 ? null : stack[stack.length - 1]!);
const canCreateHere = $derived(
currentFolder === null ? ($me?.isWebsiteAdmin ?? false) : atLeast(currentFolder.role, "EDIT"),
);
const projectCanEdit = $derived(projectNode !== null && projectNode.role !== "VIEW");
/* ------------------------------------------------------------ 数据加载 */
async function loadChildren(): Promise<void> {
children = null;
nodesError = null;
try {
const parent = currentFolder;
const url = parent === null
? "/database/api/nodes"
: `/database/api/nodes?parentId=${encodeURIComponent(parent.id)}`;
const r = await api<{ nodes: NodeChild[] }>(url);
children = r.nodes;
} catch (e) {
nodesError = errText(e);
}
}
async function loadFiles(): Promise<void> {
if (projectNode === null) return;
files = null;
filesError = null;
try {
const r = await api<{ files: FileEntry[] }>(`/database/api/projects/${projectNode.id}/files`);
files = r.files;
} catch (e) {
filesError = errText(e);
}
}
async function fetchDetail(id: string): Promise<NodeDetail> {
const r = await api<{ node: NodeDetail }>(`/database/api/nodes/${id}`);
return r.node;
}
onMount(loadChildren);
function refresh(): void {
selected = null;
menu = null;
if (view === "files") void loadFiles();
else void loadChildren();
}
/* ------------------------------------------------------------ 导航 */
function openNode(n: StackItem): void {
selected = null;
if (n.kind === "FOLDER") {
stack = [...stack, n];
void loadChildren();
} else {
void (async () => {
try {
projectNode = await fetchDetail(n.id);
view = "files";
await loadFiles();
} catch (e) {
toastErr(errText(e));
}
})();
}
}
function goRoot(): void {
if (view === "files") {
view = "nodes";
projectNode = null;
clearSelectedFile();
return;
}
stack = [];
void loadChildren();
}
function goUp(): void {
if (view === "files") {
goRoot();
return;
}
if (stack.length === 0) return;
stack = stack.slice(0, -1);
void loadChildren();
}
function goToDepth(depth: number): void {
if (view === "files") {
view = "nodes";
projectNode = null;
clearSelectedFile();
}
stack = stack.slice(0, depth);
void loadChildren();
}
/* ------------------------------------------------------------ 节点操作 */
function openCreate(kind: "FOLDER" | "PROJECT", parentId: string | null): void {
createKind = kind;
createParentId = parentId;
formName = "";
formDesc = "";
modal = "create";
}
async function submitCreate(): Promise<void> {
const name = formName.trim();
if (name === "") return;
saving = true;
try {
await api("/database/api/nodes", {
method: "POST",
body: {
parentId: createParentId,
kind: createKind,
name,
...(formDesc.trim() !== "" ? { description: formDesc.trim() } : {}),
},
});
toastOk("已创建");
modal = null;
refresh();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
function openRename(n: StackItem): void {
renameTarget = n;
formName = n.name;
modal = "rename";
}
async function submitRename(): Promise<void> {
if (renameTarget === null) return;
const name = formName.trim();
if (name === "" || name === renameTarget.name) return;
saving = true;
try {
await api(`/database/api/nodes/${renameTarget.id}`, { method: "PATCH", body: { name } });
toastOk("已重命名");
modal = null;
refresh();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
async function removeNode(n: StackItem): Promise<void> {
if (!confirm(`删除「${n.name}」?软删除后不可见。`)) return;
try {
await api(`/database/api/nodes/${n.id}`, { method: "DELETE" });
toastOk("已删除");
refresh();
} catch (e) {
toastErr(errText(e));
}
}
async function openGrants(n: StackItem): Promise<void> {
try {
detailNode = await fetchDetail(n.id);
modal = "grants";
} catch (e) {
toastErr(errText(e));
}
}
async function openDetail(n: StackItem): Promise<void> {
try {
detailNode = await fetchDetail(n.id);
modal = "detail";
} catch (e) {
toastErr(errText(e));
}
}
/* ------------------------------------------------------------ 文件操作 */
function previewFile(f: FileEntry): void {
selectedFilePath.set(f.path);
}
async function submitNewFile(): Promise<void> {
if (projectNode === null) return;
const path = newPath.trim();
if (path === "") return;
saving = true;
try {
await api(`/database/api/projects/${projectNode.id}/file`, {
method: "PUT",
body: { path, content: newContent },
});
toastOk("已创建");
modal = null;
newPath = ""; newContent = "";
await loadFiles();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
async function removeFile(f: FileEntry): Promise<void> {
if (projectNode === null || !confirm(`删除文件 ${f.path}?`)) return;
try {
const cur = await api<{ version: string }>(
`/database/api/projects/${projectNode.id}/file?path=${encodeURIComponent(f.path)}`,
);
await api(`/database/api/projects/${projectNode.id}/file?path=${encodeURIComponent(f.path)}`, {
method: "DELETE",
body: { baseVersion: cur.version },
});
toastOk("已删除");
if ($selectedFilePath === f.path) clearSelectedFile();
await loadFiles();
} catch (e) {
toastErr(errText(e));
}
}
/* ------------------------------------------------------------ 右键菜单 */
function nodeMenuItems(n: StackItem): MenuItem[] {
const items: MenuItem[] = [{ label: "打开", icon: "chevron", onclick: () => openNode(n) }];
if (n.kind === "FOLDER" && atLeast(n.role, "EDIT")) {
items.push({ label: "新建子文件夹", icon: "plus", onclick: () => openCreate("FOLDER", n.id) });
}
if (n.role === "MANAGE") {
items.push(
{ label: "重命名", icon: "pencil", onclick: () => openRename(n) },
{ label: "授权管理", icon: "shield", onclick: () => void openGrants(n) },
);
}
items.push({ label: "详情", icon: "info", onclick: () => void openDetail(n) });
if (n.role === "MANAGE") {
items.push({ label: "删除", icon: "trash", danger: true, onclick: () => void removeNode(n) });
}
return items;
}
function fileMenuItems(f: FileEntry): MenuItem[] {
const items: MenuItem[] = [
{ label: "打开预览", icon: "chevron", onclick: () => previewFile(f) },
{
label: "下载",
icon: "download",
onclick: () => {
if (projectNode === null) return;
const a = document.createElement("a");
a.href = `/database/api/projects/${projectNode.id}/file/raw?path=${encodeURIComponent(f.path)}`;
a.download = "";
a.click();
},
},
];
if (projectCanEdit) {
items.push({ label: "删除", icon: "trash", danger: true, onclick: () => void removeFile(f) });
}
return items;
}
function bgMenuItems(): MenuItem[] {
const items: MenuItem[] = [];
if (view === "nodes" && canCreateHere) {
items.push(
{ label: "新建文件夹", icon: "plus", onclick: () => openCreate("FOLDER", currentFolder?.id ?? null) },
{ label: "新建项目", icon: "plus", onclick: () => openCreate("PROJECT", currentFolder?.id ?? null) },
);
}
if (view === "files" && projectCanEdit) {
items.push({ label: "新建文件", icon: "plus", onclick: () => (modal = "newFile") });
}
items.push({ label: "刷新", icon: "refresh", onclick: refresh });
return items;
}
</script>
<div class="flex min-h-0 flex-1 flex-col">
<!-- 顶栏:返回 + 面包屑 + 动作 + 身份 -->
<header class="flex shrink-0 items-center gap-2 border-b border-line-soft bg-panel px-5 py-3">
{#if view === "files" || stack.length > 0}
<button class="btn btn-sm" onclick={goUp} title="返回上级">
<Icon name="arrowLeft" size={13} /> 返回
</button>
{/if}
<nav class="flex min-w-0 flex-1 items-center gap-1 text-[13px]">
<button
class="shrink-0 {view === 'nodes' && stack.length === 0 ? 'font-semibold text-ink' : 'text-ink-3 hover:text-ink'}"
onclick={goRoot}
>文件库</button>
{#each stack as n, i (n.id)}
<span class="text-line">/</span>
<button
class="truncate {view === 'nodes' && i === stack.length - 1
? 'font-semibold text-ink'
: 'text-ink-3 hover:text-ink'}"
onclick={() => goToDepth(i + 1)}
>{n.name}</button>
{/each}
{#if view === "files" && projectNode}
<span class="text-line">/</span>
<span class="truncate font-semibold text-ink">{projectNode.name}</span>
{/if}
</nav>
{#if view === "nodes" && canCreateHere}
<button class="btn btn-sm" onclick={() => openCreate("FOLDER", currentFolder?.id ?? null)}>
<Icon name="plus" size={13} /> 新建文件夹
</button>
<button class="btn btn-sm" onclick={() => openCreate("PROJECT", currentFolder?.id ?? null)}>
<Icon name="plus" size={13} /> 新建项目
</button>
{/if}
{#if view === "files" && projectCanEdit}
<button class="btn btn-sm" onclick={() => (modal = "newFile")}>
<Icon name="plus" size={13} /> 新建文件
</button>
{/if}
<button class="btn btn-sm" onclick={refresh} title="刷新"><Icon name="refresh" size={13} /></button>
</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(132px,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(132px,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} ondeleted={() => { modal = null; refresh(); }} />
</Modal>
{/if}
{#if modal === "newFile"}
<Modal title="新建文件" onclose={() => (modal = null)}>
<div class="form-row">
<label class="form-label" for="gf-path">路径</label>
<input id="gf-path" class="input font-mono" bind:value={newPath} placeholder="讲义/第一章.md" />
</div>
<div class="form-row">
<label class="form-label" for="gf-content">内容</label>
<textarea id="gf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (modal = null)}>取消</button>
<button class="btn btn-primary disabled:opacity-50" onclick={submitNewFile} disabled={saving}>
{saving ? "创建中…" : "创建"}
</button>
</div>
</Modal>
{/if}
-769
View File
@@ -1,769 +0,0 @@
<script lang="ts">
/**
* Group 管理面板。从已删除的 routes/adminPanels.ts `renderGroupsPanel`(747 行)
* 迁来(ADR-0029),功能与视觉逐条对齐:折叠树 / 组名过滤(命中项保留祖先链)/
* 归档组展示与恢复 / 右键菜单 / 面包屑 / 统计条 / 成员表(头像·openId·加入时间)。
*/
import { onMount } from "svelte";
import { page } from "$app/state";
import { api } from "./api.js";
import { toastErr, toastOk } from "./stores.js";
import type { MemberGroupNode, MemberGroupMember, UserSearchResult } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
import Avatar from "./Avatar.svelte";
// 后端返回扁平列表(ADR-0028);前端按 parentId/depth 拼成有序树。
let groups = $state<MemberGroupNode[]>([]);
let loaded = $state(false);
let listError = $state<string | null>(null);
let selectedId = $state<string | null>(null);
// 折叠的组 id(默认全展开)。用数组而非 Set:$state 的深层代理只跟踪普通对象/
// 数组,Set 的变更不会触发重渲染。
let collapsedIds = $state<string[]>([]);
const isCollapsed = (id: string): boolean => collapsedIds.includes(id);
const toggleCollapsed = (id: string): void => {
collapsedIds = isCollapsed(id) ? collapsedIds.filter((x) => x !== id) : [...collapsedIds, id];
};
let filterText = $state("");
let showArchived = $state(false);
let members = $state<MemberGroupMember[]>([]);
let membersLoaded = $state(false);
let membersError = $state<string | null>(null);
let memberFilter = $state("");
const selected = $derived(groups.find((g) => g.id === selectedId) ?? null);
const isArchived = $derived(selected?.archivedAt != null);
interface Row {
readonly g: MemberGroupNode;
readonly hasKids: boolean;
/** 过滤态下:自身是否命中(祖先链上的非命中项半透明显示)。 */
readonly hit: boolean;
}
/** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
const rows = $derived.by((): Row[] => {
const byParent = new Map<string | null, MemberGroupNode[]>();
const byId = new Map<string, MemberGroupNode>();
for (const g of groups) {
byId.set(g.id, g);
const arr = byParent.get(g.parentId) ?? [];
arr.push(g);
byParent.set(g.parentId, arr);
}
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
const q = filterText.trim().toLowerCase();
let keep: Set<string> | null = null;
if (q !== "") {
keep = new Set<string>();
for (const g of groups) {
if (!g.name.toLowerCase().includes(q)) continue;
let cur: MemberGroupNode | undefined = g;
while (cur !== undefined) {
keep.add(cur.id);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
}
}
const out: Row[] = [];
const walk = (parentId: string | null): void => {
for (const g of byParent.get(parentId) ?? []) {
if (keep !== null && !keep.has(g.id)) continue;
const kids = (byParent.get(g.id) ?? []).filter((k) => keep === null || keep.has(k.id));
out.push({ g, hasKids: kids.length > 0, hit: q === "" || g.name.toLowerCase().includes(q) });
// 过滤态下强制展开(否则命中项被折叠的祖先藏住)。
if (keep !== null || !isCollapsed(g.id)) walk(g.id);
}
};
walk(null);
// 兜底:父不在列表的孤儿(级联软删理论上不产生)也列出,避免"看不见"。
const seen = new Set(out.map((r) => r.g.id));
for (const g of groups) {
if (seen.has(g.id)) continue;
if (keep !== null && !keep.has(g.id)) continue;
out.push({ g, hasKids: false, hit: true });
}
return out;
});
const treeFoot = $derived.by(() => {
const active = groups.filter((g) => g.archivedAt === null);
const archivedN = groups.length - active.length;
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
return (
`${active.length} 个活跃组 · ${totalMembers} 条成员关系` +
(archivedN > 0 ? ` · ${archivedN} 个已删除` : "")
);
});
/** 面包屑:祖先链(根在前,自身在末)。 */
const chain = $derived.by((): MemberGroupNode[] => {
if (selected === null) return [];
const byId = new Map(groups.map((g) => [g.id, g]));
const out: MemberGroupNode[] = [];
for (let cur: MemberGroupNode | undefined = selected; cur !== undefined; ) {
out.unshift(cur);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
return out;
});
const childCount = $derived(groups.filter((g) => g.parentId === selectedId).length);
const shownMembers = $derived.by(() => {
const q = memberFilter.trim().toLowerCase();
if (q === "") return members;
return members.filter(
(m) =>
m.displayName.toLowerCase().includes(q) ||
m.userId.toLowerCase().includes(q) ||
m.feishuOpenId.toLowerCase().includes(q),
);
});
function fmtDate(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
async function loadGroups(): Promise<void> {
try {
const r = await api<{ groups: MemberGroupNode[] }>(
`/database/api/groups${showArchived ? "?includeArchived=1" : ""}`,
);
groups = r.groups;
listError = null;
loaded = true;
if (selectedId !== null && !groups.some((g) => g.id === selectedId)) {
selectedId = null;
members = [];
membersLoaded = false;
}
} catch (e) {
listError = errText(e);
loaded = true;
}
}
async function loadMembers(): Promise<void> {
if (selectedId === null) return;
membersLoaded = false;
membersError = null;
try {
const r = await api<{ members: MemberGroupMember[] }>(
`/database/api/groups/${encodeURIComponent(selectedId)}/members`,
);
members = r.members;
membersLoaded = true;
} catch (e) {
membersError = errText(e);
membersLoaded = true;
}
}
onMount(async () => {
await loadGroups();
// 授权面板「成员」单元格跳转:?select=<groupId> 直接选中该组。
// 组不在列表(已归档且未开归档展示)时不动作,停留默认态。
const target = page.url.searchParams.get("select");
if (target !== null && groups.some((g) => g.id === target)) select(target);
});
function select(id: string): void {
selectedId = id;
memberFilter = "";
void loadMembers();
}
async function toggleArchived(): Promise<void> {
showArchived = !showArchived;
await loadGroups();
}
/* ---------------- 右键菜单 ---------------- */
interface MenuItem {
readonly label?: string;
readonly ic?: import("./Icon.svelte").IconName;
readonly danger?: boolean;
readonly sep?: boolean;
readonly fn?: () => void;
}
let menu = $state<{ x: number; y: number; items: MenuItem[] } | null>(null);
function openMenu(e: MouseEvent, target: MemberGroupNode | null): void {
e.preventDefault();
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
const items: MenuItem[] =
target === null
? [{ label: "新建根 Group", ic: "plus", fn: () => openCreate(null) }]
: target.archivedAt !== null
? [
{ label: "查看成员(只读)", ic: "users", fn: () => select(target.id) },
{ label: "恢复此 Group", ic: "restore", fn: () => void restoreGroup(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
]
: [
{ label: "新建子 Group", ic: "plus", fn: () => openCreate(target) },
{ label: "添加成员", ic: "user", fn: () => { select(target.id); openAddMember(); } },
{ label: "重命名 / 改描述", ic: "pencil", fn: () => openRename(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => void deleteGroup(target) },
];
// 贴边翻转,避免菜单溢出视口(菜单宽 184、每项约 34)。
const w = 184;
const h = items.reduce((n, it) => n + (it.sep === true ? 9 : 34), 10);
menu = {
x: Math.min(e.clientX, window.innerWidth - w - 8),
y: Math.min(e.clientY, window.innerHeight - h - 8),
items,
};
}
/* ---------------- 弹窗 ---------------- */
let showCreate = $state(false);
let createParent = $state<MemberGroupNode | null>(null);
let newName = $state("");
let newDesc = $state("");
let showRename = $state(false);
let renameTarget = $state<MemberGroupNode | null>(null);
let editName = $state("");
let editDesc = $state("");
let showAdd = $state(false);
let addQuery = $state("");
let addResults = $state<UserSearchResult[]>([]);
let addSearching = $state(false);
function openCreate(parent: MemberGroupNode | null): void {
createParent = parent;
newName = "";
newDesc = "";
showCreate = true;
}
async function createGroup(): Promise<void> {
const name = newName.trim();
if (name === "") {
toastErr("名称必填");
return;
}
const parentId = createParent?.id ?? null;
try {
await api("/database/api/groups", {
method: "POST",
body: { name, parentId, ...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}) },
});
showCreate = false;
// 建完自动展开父节点,否则新子组藏在折叠的父下面看不见。
if (parentId !== null) collapsedIds = collapsedIds.filter((x) => x !== parentId);
toastOk("已创建成员组");
await loadGroups();
} catch (e) {
toastErr(errText(e));
}
}
function openRename(g: MemberGroupNode): void {
renameTarget = g;
editName = g.name;
editDesc = g.description ?? "";
showRename = true;
}
async function saveRename(): Promise<void> {
if (renameTarget === null) return;
const name = editName.trim();
if (name === "") {
toastErr("名称必填");
return;
}
try {
// description 总是回传(含空串)—— 空串即清除描述(ADR-0028 决策6)。
await api(`/database/api/groups/${encodeURIComponent(renameTarget.id)}`, {
method: "PATCH",
body: { name, description: editDesc.trim() },
});
showRename = false;
toastOk("已保存");
await loadGroups();
} catch (e) {
toastErr(errText(e));
}
}
async function deleteGroup(g: MemberGroupNode): Promise<void> {
if (
!confirm(
`删除「${g.name}」?\n\n软删除:整棵子树一并标记删除,相关授权立即失效,` +
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。",
)
)
return;
try {
const r = await api<{ archivedCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}`,
{ method: "DELETE" },
);
if (selectedId === g.id && !showArchived) {
selectedId = null;
members = [];
membersLoaded = false;
}
toastOk(`已删除 ${r.archivedCount} 个组(软删除,可恢复)`);
await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) {
toastErr(errText(e));
}
}
async function restoreGroup(g: MemberGroupNode): Promise<void> {
// 恢复语义与删除不对称(ADR-0028 决策7):只回该组 + 已归档祖先链,子树仍归档。
if (
!confirm(
`恢复「${g.name}」?\n\n其已删除的上级会一并恢复(否则它在树上无路径);` +
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。",
)
)
return;
try {
const r = await api<{ restoredCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}/restore`,
{ method: "POST" },
);
toastOk(`已恢复 ${r.restoredCount} 个组`);
await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) {
toastErr(errText(e));
}
}
function openAddMember(): void {
addQuery = "";
addResults = [];
showAdd = true;
}
/** 成员选择器:搜全局用户,excludeGroupId 过滤掉本组已有成员。 */
async function searchUsers(): Promise<void> {
if (selectedId === null) return;
addSearching = true;
try {
const r = await api<{ users: UserSearchResult[] }>(
`/database/api/users/search?q=${encodeURIComponent(addQuery.trim())}` +
`&excludeGroupId=${encodeURIComponent(selectedId)}`,
);
addResults = r.users;
} catch (e) {
toastErr(errText(e));
} finally {
addSearching = false;
}
}
async function addMember(userId: string): Promise<void> {
if (selectedId === null) return;
try {
await api(`/database/api/groups/${encodeURIComponent(selectedId)}/members`, {
method: "POST",
body: { userId },
});
toastOk("已添加成员");
addResults = addResults.filter((u) => u.userId !== userId);
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(errText(e));
}
}
async function removeMember(m: MemberGroupMember): Promise<void> {
if (selectedId === null) return;
if (!confirm(`将「${m.displayName || m.userId}」移出本组?其经由本组获得的授权立即失效。`)) return;
try {
await api(
`/database/api/groups/${encodeURIComponent(selectedId)}/members/${encodeURIComponent(m.userId)}`,
{ method: "DELETE" },
);
toastOk("已移除成员");
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(errText(e));
}
}
</script>
<svelte:window
onclick={() => (menu = null)}
onkeydown={(e) => {
if (e.key === "Escape") menu = null;
}}
/>
<div class="flex h-full min-h-0 items-stretch gap-3.5">
<!-- 左:组树 -->
<div class="panel flex w-[326px] shrink-0 flex-col !p-3.5" style="min-height:0">
<div class="mb-2.5 flex items-center gap-2">
<span class="flex text-accent"><Icon name="layers" size={17} /></span>
<div class="section-title flex-1">Group 树</div>
<button class="btn btn-sm" onclick={() => openCreate(null)}>
<Icon name="plus" size={13} /> 根组
</button>
</div>
<div class="relative mb-2">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="过滤组名…" bind:value={filterText} />
</div>
<label class="switch mb-2.5 text-[11.5px] text-ink-3">
<input type="checkbox" checked={showArchived} onchange={toggleArchived} />
<span></span>
显示已删除的组
</label>
<!-- 树空白处右键 = 建根组 -->
<div
class="-mx-1.5 min-h-0 flex-1 overflow-y-auto"
role="tree"
tabindex="-1"
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("[data-node]") !== null) return;
openMenu(e, null);
}}
>
{#if !loaded}
<div class="quiet px-3 py-6 text-center">加载中…</div>
{:else if listError !== null}
<div class="px-3 py-6 text-center text-xs text-danger">{listError}</div>
{:else if groups.length === 0}
<div class="quiet flex flex-col items-center gap-2 px-3 py-[22px] text-center">
<span class="flex text-line"><Icon name="layers" size={30} /></span>
暂无成员组 · 点上方「根组」开始
</div>
{:else if rows.length === 0}
<div class="quiet px-3 py-[22px] text-center">无匹配的组</div>
{:else}
{#each rows as { g, hasKids, hit } (g.id)}
{@const arch = g.archivedAt !== null}
<div
data-node
class="flex cursor-pointer select-none items-center gap-1.5 rounded-lg py-1.5 pr-2 text-[13px]"
class:bg-selected={selectedId === g.id}
class:opacity-50={!hit}
style="padding-left: {8 + g.depth * 15}px"
role="treeitem"
aria-selected={selectedId === g.id}
tabindex="-1"
onclick={() => select(g.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(g.id);
}
}}
oncontextmenu={(e) => openMenu(e, g)}
>
{#if hasKids}
<span
class="flex w-[15px] shrink-0 justify-center text-ink-3 transition-transform"
class:rotate-90={!(isCollapsed(g.id) && filterText.trim() === "")}
role="button"
tabindex="-1"
aria-label="折叠 / 展开"
onclick={(e) => {
e.stopPropagation();
toggleCollapsed(g.id);
}}
onkeydown={(e) => {
if (e.key === "Enter") toggleCollapsed(g.id);
}}
>
<Icon name="chevron" size={13} />
</span>
{:else}
<span class="inline-block w-[15px] shrink-0"></span>
{/if}
<span class="flex" class:text-accent={selectedId === g.id && !arch} class:text-ink-3={arch || selectedId !== g.id}>
<Icon name={arch ? "archive" : "group"} size={15} />
</span>
<span class="flex-1 truncate" class:text-ink-3={arch} class:line-through={arch}>{g.name}</span>
<span class="tag shrink-0" class:opacity-70={arch}>
<Icon name="user" size={10} />{g.memberCount}
</span>
{#if arch}
<span class="tag shrink-0 !text-[10px] opacity-85">已删除</span>
{/if}
</div>
{/each}
{/if}
</div>
<div class="section-note mt-2 border-t border-line-soft pt-2">{loaded ? treeFoot : ""}</div>
</div>
<!-- 右:成员表 -->
<div class="panel flex min-h-0 min-w-0 flex-1 flex-col !p-0">
{#if selected === null}
<div class="quiet m-auto flex flex-col items-center gap-2.5 p-7 text-center">
<span class="flex text-line"><Icon name="users" size={40} /></span>
从左侧选择一个 Group 查看成员
</div>
{:else}
{#if isArchived}
<!-- 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。 -->
<div
class="flex shrink-0 items-center gap-2.5 border-b border-line-soft bg-hover px-[18px] py-2.5 text-[12.5px]"
>
<span class="flex text-ink-3"><Icon name="archive" size={15} /></span>
<span class="flex-1">
此 Group 已删除于 {fmtDate(selected.archivedAt ?? "")} · 成员只读,不再授予任何权限
</span>
<button class="btn !text-xs" onclick={() => void restoreGroup(selected)}>
<Icon name="restore" size={13} /> 恢复
</button>
</div>
{/if}
<div class="shrink-0 border-b border-line-soft px-[18px] pb-3 pt-4">
<div class="mb-1.5 text-xs">
{#each chain as c, i (c.id)}
{#if i > 0}<span class="mx-[5px] text-ink-3">/</span>{/if}
<span class={i === chain.length - 1 ? "font-medium text-ink" : "text-ink-3"}>{c.name}</span>
{/each}
</div>
<div class="flex items-center gap-2.5">
<span class="flex" class:text-ink-3={isArchived} class:text-accent={!isArchived}>
<Icon name={isArchived ? "archive" : "group"} size={20} />
</span>
<div class="min-w-0 flex-1">
<div class="text-base font-semibold" class:text-ink-3={isArchived}>{selected.name}</div>
{#if selected.description !== null && selected.description !== ""}
<div class="section-note mt-0.5">{selected.description}</div>
{:else}
<div class="section-note mt-0.5 opacity-60">无描述</div>
{/if}
</div>
<!-- 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。 -->
{#if !isArchived}
<button class="btn !text-xs" onclick={() => openRename(selected)}>
<Icon name="pencil" size={13} /> 编辑
</button>
<button class="btn btn-primary !text-xs" onclick={openAddMember}>
<Icon name="plus" size={13} /> 添加成员
</button>
{/if}
</div>
<div class="mt-3 flex gap-4 text-xs text-ink-3">
<span class="inline-flex items-center gap-1"><Icon name="user" size={12} />{members.length} 名成员</span>
<span class="inline-flex items-center gap-1"><Icon name="layers" size={12} />层级 {selected.depth}</span>
<span class="inline-flex items-center gap-1"><Icon name="group" size={12} />{childCount} 个子组</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2.5 px-[18px] py-2.5">
<div class="relative max-w-[280px] flex-1">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="搜索成员…" bind:value={memberFilter} />
</div>
<span class="file-meta">
{memberFilter.trim() === "" ? "" : `${shownMembers.length} / ${members.length}`}
</span>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
{#if !membersLoaded}
<div class="quiet px-3 py-9 text-center">加载中…</div>
{:else if membersError !== null}
<div class="px-3 py-9 text-center text-xs text-danger">{membersError}</div>
{:else if members.length === 0}
<div class="quiet flex flex-col items-center gap-2.5 px-3 py-9 text-center">
<span class="flex text-line"><Icon name="users" size={34} /></span>
{isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」"}
</div>
{:else if shownMembers.length === 0}
<div class="quiet px-3 py-[30px] text-center">无匹配成员</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>飞书 openId</th>
<th>加入时间</th>
{#if !isArchived}<th class="!text-right">操作</th>{/if}
</tr>
</thead>
<tbody>
{#each shownMembers as m (m.userId)}
<tr>
<td>
<span class="inline-flex items-center gap-2.5">
<Avatar displayName={m.displayName} userId={m.userId} avatarUrl={m.avatarUrl} size={28} />
<span class="font-medium">{m.displayName || "(未命名)"}</span>
</span>
</td>
<td class="file-meta">{m.userId}</td>
<td class="file-meta">{m.feishuOpenId || "—"}</td>
<td class="file-meta">{fmtDate(m.joinedAt)}</td>
{#if !isArchived}
<td class="text-right">
<button class="link-danger inline-flex items-center gap-1" onclick={() => void removeMember(m)}>
<Icon name="minus" size={12} /> 移除
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
</div>
</div>
<!-- 右键菜单 -->
{#if menu !== null}
<div
class="fixed z-[60] min-w-[184px] rounded-[10px] border border-line bg-panel p-[5px] text-[13px] shadow-[0_4px_20px_rgba(26,26,24,.07)]"
style="left:{menu.x}px;top:{menu.y}px"
role="menu"
tabindex="-1"
>
{#each menu.items as it, i (i)}
{#if it.sep === true}
<div class="mx-1.5 my-1 h-px bg-line-soft"></div>
{:else}
<div
class="flex cursor-pointer items-center gap-2 rounded-md px-[11px] py-[7px] hover:bg-hover"
class:text-danger={it.danger === true}
role="menuitem"
tabindex="-1"
onclick={(e) => {
e.stopPropagation();
menu = null;
it.fn?.();
}}
onkeydown={(e) => {
if (e.key === "Enter") {
menu = null;
it.fn?.();
}
}}
>
{#if it.ic !== undefined}<span class="flex opacity-75"><Icon name={it.ic} size={14} /></span>{/if}
{it.label}
</div>
{/if}
{/each}
</div>
{/if}
{#if showCreate}
<Modal
title={createParent === null ? "新建根 Group" : `在「${createParent.name}」下新建子 Group`}
onclose={() => (showCreate = false)}
>
<div class="form-row">
<label class="form-label" for="gc-name">名称</label>
<input id="gc-name" class="input" bind:value={newName} placeholder="例如:物理教研组" />
</div>
<div class="form-row">
<label class="form-label" for="gc-desc">描述(可选)</label>
<input id="gc-desc" class="input" bind:value={newDesc} placeholder="一句话说明" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showCreate = false)}>取消</button>
<button class="btn btn-primary" onclick={createGroup}>创建</button>
</div>
</Modal>
{/if}
{#if showRename && renameTarget !== null}
<Modal title="重命名 / 改描述" onclose={() => (showRename = false)}>
<div class="form-row">
<label class="form-label" for="gr-name">名称</label>
<input id="gr-name" class="input" bind:value={editName} />
</div>
<div class="form-row">
<label class="form-label" for="gr-desc">描述</label>
<input id="gr-desc" class="input" bind:value={editDesc} placeholder="留空则清除描述" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showRename = false)}>取消</button>
<button class="btn btn-primary" onclick={saveRename}>保存</button>
</div>
</Modal>
{/if}
{#if showAdd && selected !== null}
<Modal title={`向「${selected.name}」添加成员`} onclose={() => (showAdd = false)}>
<div class="form-row">
<label class="form-label" for="ga-q">搜索用户(姓名 / userId / 飞书 openId)</label>
<div class="flex gap-2">
<input
id="ga-q"
class="input"
bind:value={addQuery}
placeholder="留空列出全部候选"
onkeydown={(e) => {
if (e.key === "Enter") void searchUsers();
}}
/>
<button class="btn" onclick={searchUsers}><Icon name="search" size={13} /> 搜索</button>
</div>
<div class="section-note mt-1.5">已在本组的成员不会出现在结果里。</div>
</div>
<div class="max-h-[280px] overflow-y-auto">
{#if addSearching}
<div class="quiet px-3 py-6 text-center">搜索中…</div>
{:else if addResults.length === 0}
<div class="quiet px-3 py-6 text-center">无候选用户 · 先点「搜索」</div>
{:else}
{#each addResults as u (u.userId)}
<div class="flex items-center gap-2.5 border-b border-line-soft py-2 last:border-b-0">
<Avatar displayName={u.displayName} userId={u.userId} avatarUrl={u.avatarUrl} size={26} />
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium">{u.displayName || "(未命名)"}</div>
<div class="file-meta truncate">{u.feishuOpenId || u.userId}</div>
</div>
<button class="btn btn-sm" onclick={() => void addMember(u.userId)}>
<Icon name="plus" size={12} /> 添加
</button>
</div>
{/each}
{/if}
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showAdd = false)}>关闭</button>
</div>
</Modal>
{/if}
-113
View File
@@ -1,113 +0,0 @@
<script lang="ts">
/**
* 项目修改历史 tab:展示所有文件的提交记录(新→旧)。
* 默认只显示 commit message + 作者头像 + 时间,点击可展开查看修改的文件。
*/
import { api } from "./api.js";
import { toastErr } from "./stores.js";
import type { ProjectCommitInfo, NodeDetail } from "./types.js";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
let history = $state<ProjectCommitInfo[] | null>(null);
let loadError = $state<string | null>(null);
let limit = $state(50);
let expanded = $state<Set<string>>(new Set());
async function loadHistory(): Promise<void> {
try {
const r = await api<{ history: ProjectCommitInfo[] }>(
`/database/api/projects/${node.id}/history?limit=${limit}`,
);
history = r.history;
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
$effect(() => {
void node.id;
void loadHistory();
});
function toggle(version: string): void {
const next = new Set(expanded);
if (next.has(version)) next.delete(version);
else next.add(version);
expanded = next;
}
function loadMore(): void {
limit += 50;
void loadHistory();
}
function authorInitial(author: string | undefined): string {
return (author ?? "?").slice(0, 1).toUpperCase();
}
</script>
<div class="panel">
<div class="mb-3 section-title">修改历史</div>
{#if history === 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 history && history.length === 0}
<div class="quiet py-5 text-center">暂无提交记录</div>
{:else if history}
<div>
{#each history as commit (commit.version)}
{@const isOpen = expanded.has(commit.version)}
<div class="border-t border-line-soft first:border-t-0">
<button
class="flex w-full items-center gap-2.5 py-3 text-left transition hover:bg-hover rounded-md px-1.5 -mx-1.5"
onclick={() => toggle(commit.version)}
aria-expanded={isOpen}
>
<!-- 头像 -->
<span
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent text-[12px] font-semibold text-white"
aria-hidden="true"
>{authorInitial(commit.author)}</span>
<!-- 消息与时间 -->
<div class="min-w-0 flex-1">
<p class="text-[13px] text-ink">{commit.message}</p>
<p class="mt-0.5 text-[11.5px] text-ink-3">
{#if commit.author}<span>{commit.author}</span> · {/if}{new Date(commit.committedAt).toLocaleString("zh-CN")}
</p>
</div>
<!-- 展开指示 -->
<span class="shrink-0 text-ink-3 transition {isOpen ? 'rotate-90' : ''}">
<Icon name="chevron" size={12} />
</span>
</button>
{#if isOpen}
<div class="pb-3 pl-[46px]">
<div class="flex flex-wrap gap-1">
{#each commit.files as filePath (filePath)}
<span class="inline-flex items-center gap-1 rounded-md bg-hover px-1.5 py-0.5 font-mono text-[11px] text-ink-2">
<Icon name="file" size={10} />{filePath}
</span>
{/each}
{#if commit.files.length === 0}
<span class="text-[11.5px] text-ink-3">无文件变更信息</span>
{/if}
</div>
</div>
{/if}
</div>
{/each}
</div>
{#if history.length >= limit}
<div class="mt-3 flex justify-center">
<button class="btn" onclick={loadMore}>加载更多</button>
</div>
{/if}
{/if}
</div>
-54
View File
@@ -1,54 +0,0 @@
<script lang="ts" module>
// 从已删除的 routes/adminPanels.ts 的 GROUP_ICONS 原样搬来(ADR-0029)。
export const ICONS = {
// Group 节点 = 人的集合。**不用文件夹图标** —— Group 不是目录,与文件库的
// FOLDER/PROJECT 是两套体系,图标上也不应混淆。两人剪影。
group:
"M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19M10 11.5a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM20 19v-1.5a3.5 3.5 0 0 0-2.6-3.38M15.4 5.22a3.25 3.25 0 0 1 0 6.06",
users:
"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",
plus: "M12 5v14M5 12h14",
pencil: "M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3Z",
trash: "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14",
search: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z",
chevron: "m9 18 6-6-6-6",
layers: "m12 2 9 5-9 5-9-5 9-5Zm9 11-9 5-9-5m18 5-9 5-9-5",
clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-14v6l4 2",
minus: "M5 12h14",
download: "M12 3v12m0 0 4-4m-4 4-4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2",
refresh: "M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6",
arrowLeft: "M19 12H5m0 0 6 6m-6-6 6-6",
info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-10v6m0-11v.5",
shield: "M12 3l8 3v6c0 4.5-3.2 7.7-8 9-4.8-1.3-8-4.5-8-9V6l8-3Z",
folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z",
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6",
restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5",
download: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3",
// Windows 资源管理器式文件浏览:文件夹/文件项与大小图标切换用。
folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z",
file: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z M14 2v4a2 2 0 0 0 2 2h4",
viewList: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
viewGrid: "M4 4h7v7H4V4Zm9 0h7v7h-7V4ZM4 13h7v7H4v-7Zm9 0h7v7h-7v-7Z",
arrowUp: "M12 19V5M5 12l7-7 7 7",
} as const;
export type IconName = keyof typeof ICONS;
</script>
<script lang="ts">
let { name, size = 16 }: { name: IconName; size?: number } = $props();
</script>
<svg
style="width:{size}px;height:{size}px"
class="shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
><path d={ICONS[name]} /></svg>
-161
View File
@@ -1,161 +0,0 @@
<script lang="ts">
/**
* 文件库浏览器(树 + 详情 + 文件编辑栏)。
*
* 两处复用:老师端 /app(showUserFooter=true,侧栏底部带身份与退出)
* 与管理后台 /database/dashboard/library(false —— 外层壳已有身份区)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { me, toastErr, toastOk } from "./stores.js";
import { logout } from "./session.js";
import { treeVersion, bumpTree, currentNode, breadcrumb, selectedFilePath, clearSelectedFile, bumpFiles, restoreNodeId } from "./browser.js";
import type { NodeChild, NodeDetail, BreadcrumbEntry } from "./types.js";
import TreeNode from "./TreeNode.svelte";
import NodeDetailPanel from "./NodeDetailPanel.svelte";
import FileEditor from "./FileEditor.svelte";
import Modal from "./Modal.svelte";
let { showUserFooter = false }: { showUserFooter?: boolean } = $props();
let roots = $state<NodeChild[] | null>(null);
let treeError = $state<string | null>(null);
let showCreateRoot = $state(false);
let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
let newDesc = $state("");
async function loadRoots(): Promise<void> {
try {
const r = await api<{ nodes: NodeChild[] }>("/database/api/nodes");
roots = r.nodes;
treeError = null;
} catch (e) {
treeError = e instanceof Error ? e.message : String(e);
}
}
onMount(async () => {
await loadRoots();
// 刷新后恢复之前选中的节点。
if (restoreNodeId) {
try {
const [detail, crumb] = await Promise.all([
api<{ node: NodeDetail }>(`/database/api/nodes/${restoreNodeId}`),
api<{ breadcrumb: BreadcrumbEntry[] }>(`/database/api/nodes/${restoreNodeId}/breadcrumb`),
]);
currentNode.set(detail.node);
breadcrumb.set(crumb.breadcrumb);
} catch {
// 节点已删除或无权访问,静默忽略。
}
}
});
$effect(() => {
void $treeVersion;
void loadRoots();
});
async function createRoot(): Promise<void> {
const name = newName.trim();
if (name === "") return;
try {
await api("/database/api/nodes", {
method: "POST",
body: {
parentId: null,
kind: newKind,
name,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
});
toastOk("已创建");
showCreateRoot = false;
newName = ""; newKind = "FOLDER"; newDesc = "";
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
const initial = $derived((($me?.displayName ?? $me?.userId) ?? "U").slice(0, 1).toUpperCase());
</script>
<div class="flex min-h-0 flex-1">
<!-- 侧栏 -->
<aside class="flex w-[300px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
<span class="text-[15px] font-semibold text-ink">文件库</span>
{#if $me?.isWebsiteAdmin}
<button class="btn btn-sm" onclick={() => (showCreateRoot = true)}>
+ 根目录
</button>
{/if}
</div>
<div class="flex-1 overflow-y-auto px-2 py-2 text-[13px]">
{#if treeError}
<div class="px-3 py-6 text-center text-xs text-danger">{treeError}</div>
{:else if roots === null}
<div class="px-3 py-6 text-center text-xs text-ink-3">加载中…</div>
{:else if roots.length === 0}
<div class="px-3 py-6 text-center text-xs text-ink-3">
{$me?.isWebsiteAdmin ? "空文件库 · 点上方「+ 根目录」开始" : "文件库为空,请联系管理员创建根目录"}
</div>
{:else}
{#each roots as node (node.id)}
<TreeNode {node} depth={0} />
{/each}
{/if}
</div>
{#if showUserFooter}
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
<!-- 展示名优先;/me 取不到 User 行时后端已回落为 userId。 -->
<span class="flex-1 truncate text-ink" title={$me?.userId ?? ""}>{$me?.displayName ?? ""}</span>
<button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button>
</div>
{/if}
</aside>
<!-- 主区 -->
<main class="flex-1 overflow-y-auto">
<NodeDetailPanel />
</main>
<!-- 文件编辑器(模态框) -->
{#if $selectedFilePath && $currentNode?.kind === "PROJECT"}
<FileEditor
projectId={$currentNode.id}
path={$selectedFilePath}
role={$currentNode.role}
onchanged={bumpFiles}
onclose={clearSelectedFile}
/>
{/if}
</div>
{#if showCreateRoot}
<Modal title="新建根目录" onclose={() => (showCreateRoot = false)}>
<div class="form-row">
<label class="form-label" for="root-name">名称</label>
<input id="root-name" class="input" bind:value={newName} placeholder="例如:物理教研" />
</div>
<div class="form-row">
<label class="form-label" for="root-kind">类型</label>
<select id="root-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option>
</select>
</div>
<div class="form-row">
<label class="form-label" for="root-desc">简介(可选)</label>
<textarea id="root-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showCreateRoot = false)}>取消</button>
<button class="btn btn-primary" onclick={createRoot}>创建</button>
</div>
</Modal>
{/if}
-43
View File
@@ -1,43 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { loadConfig, type AppConfig } from "./config.js";
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
try {
info = await loadConfig();
} catch {
loadFailed = true;
}
});
</script>
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[380px] rounded-2xl border border-line-soft bg-panel p-9 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<div class="text-center text-[26px] font-semibold tracking-wide text-ink">文件库</div>
<p class="mt-2.5 mb-8 text-center text-[13px] text-ink-3">课程资源与教研文件,一处安放,随处可查</p>
{#if info}
<a
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
>使用飞书登录</a>
{#if info.devLoginEnabled}
<div class="my-5 flex items-center gap-2.5 text-[11px] text-ink-3">
<span class="flex-1 border-t border-line-soft"></span>开发模式
<span class="flex-1 border-t border-line-soft"></span>
</div>
<a href="/app/dev-login-teacher" data-sveltekit-reload class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover">⚡ 一键登录(老师)</a>
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
{:else if loadFailed}
<p class="text-center text-[12.5px] text-danger">无法加载登录配置,请稍后重试</p>
{:else}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{/if}
</div>
</div>
-16
View File
@@ -1,16 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { title, onclose, children, maxW = 440 }: { title: string; onclose: () => void; children: Snippet; maxW?: number } = $props();
</script>
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/30 p-4"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<div class="max-h-[88vh] w-full overflow-y-auto rounded-2xl border border-line-soft bg-panel p-6 shadow-[0_4px_20px_rgba(26,26,24,.07)]" style="max-width:{maxW}px">
<div class="mb-4 text-[15px] font-semibold">{title}</div>
{@render children()}
</div>
</div>
@@ -1,189 +0,0 @@
<script lang="ts">
import { api } from "./api.js";
import { currentNode, breadcrumb, bumpTree, clearSelectedFile, activeTab, restoreTab } from "./browser.js";
import { toastOk, toastErr } from "./stores.js";
import { ROLE_LABEL } from "./labels.js";
import OverviewPanel from "./OverviewPanel.svelte";
import FilesPanel from "./FilesPanel.svelte";
import GrantsPanel from "./GrantsPanel.svelte";
import HistoryPanel from "./HistoryPanel.svelte";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
type Tab = "detail" | "files" | "history" | "grants";
const validTabs: readonly Tab[] = ["detail", "files", "history", "grants"];
let tab = $state<Tab>((validTabs.includes(restoreTab as Tab) ? restoreTab as Tab : "detail"));
/** 跟踪上一次见到的 node id,用于判断是否真正切换了节点。 */
let prevNodeId: string | null = null;
/** 首次恢复时不重置 tab。 */
let restoredOnce = restoreTab !== null;
let showCreateChild = $state(false);
let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
let newDesc = $state("");
const node = $derived($currentNode);
const crumbs = $derived($breadcrumb);
const canManage = $derived(node?.role === "MANAGE");
const canEdit = $derived(canManage || node?.role === "EDIT");
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;修改历史仅 PROJECT;授权仅 MANAGE
// (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。
const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => {
const out: Array<readonly [Tab, string]> = [["detail", "概览"]];
if (node?.kind === "PROJECT") out.push(["files", "文件"]);
if (node?.kind === "PROJECT") out.push(["history", "修改历史"]);
if (canManage) out.push(["grants", "授权"]);
return out;
});
$effect(() => {
const id = node?.id ?? null;
// node 还未加载或与上次相同时不做任何事。
if (id === null || id === prevNodeId) return;
prevNodeId = id;
if (restoredOnce) {
// 首次恢复(刷新后)保持 persisted tab,但要确保 tab 对当前节点有效。
restoredOnce = false;
const isProject = node?.kind === "PROJECT";
if ((tab === "files" || tab === "history") && !isProject) tab = "detail";
if (tab === "grants" && node?.role !== "MANAGE") tab = "detail";
} else {
tab = "detail";
}
clearSelectedFile();
});
// tab 变化时同步到持久化 store。
$effect(() => {
activeTab.set(tab);
});
async function createChild(): Promise<void> {
const name = newName.trim();
if (name === "" || node === null) return;
try {
await api("/database/api/nodes", {
method: "POST",
body: {
parentId: node.id,
kind: newKind,
name,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
});
toastOk("已创建");
showCreateChild = false;
newName = ""; newKind = "FOLDER"; newDesc = "";
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function renameNode(): Promise<void> {
if (node === null) return;
const name = prompt("新名称", node.name);
if (name === null) return;
try {
await api(`/database/api/nodes/${node.id}`, { method: "PATCH", body: { name } });
toastOk("已重命名");
bumpTree();
currentNode.update((n) => (n ? { ...n, name } : n));
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function deleteNode(): Promise<void> {
if (node === null || !confirm(`确认删除「${node.name}」?软删除后不可见。`)) return;
try {
await api(`/database/api/nodes/${node.id}`, { method: "DELETE" });
toastOk("已删除");
currentNode.set(null);
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
{#if node === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">从左侧选择一个文件夹或项目</div>
{:else}
<div class="mx-auto max-w-[1400px] px-9 py-9">
<div class="mb-2 text-[12.5px] text-ink-3">
{#each crumbs as c, i (i)}
{#if i > 0}<span class="mx-1 text-line">/</span>{/if}
<span>{c.name ?? "…"}</span>
{/each}
</div>
<div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-2 text-[17px] font-semibold text-ink">
{node.name}
<span class="tag">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
<span class="tag !border-line !text-ink-2">{ROLE_LABEL[node.role]}</span>
</div>
<div class="flex gap-1.5">
{#if canEdit && node.kind === "FOLDER"}
<button class="btn" onclick={() => (showCreateChild = true)}>
<Icon name="plus" size={13} /> 新建子节点
</button>
{/if}
{#if canManage}
<button class="btn" onclick={renameNode}><Icon name="pencil" size={13} /> 重命名</button>
<button class="btn btn-danger" onclick={deleteNode}><Icon name="trash" size={13} /> 删除</button>
{/if}
</div>
</div>
<div class="mb-[18px] flex gap-0.5 border-b border-line-soft">
{#each tabs as [id, label] (id)}
<button
class="-mb-px border-b-2 px-3.5 py-2 text-[13px] transition {tab === id
? 'border-accent font-semibold text-ink'
: 'border-transparent text-ink-3 hover:text-ink'}"
onclick={() => (tab = id)}
>{label}</button>
{/each}
</div>
{#if tab === "grants"}
<GrantsPanel {node} />
{:else if tab === "files" && node.kind === "PROJECT"}
<FilesPanel {node} />
{:else if tab === "history" && node.kind === "PROJECT"}
<HistoryPanel {node} />
{:else}
<OverviewPanel {node} />
{#if node.kind === "FOLDER"}
<div class="quiet mt-3.5">文件夹是透明组织节点,点左侧树展开以浏览子内容。</div>
{/if}
{/if}
</div>
{/if}
{#if showCreateChild && node}
<Modal title="新建子节点" onclose={() => (showCreateChild = false)}>
<div class="form-row">
<label class="form-label" for="child-name">名称</label>
<input id="child-name" class="input" bind:value={newName} placeholder="例如:物理必修一" />
</div>
<div class="form-row">
<label class="form-label" for="child-kind">类型</label>
<select id="child-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option>
</select>
</div>
<div class="form-row">
<label class="form-label" for="child-desc">简介(可选)</label>
<textarea id="child-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showCreateChild = false)}>取消</button>
<button class="btn btn-primary" onclick={createChild}>创建</button>
</div>
</Modal>
{/if}
@@ -1,190 +0,0 @@
<script lang="ts">
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { currentNode } from "./browser.js";
import { ROLE_LABEL } from "./labels.js";
import type { ExportJob, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { node, ondeleted }: { node: NodeDetail; ondeleted?: () => void } = $props();
let showEditDesc = $state(false);
let descDraft = $state("");
let exportJob = $state<ExportJob | null>(null);
let exporting = $state(false);
let deleting = $state(false);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const canManage = $derived(node.role === "MANAGE");
const roleLabel = $derived(ROLE_LABEL[node.role]);
/** 删除(进回收站,可恢复;ADR-0031)。MANAGE 专属,与右键菜单同语义。 */
async function deleteNode(): Promise<void> {
if (!confirm(`删除「${node.name}」?移入回收站,可在回收站恢复。`)) return;
deleting = true;
try {
await api(`/database/api/nodes/${node.id}`, { method: "DELETE" });
toastOk("已移入回收站");
ondeleted?.();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
} finally {
deleting = false;
}
}
$effect(() => {
void node.id;
exportJob = null;
exporting = false;
});
function openEditDesc(): void {
descDraft = node.description ?? "";
showEditDesc = true;
}
async function saveDesc(): Promise<void> {
const description = descDraft.trim();
try {
await api(`/database/api/nodes/${node.id}`, {
method: "PATCH",
body: { description: description === "" ? null : description },
});
toastOk("简介已保存");
showEditDesc = false;
const next = description === "" ? null : description;
currentNode.update((n) => (n && n.id === node.id ? { ...n, description: next } : n));
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
/**
* 导出 PDF:提交 job → 轮询 → 完成即自动触发浏览器下载。
*
* 只有一个导出目标,所以不给目标选择器 —— target 由后端 adapter 固定。
* 下载走 <a download> 而非 fetch+blob:接口是 same-origin cookie 认证,
* 浏览器直接带上会话,不需要在 JS 里搬一遍字节。
*/
async function exportPdf(): Promise<void> {
if (exporting) return;
exporting = true;
exportJob = null;
try {
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
method: "POST",
body: { target: "pdf" },
});
await pollExport(r.jobId);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
exporting = false;
}
}
async function pollExport(jobId: string): Promise<void> {
for (;;) {
await new Promise((r) => setTimeout(r, 800));
let job: ExportJob;
try {
job = await api<ExportJob>(`/database/api/exports/${jobId}`);
} catch (e) {
exporting = false;
toastErr(e instanceof Error ? e.message : String(e));
return;
}
exportJob = job;
if (job.status === "DONE") {
exporting = false;
toastOk("导出完成,开始下载");
triggerDownload(`/database/api/exports/${job.id}/download`);
return;
}
if (job.status === "FAILED") {
exporting = false;
toastErr(`导出失败:${job.error ?? "未知原因"}`);
return;
}
}
}
function triggerDownload(url: string): void {
const a = document.createElement("a");
a.href = url;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
}
</script>
<div class="panel">
<div class="mb-4">
<div class="mb-1.5 text-[11.5px] text-ink-3">简介</div>
<div class="text-[13.5px] leading-7 text-ink">
{#if node.description}
{node.description}
{:else}
<span class="italic text-ink-3">暂无简介</span>
{/if}
{#if canEdit}
<button class="btn ml-2.5 !px-2.5 !py-0.5 align-middle !text-[11.5px]" onclick={openEditDesc}>编辑</button>
{/if}
</div>
</div>
<div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-col gap-1.5 text-[13px] text-ink-2">
<div>类型 <b class="font-semibold text-ink">{node.kind === "PROJECT" ? "项目" : "文件夹"}</b></div>
<div>我的角色 <b class="font-semibold text-ink">{roleLabel}</b></div>
<div>创建时间 <b class="font-semibold text-ink">{new Date(node.createdAt).toLocaleString("zh-CN")}</b></div>
<div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
</div>
<!-- 导出只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
{#if node.kind === "PROJECT"}
<div class="my-4 border-t border-line-soft"></div>
<div class="section-title mb-2">导出</div>
<div class="flex items-center gap-2">
<button class="btn" onclick={exportPdf} disabled={exporting}>
<Icon name="download" size={13} />
{exporting ? "导出中…" : "导出 PDF"}
</button>
{#if exportJob?.status === "DONE"}
<span class="file-meta">
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download" download>重新下载</a>
</span>
{:else if exportJob?.status === "FAILED"}
<span class="file-meta text-danger">失败:{exportJob.error ?? "未知原因"}</span>
{/if}
</div>
{/if}
{#if canManage}
<div class="my-4 border-t border-line-soft"></div>
<div class="flex items-center justify-between">
<span class="quiet">删除后移入回收站,可恢复</span>
<button class="btn btn-danger disabled:opacity-50" onclick={deleteNode} disabled={deleting}>
{deleting ? "删除中…" : `删除此${node.kind === "PROJECT" ? "项目" : "文件夹"}`}
</button>
</div>
{/if}
</div>
{#if showEditDesc}
<Modal title="编辑简介" onclose={() => (showEditDesc = false)}>
<div class="form-row">
<label class="form-label" for="desc-draft">简要说明这个项目的内容</label>
<textarea id="desc-draft" rows="5" class="input !leading-7" bind:value={descDraft} placeholder="例如:高中物理必修一第三章,表面张力相关内容……"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showEditDesc = false)}>取消</button>
<button class="btn btn-primary" onclick={saveDesc}>保存</button>
</div>
</Modal>
{/if}
-11
View File
@@ -1,11 +0,0 @@
<script lang="ts">
import { toasts } from "./stores.js";
</script>
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{#each $toasts as t (t.id)}
<div class="max-w-[340px] rounded-lg px-4 py-2 text-sm text-white {t.kind === 'err' ? 'bg-[#7E2C26]' : 'bg-[#333230]'}">
{t.message}
</div>
{/each}
</div>
-83
View File
@@ -1,83 +0,0 @@
<script lang="ts">
import TreeNode from "./TreeNode.svelte";
import { api } from "./api.js";
import { expanded, currentNode, breadcrumb, toggleExpanded, treeVersion } from "./browser.js";
import { toastErr } from "./stores.js";
import { ROLE_LABEL } from "./labels.js";
import type { BreadcrumbEntry, NodeChild, NodeDetail } from "./types.js";
let { node, depth }: { node: NodeChild; depth: number } = $props();
let children = $state<NodeChild[] | null>(null);
const isOpen = $derived($expanded.has(node.id));
const isSelected = $derived($currentNode?.id === node.id);
// 树刷新信号(增/删/移/重命名)→ 失效子节点缓存,展开状态下随之重载
$effect(() => {
void $treeVersion;
children = null;
});
$effect(() => {
if (isOpen && node.kind === "FOLDER" && children === null) {
api<{ nodes: NodeChild[] }>(`/database/api/nodes?parentId=${encodeURIComponent(node.id)}`)
.then((r) => (children = r.nodes))
.catch((e) => toastErr(e instanceof Error ? e.message : String(e)));
}
});
async function select(): Promise<void> {
if (node.kind === "FOLDER") toggleExpanded(node.id);
try {
const [detail, crumb] = await Promise.all([
api<{ node: NodeDetail }>(`/database/api/nodes/${node.id}`),
api<{ breadcrumb: BreadcrumbEntry[] }>(`/database/api/nodes/${node.id}/breadcrumb`),
]);
currentNode.set(detail.node);
breadcrumb.set(crumb.breadcrumb);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<div>
<div
class="tree-item flex cursor-pointer items-center gap-1 rounded-lg px-1.5 py-1.5 select-none {isSelected ? 'bg-selected' : 'hover:bg-hover'}"
role="button"
tabindex="0"
onclick={select}
onkeydown={(e) => e.key === "Enter" && select()}
>
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-ink-3">
{#if node.kind === "FOLDER"}
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
{#if isOpen}<path d="M6 9l6 6 6-6z" />{:else}<path d="M9 6l6 6-6 6z" />{/if}
</svg>
{/if}
</span>
<span class="flex h-4 w-4 shrink-0 items-center justify-center {node.kind === 'PROJECT' ? 'text-ink' : 'text-ink-3'}">
{#if node.kind === "PROJECT"}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
{/if}
</span>
<span class="truncate">{node.name}</span>
{#if node.role !== "MANAGE"}
<span class="ml-auto pr-1 text-[10px] text-ink-3">{ROLE_LABEL[node.role]}</span>
{/if}
</div>
{#if node.kind === "FOLDER" && isOpen}
<div class="ml-[15px] border-l border-guide pl-1">
{#if children === null}
<div class="px-3 py-1.5 text-xs text-ink-3"></div>
{:else}
{#each children as child (child.id)}
<TreeNode node={child} depth={depth + 1} />
{/each}
{/if}
</div>
{/if}
</div>
-49
View File
@@ -1,49 +0,0 @@
/** 与 /database/api/* 的约定一致;401 时清空会话(回到登录视图)。 */
import { me } from "./stores.js";
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "ApiError";
}
}
export class UnauthenticatedError extends Error {
constructor() {
super("unauthenticated");
this.name = "UnauthenticatedError";
}
}
interface RequestOpts {
readonly method?: string;
readonly body?: unknown;
}
export async function api<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
const res = await fetch(path, {
credentials: "same-origin",
method: opts.method ?? "GET",
...(opts.body !== undefined
? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts.body) }
: {}),
});
if (res.status === 401) {
me.set(null);
throw new UnauthenticatedError();
}
if (res.status === 204) return null as T;
const text = await res.text();
const data = text === "" ? null : (JSON.parse(text) as unknown);
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | null)?.error ?? {};
throw new ApiError(res.status, err.code ?? "unknown", err.message ?? res.statusText, err as Record<string, unknown>);
}
return data as T;
}
-79
View File
@@ -1,79 +0,0 @@
import { writable, get } from "svelte/store";
import type { BreadcrumbEntry, NodeDetail } from "./types.js";
const STORAGE_KEY = "filelib.browser";
interface PersistedState {
expanded: string[];
currentNodeId: string | null;
tab: string | null;
selectedFilePath: string | null;
}
function loadPersisted(): PersistedState {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw) as PersistedState;
} catch { /* ignore */ }
return { expanded: [], currentNodeId: null, tab: null, selectedFilePath: null };
}
function savePersisted(): void {
try {
const state: PersistedState = {
expanded: [...get(expanded)],
currentNodeId: get(currentNode)?.id ?? null,
tab: get(activeTab),
selectedFilePath: get(selectedFilePath),
};
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* sessionStorage 不可用时静默忽略 */ }
}
const persisted = loadPersisted();
/** 树展开集合 / 当前选中节点 / 面包屑 / 树刷新计数。 */
export const expanded = writable<Set<string>>(new Set(persisted.expanded));
export const currentNode = writable<NodeDetail | null>(null);
export const breadcrumb = writable<BreadcrumbEntry[]>([]);
export const treeVersion = writable(0);
/** 刷新后需要恢复的节点 ID;LibraryView onMount 消费后清空。 */
export const restoreNodeId = persisted.currentNodeId;
/** 刷新后需要恢复的 tab;NodeDetailPanel 消费。 */
export const restoreTab = persisted.tab;
/** 右侧预览栏:当前选中文件路径(项目内);切换节点时清空。 */
export const selectedFilePath = writable<string | null>(persisted.selectedFilePath);
/** 文件列表刷新计数(编辑器保存/删除后 bump,列表随之重载)。 */
export const filesVersion = writable(0);
/** 当前激活的 tab(由 NodeDetailPanel 写入,持久化用)。 */
export const activeTab = writable<string | null>(persisted.tab);
// 订阅需要持久化的 store,变化时写 sessionStorage。
expanded.subscribe(() => savePersisted());
currentNode.subscribe(() => savePersisted());
selectedFilePath.subscribe(() => savePersisted());
activeTab.subscribe(() => savePersisted());
export function bumpTree(): void {
treeVersion.update((v) => v + 1);
}
export function bumpFiles(): void {
filesVersion.update((v) => v + 1);
}
export function clearSelectedFile(): void {
selectedFilePath.set(null);
}
export function toggleExpanded(id: string): void {
expanded.update((set) => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
-25
View File
@@ -1,25 +0,0 @@
/**
* 前端 bootstrap:silo org slug(拼飞书 OAuth 链接用)+ dev 一键登录开关。
*
* 打 `/database/config` 而非 `/database/api/login-info`:后者由 teacherApp.ts 在
* silo org 查找成功之后才注册,org 缺失时整条链路不存在;前者在
* databaseRoutes.ts 顶部无条件注册。两者形状相同(见 src/database/README.md)。
*
* 免鉴权 —— org slug 本就出现在 OAuth URL 里,不构成敏感信息。
*/
import { api } from "./api.js";
export interface AppConfig {
readonly orgSlug: string;
readonly devLoginEnabled: boolean;
/** 单文件上传上限(字节)。后端 `HUB_FILELIB_MAX_FILE_BYTES` 的生效值。 */
readonly maxFileBytes: number;
}
let cached: AppConfig | null = null;
export async function loadConfig(): Promise<AppConfig> {
if (cached !== null) return cached;
cached = await api<AppConfig>("/database/config");
return cached;
}
-10
View File
@@ -1,10 +0,0 @@
/** 展示层文案(与 API 枚举值解耦;传参仍用英文枚举)。 */
import type { Role } from "./types.js";
/** 文件库权限级(契约 8.1 MANAGE>EDIT>VIEW)的中文展示名。 */
export const ROLE_LABEL: Record<Role, string> = {
VIEW: "只读",
EDIT: "可编辑",
MANAGE: "可管理",
};
-34
View File
@@ -1,34 +0,0 @@
/**
* 会话装载:GET /database/api/me 一次,结果进 `me` store。
* 老师端与管理后台共用 —— 两处的区别只是拿到 me 之后怎么用
* (老师端未登录显示登录视图;管理后台未登录跳 /database/admin,
* 非 isWebsiteAdmin 显示无权提示)。
*/
import { get } from "svelte/store";
import { api, UnauthenticatedError } from "./api.js";
import { me, authChecked } from "./stores.js";
import type { MeResponse } from "./types.js";
/** 幂等:已检查过就不再打请求(路由间切换不重复拉取)。 */
export async function loadSession(force = false): Promise<void> {
if (get(authChecked) && !force) return;
try {
me.set(await api<MeResponse>("/database/api/me"));
} catch (e) {
if (!(e instanceof UnauthenticatedError)) console.error(e);
me.set(null);
} finally {
authChecked.set(true);
}
}
/** 退出登录:清后端 cookie 再清前端 store。 */
export async function logout(): Promise<void> {
try {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
} catch {
/* 网络失败也照样清前端状态 */
}
me.set(null);
}
-26
View File
@@ -1,26 +0,0 @@
import { writable } from "svelte/store";
import type { MeResponse } from "./types.js";
/** 当前登录身份;null = 未登录(显示登录视图)。 */
export const me = writable<MeResponse | null>(null);
export const authChecked = writable(false);
export interface ToastItem {
readonly id: number;
readonly message: string;
readonly kind: "info" | "err";
}
let nextToastId = 1;
export const toasts = writable<ToastItem[]>([]);
export function toast(message: string, kind: ToastItem["kind"] = "info"): void {
const id = nextToastId++;
toasts.update((list) => [...list, { id, message, kind }]);
setTimeout(() => {
toasts.update((list) => list.filter((t) => t.id !== id));
}, 3600);
}
export const toastOk = (m: string): void => toast(m, "info");
export const toastErr = (m: string): void => toast(m, "err");
-178
View File
@@ -1,178 +0,0 @@
/** 与后端 /database/api/* 响应形状对齐。 */
export type NodeKind = "FOLDER" | "PROJECT";
export type Role = "VIEW" | "EDIT" | "MANAGE";
export interface NodeChild {
readonly id: string;
readonly parentId: string | null;
readonly kind: NodeKind;
readonly name: string;
readonly role: Role;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface BreadcrumbEntry {
readonly depth: number;
readonly id: string | null;
readonly name: string | null;
readonly kind: NodeKind;
/** 该节点对调用者的 effective role;无 View 为 null。 */
readonly role: Role | null;
}
export interface NodeDetail {
readonly id: string;
readonly parentId: string | null;
readonly kind: NodeKind;
readonly name: string;
readonly description: string | null;
readonly role: Role;
readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED";
readonly createdAt: string;
readonly updatedAt: string;
}
export interface MeResponse {
readonly userId: string;
readonly isWebsiteAdmin: boolean;
/** 侧栏身份区显示用;后端取不到 User 行时回落为 userId。 */
readonly displayName: string;
readonly avatarUrl: string | null;
}
export interface FileEntry {
readonly path: string;
readonly size: number;
}
export type FileContentEncoding = "utf8" | "base64";
export interface FileContent {
readonly path: string;
readonly version: string;
readonly encoding: FileContentEncoding;
readonly content: string;
readonly size: number;
}
export interface VersionInfo {
readonly version: string;
readonly message: string;
readonly author?: string;
readonly committedAt: string;
}
/** 项目级提交历史条目(包含受影响文件路径)。 */
export interface ProjectCommitInfo {
readonly version: string;
readonly message: string;
readonly author?: string;
readonly committedAt: string;
readonly files: readonly string[];
}
export interface ExportJob {
readonly id: string;
readonly nodeId: string;
readonly target: string;
readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
readonly error: string | null;
readonly createdAt: string;
}
export interface GroupSearchResult {
readonly id: string;
readonly name: string;
readonly breadcrumb: string;
}
/** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */
export interface MemberGroupNode {
readonly id: string;
readonly parentId: string | null;
readonly name: string;
readonly description: string | null;
readonly depth: number;
readonly memberCount: number;
/** 软删标记(ADR-0028 决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。
* 仅在 ?includeArchived=1 时可能非 null。ISO 串(后端 JSON 序列化后不再是 Date)。 */
readonly archivedAt: string | null;
}
export interface MemberGroupMember {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
/** 加入本组时间;ISO 串。 */
readonly joinedAt: string;
}
/** 节点授权(GET /database/api/nodes/:id/grants)。 */
export interface Grant {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
/** 主体显示名(用户 displayName / 组 name);主体已删为 null,展示回落 principalId。 */
readonly principalName: string | null;
/** USER 主体的飞书 openId;GROUP 或主体已删为 null。 */
readonly principalOpenId: string | null;
readonly role: Role;
/** 创建者授权不可收回、不可改(契约 8.1)。 */
readonly isCreatorGrant: boolean;
readonly createdAt: string;
}
/** Group 选择器候选(GET /database/api/groups/search)。 */
export interface MemberGroupSearchResult {
readonly id: string;
readonly name: string;
/** 祖先链(根在前,自身在末),用 " / " 连接。 */
readonly breadcrumb: string;
}
/** 成员选择器候选(GET /database/api/users/search)。 */
export interface UserSearchResult {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
}
/** 回收站条目(GET /database/api/bin)。 */
export interface BinEntry {
readonly id: string;
readonly parentId: string | null;
readonly kind: NodeKind;
readonly name: string;
readonly deletedAt: string;
}
/** 管理后台概览统计(GET /database/api/stats)。 */
export interface DashboardStats { readonly folders: number;
readonly projects: number;
readonly files: number;
readonly grants: number;
readonly recent: ReadonlyArray<{
readonly action: string;
readonly actor: string;
readonly label: string;
/** ISO 串;后端 JSON 序列化后不再是 Date。 */
readonly when: string;
}>;
}
/** org 成员(GET /api/org/:slug/members);用户管理面板消费。 */
export type OrgRole = "OWNER" | "ADMIN" | "MEMBER";
export interface OrgMember {
readonly userId: string;
readonly feishuOpenId: string;
readonly displayName: string;
readonly avatarUrl: string | null;
readonly role: OrgRole;
readonly createdAt: string;
}
-12
View File
@@ -1,12 +0,0 @@
<script lang="ts">
import "../app.css";
import Toasts from "$lib/Toasts.svelte";
let { children } = $props();
</script>
<div class="h-full">
{@render children()}
</div>
<Toasts />
-7
View File
@@ -1,7 +0,0 @@
/**
* 纯 SPA:关掉 SSR 与预渲染,构建产物只有一个 fallback index.html
* (adapter-static + fallback,见 svelte.config.js),由 hub 后端在
* /app 与 /database/* 两个前缀下原样送出。
*/
export const ssr = false;
export const prerender = false;
-11
View File
@@ -1,11 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// 根路径不承载界面:老师端在 /app,管理后台在 /database。
onMount(() => {
void goto("/app", { replaceState: true });
});
</script>
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转中…</div>
@@ -1,71 +0,0 @@
<script lang="ts">
/** 老师端。未登录显示登录卡片;登录后是带左栏导航的文件库(ADR-0031)。 */
import { onMount } from "svelte";
import { me, authChecked } from "$lib/stores.js";
import { loadSession, logout } from "$lib/session.js";
import LoginView from "$lib/LoginView.svelte";
import GridLibraryView from "$lib/GridLibraryView.svelte";
import BinView from "$lib/BinView.svelte";
import Icon from "$lib/Icon.svelte";
onMount(loadSession);
type View = "library" | "bin";
let view = $state<View>("library");
const tabs: ReadonlyArray<readonly [View, string, "layers" | "trash"]> = [
["library", "文件库", "layers"],
["bin", "回收站", "trash"],
];
const initial = $derived(($me?.displayName ?? $me?.userId ?? "U").slice(0, 1).toUpperCase());
</script>
<svelte:head><title>教研数据库</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
{:else if $me}
<div class="flex h-full">
<!-- 左栏导航(ADR-0031) -->
<nav class="flex w-[240px] shrink-0 flex-col border-r border-line-soft bg-sidebar px-3 py-4">
<!-- 标题 -->
<div class="mb-3 px-1 text-[15px] font-bold text-ink">教研数据库</div>
<div class="mb-2 border-t border-line-soft"></div>
<!-- 导航项 -->
<div class="flex flex-1 flex-col gap-0.5">
{#each tabs as [id, label, icon] (id)}
<button
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] transition {view === id
? 'bg-selected font-semibold text-ink'
: 'text-ink-2 hover:bg-hover'}"
onclick={() => (view = id)}
>
<span class="text-ink-3"><Icon name={icon} size={15} /></span>
{label}
</button>
{/each}
</div>
<!-- 底部:用户身份 + 退出 -->
<div class="mt-auto flex items-center gap-2 border-t border-line-soft pt-3">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</span>
<span class="min-w-0 flex-1 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>
</nav>
{#if view === "library"}
<GridLibraryView />
{:else}
<BinView />
{/if}
</div>
{:else}
<LoginView />
{/if}
@@ -1,11 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// /database 本身不承载界面(与旧后端 /database/admin → dashboard 的跳转一致)。
onMount(() => {
void goto("/database/dashboard", { replaceState: true });
});
</script>
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转中…</div>
@@ -1,63 +0,0 @@
<script lang="ts">
/**
* 管理后台登录页(迁自后端 renderLoginPage)。
* 已登录直接跳 dashboard —— 与旧后端路由 /database/admin 的行为一致。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import { loadConfig, type AppConfig } from "$lib/config.js";
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
await loadSession();
if ($me !== null) {
void goto("/database/dashboard", { replaceState: true });
return;
}
try {
info = await loadConfig();
} catch {
loadFailed = true;
}
});
</script>
<svelte:head><title>Database Admin · 登录</title></svelte:head>
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[380px] rounded-2xl border border-line-soft bg-panel p-9 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<div class="text-center text-[26px] font-semibold text-ink">Database Admin</div>
<p class="mt-2.5 mb-8 text-center text-[13px] text-ink-3">使用飞书登录以管理数据库</p>
{#if !$authChecked}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{:else if info}
<a
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
>使用飞书登录</a>
{#if info.devLoginEnabled}
<div class="my-5 flex items-center gap-2.5 text-[11px] text-ink-3">
<span class="flex-1 border-t border-line-soft"></span>开发模式
<span class="flex-1 border-t border-line-soft"></span>
</div>
<a
href="/database/dev-login"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover"
>⚡ 一键登录管理员</a>
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
{:else if loadFailed}
<p class="text-center text-[12.5px] text-danger">无法加载登录配置,请稍后重试</p>
{:else}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{/if}
</div>
</div>
@@ -1,108 +0,0 @@
<script lang="ts">
/**
* 管理后台外壳(迁自后端 renderDashboard 的侧栏 + 身份区)。
*
* 与旧实现的区别:六个 tab 是真 URL 路由(/database/dashboard/library 等),
* 不再是 location.hash + display:none 切换 —— 刷新不丢位置,链接可分享。
*
* 权限门:未登录跳 /database/admin;登录但非 OWNER/ADMIN(isWebsiteAdmin)
* 显示无权提示。语义与 ADR-0028 一致 —— 管理面板要求 silo org 的 OWNER/ADMIN。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { me, authChecked } from "$lib/stores.js";
import { loadSession, logout } from "$lib/session.js";
import Avatar from "$lib/Avatar.svelte";
let { children } = $props();
const NAV = [
{ seg: "", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" },
{ seg: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" },
{ seg: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" },
{ seg: "groups", label: "Group 管理", icon: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75M23 21v-2a4 4 0 0 0-3-3.87" },
{ seg: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" },
{ seg: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" },
] as const;
const BASE = "/database/dashboard";
onMount(loadSession);
/**
* 未登录一律回登录页 —— 必须是 effect 而非 onMount 里的一次性判断:
* `logout()` 只清 store(它被老师端 /app 共用,那边 me=null 是终态而非跳转),
* 退出后这层壳会重新渲染成 me===null,若跳转只写在 onMount 就永远停在
* "跳转到登录页…"。
*/
$effect(() => {
if ($authChecked && $me === null) {
void goto("/database/admin", { replaceState: true });
}
});
function href(seg: string): string {
return seg === "" ? BASE : `${BASE}/${seg}`;
}
function isActive(seg: string): boolean {
const path = page.url.pathname.replace(/\/$/, "");
return seg === "" ? path === BASE : path === `${BASE}/${seg}`;
}
</script>
<svelte:head><title>Database Admin</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">加载中…</div>
{:else if $me === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转到登录页…</div>
{:else if !$me.isWebsiteAdmin}
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[420px] rounded-2xl border border-line-soft bg-panel p-9 text-center shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<h2 class="mb-2 text-lg font-semibold text-ink">无权访问管理后台</h2>
<p class="mb-6 text-[13px] text-ink-3">
当前账号不是本组织的所有者或管理员。普通老师请到文件库使用。
</p>
<a href="/app" class="btn btn-primary justify-center">前往文件库</a>
<button class="btn mt-3 w-full justify-center" onclick={logout}>退出登录</button>
</div>
</div>
{:else}
<div class="flex h-full">
<aside class="flex w-[240px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="border-b border-line-soft px-4 py-4">
<span class="text-[15px] font-semibold text-ink">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-0.5 p-2.5">
{#each NAV as item (item.seg)}
{@const active = isActive(item.seg)}
<a
href={href(item.seg)}
class="flex items-center gap-2.5 rounded-[10px] px-3.5 py-2 text-[13px] transition"
class:bg-selected={active}
class:text-ink={active}
class:font-semibold={active}
class:text-ink-3={!active}
class:hover:bg-hover={!active}
>
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={item.icon} /></svg>
{item.label}
</a>
{/each}
</nav>
<div class="m-2.5 flex items-center gap-2.5 border-t border-line-soft px-3 py-2.5">
<Avatar displayName={$me.displayName} userId={$me.userId} avatarUrl={$me.avatarUrl} size={26} />
<p class="min-w-0 flex-1 truncate text-[12.5px] text-ink" title={$me.userId}>{$me.displayName}</p>
<button class="btn !px-2.5 !py-[3px] !text-[11px]" onclick={logout}>退出</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col">
{@render children()}
</div>
</div>
{/if}
@@ -1,67 +0,0 @@
<script lang="ts">
/** 概览(迁自后端 renderDashboard 的统计卡片 + 最近活动)。数据走 GET /database/api/stats。 */
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import type { DashboardStats } from "$lib/types.js";
let stats = $state<DashboardStats | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
try {
stats = await api<DashboardStats>("/database/api/stats");
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
const cards = $derived([
{ label: "文件夹", value: stats?.folders },
{ label: "项目", value: stats?.projects },
{ label: "文件", value: stats?.files },
{ label: "活跃授权", value: stats?.grants },
]);
function fmtWhen(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN");
} catch {
return iso;
}
}
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">概览</h1>
<p class="mb-5 text-[11.5px] text-ink-3">文件库实时数据</p>
{#if error}
<div class="panel text-[12.5px] text-danger">{error}</div>
{:else}
<div class="grid grid-cols-4 gap-4">
{#each cards as card (card.label)}
<div class="panel !px-5 !py-[18px]">
<p class="text-[12.5px] text-ink-3">{card.label}</p>
<p class="mt-1.5 text-[28px] font-semibold text-ink">{card.value ?? "—"}</p>
</div>
{/each}
</div>
<div class="panel mt-[18px]">
<h2 class="mb-2 text-[13.5px] font-semibold text-ink">最近活动</h2>
{#if stats === null}
<div class="quiet py-6 text-center">加载中…</div>
{:else if stats.recent.length === 0}
<div class="quiet py-[26px] text-center">暂无文件库活动 · 到「文件库」里创建第一个文件夹吧</div>
{:else}
{#each stats.recent as row (row.action + row.when + row.label)}
<div class="flex items-center gap-3 border-t border-line-soft py-2.5 text-[13px]">
<span class="tag shrink-0">{row.action}</span>
<span class="truncate text-ink">{row.label}</span>
<span class="ml-auto shrink-0 text-[11.5px] text-ink-3">{row.actor} · {fmtWhen(row.when)}</span>
</div>
{/each}
{/if}
</div>
{/if}
</section>
@@ -1,10 +0,0 @@
<script lang="ts">
/** Group 管理 tab —— MemberGroup 嵌套树(ADR-0028)。
* 外框 padding/overflow 对齐旧 `#tab-groups`(padding:20px;overflow:hidden):
* 两栏各自内部滚动,外层不滚。 */
import GroupAdmin from "$lib/GroupAdmin.svelte";
</script>
<div class="min-h-0 flex-1 overflow-hidden p-5">
<GroupAdmin />
</div>
@@ -1,8 +0,0 @@
<script lang="ts">
/** 文件库 tab —— 与老师端 /app 同一个浏览器组件,区别只在侧栏身份区由外壳提供。 */
import LibraryView from "$lib/LibraryView.svelte";
</script>
<div class="flex min-h-0 flex-1 flex-col">
<LibraryView />
</div>
@@ -1,4 +0,0 @@
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">查询</h1>
<p class="text-[12.5px] text-ink-3">查询功能建设中</p>
</section>
@@ -1,4 +0,0 @@
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">设置</h1>
<p class="text-[12.5px] text-ink-3">设置功能建设中</p>
</section>
@@ -1,200 +0,0 @@
<script lang="ts">
/**
* 用户管理(迁自后端 adminPanels.ts renderUsersPanel)。
*
* 用户 = silo org 的成员,走平台层 /api/org/:slug/members(见 src/admin/routes/membersRoutes.ts)。
* 与 Group 管理是两套体系:MemberGroup 是全局主体、不归属 org(ADR-0028),
* 这里管的是 org 成员与其角色。
*/
import { onMount } from "svelte";
import { page } from "$app/state";
import { api } from "$lib/api.js";
import { loadConfig } from "$lib/config.js";
import { toastOk, toastErr } from "$lib/stores.js";
import type { OrgMember, OrgRole } from "$lib/types.js";
import Icon from "$lib/Icon.svelte";
const ROLE_LABEL: Record<OrgRole, string> = {
OWNER: "所有者",
ADMIN: "管理员",
MEMBER: "普通老师",
};
const ROLES: readonly OrgRole[] = ["OWNER", "ADMIN", "MEMBER"];
let orgSlug = $state<string | null>(null);
let members = $state<OrgMember[] | null>(null);
let error = $state<string | null>(null);
// 列表过滤;授权面板跳转会带 ?q=<userId>,以此为初始过滤词。
let filterText = $state(page.url.searchParams.get("q") ?? "");
let newOpenId = $state("");
let newName = $state("");
let newRole = $state<OrgRole>("MEMBER");
let adding = $state(false);
const base = $derived(orgSlug === null ? null : `/api/org/${encodeURIComponent(orgSlug)}`);
/** 按显示名 / userId / openId 过滤(纯前端;成员全量在手)。 */
const shown = $derived.by((): OrgMember[] | null => {
if (members === null) return null;
const q = filterText.trim().toLowerCase();
if (q === "") return members;
return members.filter(
(m) =>
m.displayName.toLowerCase().includes(q) ||
m.userId.toLowerCase().includes(q) ||
m.feishuOpenId.toLowerCase().includes(q),
);
});
async function load(): Promise<void> {
if (base === null) return;
try {
const r = await api<{ members: OrgMember[] }>(`${base}/members`);
members = r.members;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
onMount(async () => {
try {
orgSlug = (await loadConfig()).orgSlug;
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
async function addMember(): Promise<void> {
const feishuOpenId = newOpenId.trim();
if (feishuOpenId === "") {
toastErr("请填写用户 openId");
return;
}
if (base === null) return;
adding = true;
try {
const displayName = newName.trim();
await api(`${base}/members`, {
method: "POST",
body: { feishuOpenId, role: newRole, ...(displayName !== "" ? { displayName } : {}) },
});
newOpenId = "";
newName = "";
toastOk("已添加");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
} finally {
adding = false;
}
}
async function setRole(userId: string, role: string): Promise<void> {
if (base === null) return;
try {
await api(`${base}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: { role } });
toastOk("角色已更新");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
await load();
}
}
async function revoke(userId: string, displayName: string): Promise<void> {
if (base === null) return;
if (!confirm(`移除成员「${displayName || userId}」?`)) return;
try {
await api(`${base}/members/${encodeURIComponent(userId)}/revoke`, { method: "POST" });
toastOk("已移除");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-4 text-lg font-semibold text-ink">用户管理</h1>
<div class="max-w-[880px]">
<div class="panel mb-3.5">
<div class="section-title mb-2.5">添加成员</div>
<div class="flex flex-wrap items-center gap-2">
<input class="input min-w-0 flex-[2]" placeholder="用户 openId(飞书 ou_ 开头)" bind:value={newOpenId} />
<input class="input min-w-0 flex-1" placeholder="显示名(可选)" bind:value={newName} />
<select class="select !w-[130px]" bind:value={newRole}>
{#each ROLES as role (role)}
<option value={role}>{ROLE_LABEL[role]}</option>
{/each}
</select>
<button class="btn btn-primary disabled:opacity-50" onclick={addMember} disabled={adding}>
{adding ? "添加中…" : "添加"}
</button>
</div>
</div>
<div class="panel">
<div class="mb-2.5 flex items-center justify-between gap-2">
<div class="section-title">成员列表</div>
<div class="relative w-[260px] shrink-0">
<span class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input
class="input w-full !py-[5px] !pl-8 !text-[12.5px]"
placeholder="过滤:名称 / userId / openId"
bind:value={filterText}
/>
</div>
</div>
{#if error}
<div class="py-3 text-[12.5px] text-danger">{error}</div>
{:else if shown === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else if shown.length === 0}
<div class="quiet py-[18px] text-center">
{filterText.trim() === "" ? "暂无成员" : `无匹配「${filterText.trim()}」的成员`}
</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>角色</th>
<th></th>
</tr>
</thead>
<tbody>
{#each shown as m (m.userId)}
<tr>
<td class="text-ink">{m.displayName || m.userId}</td>
<td class="file-meta">{m.userId}</td>
<td>
<select
class="select !w-[110px] !px-2 !py-[3px] !text-xs"
value={m.role}
onchange={(e) => setRole(m.userId, e.currentTarget.value)}
>
{#each ROLES as role (role)}
<option value={role}>{ROLE_LABEL[role]}</option>
{/each}
</select>
</td>
<td class="text-right">
<button class="link-danger" onclick={() => revoke(m.userId, m.displayName)}>移除</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</section>
-35
View File
@@ -1,35 +0,0 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/**
* 老师端 /app 与管理后台 /database/* 共用这一份 SPA 构建产物,由 hub 后端静态托管
* (见 hub/src/database/static.ts)。服务端不渲染任何页面,只提供 /database/api/*。
*
* 两个关键配置:
*
* - `appDir: '_filelib'` —— 默认 `_app` 会与 admin-web 在同一个 Fastify 实例上注册的
* 根 `/_app/*` 资源路由撞车(见 hub/src/admin/static.ts),Fastify 重复路由会直接
* 在启动时抛错。改名后两套 SPA 的资源路径互不干扰。
*
* - `paths.relative: false` —— 同一份 index.html 会在不同深度的 URL 下被送出
* (`/app`、`/database/dashboard/users`),相对资源路径会解析错。必须用绝对路径。
*/
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
appDir: '_filelib',
paths: {
base: '',
relative: false,
},
},
};
export default config;
-17
View File
@@ -1,17 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}
}
-28
View File
@@ -1,28 +0,0 @@
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import wasm from "vite-plugin-wasm";
import { defineConfig } from "vite";
// 老师端 /app + 管理后台 /database/* 的唯一前端工程;构建产物由 hub 后端静态托管。
// 开发时 vite dev(:5173)把 API/认证/一键登录请求代理到后端(:8788);
// 页面路由全部由 SvelteKit 客户端路由处理,后端不参与。
const backend = "http://127.0.0.1:8788";
export default defineConfig({
plugins: [wasm(), tailwindcss(), sveltekit()],
server: {
port: 5173,
proxy: {
"/database/api": backend,
// 免鉴权 bootstrap(org slug + dev 开关);登录页和用户管理页都靠它。
"/database/config": backend,
"/auth": backend,
// 后端拥有的 DEV 一键登录端点(签 cookie 后 302);不代理会被 SPA 回退吃掉。
"/database/dev-login": backend,
"/app/dev-login": backend,
"/app/dev-login-teacher": backend,
// 平台层 org 成员 API(用户管理面板)。
"/api/org": backend,
},
},
});
+12 -207
View File
@@ -1,19 +1,18 @@
{
"name": "@paradigm/hub",
"version": "0.0.36",
"version": "0.0.35",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.36",
"version": "0.0.35",
"dependencies": {
"@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15",
"@alicloud/tea-util": "^1.4.11",
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^10.1.2",
"@larksuiteoapi/node-sdk": "^1.70.0",
"@prisma/client": "^6.19.3",
"ai": "^7.0.16",
@@ -903,22 +902,6 @@
"node": ">=18"
}
},
"node_modules/@fastify/accept-negotiator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
"integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -1050,83 +1033,6 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
"integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "^2.0.0",
"mime": "^3"
}
},
"node_modules/@fastify/static": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz",
"integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/error": "^4.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^2.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
},
"node_modules/@fastify/static/node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@fastify/static/node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -1162,15 +1068,6 @@
"ws": "^8.19.0"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -2037,15 +1934,6 @@
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -2085,18 +1973,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2378,6 +2254,7 @@
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -2570,7 +2447,8 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/estree-walker": {
"version": "3.0.3",
@@ -3069,23 +2947,6 @@
"giget": "dist/cli.mjs"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -3152,6 +3013,7 @@
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
@@ -3234,7 +3096,8 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
"license": "ISC",
"peer": true
},
"node_modules/ini": {
"version": "1.3.8",
@@ -3683,15 +3546,6 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -3734,18 +3588,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -3767,30 +3609,6 @@
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -3974,22 +3792,6 @@
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -4568,7 +4370,8 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
"license": "ISC",
"peer": true
},
"node_modules/shebang-command": {
"version": "2.0.0",
@@ -4767,6 +4570,7 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -4854,6 +4658,7 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.6"
}
+4 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.36",
"version": "0.0.35",
"private": true,
"type": "module",
"engines": {
@@ -12,7 +12,6 @@
"@alicloud/tea-util": "^1.4.11",
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^10.1.2",
"@larksuiteoapi/node-sdk": "^1.70.0",
"@prisma/client": "^6.19.3",
"ai": "^7.0.16",
@@ -31,10 +30,10 @@
"axios": "1.18.1"
}
},
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).",
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.",
"scripts": {
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json && npm run admin:build && npm run filelib:build",
"build": "tsc -p tsconfig.json && npm run admin:build",
"start": "npm run prisma:migrate && node dist/server.js",
"check": "tsc -p tsconfig.json --noEmit",
"audit:production": "npm audit --omit=dev --audit-level=high",
@@ -49,8 +48,6 @@
"test": "vitest run",
"test:watch": "vitest",
"admin:dev": "npm run dev --prefix admin-web",
"admin:build": "npm run build --prefix admin-web",
"filelib:dev": "npm run dev --prefix filelib-web",
"filelib:build": "npm run build --prefix filelib-web"
"admin:build": "npm run build --prefix admin-web"
}
}
@@ -1,6 +1,6 @@
-- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER}
-- model: the platform administration control plane is a separate identity/session/
-- audit surface, intentionally not built
-- audit surface (see `Spec.System.PlatformAdministration`), intentionally not built
-- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime
-- reader — no guard, route, or service queries it for an authorization decision —
-- and ADR-0023 requires it to be migrated/replaced before the platform panel ships.
@@ -1,92 +0,0 @@
-- File library (文件库) — 语义锚定:仓库根《文件库-接口契约.md》、.omo/文件库-开工计划.md (D11D19)。
-- 本地无 PG 时手写;有 PG 后可用 `prisma migrate diff` 核对与 schema 的一致性。
-- CreateEnum
CREATE TYPE "FileLibNodeKind" AS ENUM ('FOLDER', 'PROJECT');
CREATE TYPE "FileLibProvisionStatus" AS ENUM ('PROVISIONING', 'READY', 'FAILED');
CREATE TYPE "FileLibRole" AS ENUM ('VIEW', 'EDIT', 'MANAGE');
CREATE TYPE "FileLibPrincipalType" AS ENUM ('USER', 'GROUP');
CREATE TYPE "FileLibExportStatus" AS ENUM ('QUEUED', 'RUNNING', 'DONE', 'FAILED');
-- CreateTable
CREATE TABLE "FileLibNode" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
"kind" "FileLibNodeKind" NOT NULL,
"name" TEXT NOT NULL,
"nameLower" TEXT NOT NULL,
"pathIds" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"provisionStatus" "FileLibProvisionStatus" NOT NULL DEFAULT 'READY',
"storageDir" TEXT,
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FileLibNode_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FileLibGrant" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"principalType" "FileLibPrincipalType" NOT NULL,
"principalId" TEXT NOT NULL,
"role" "FileLibRole" NOT NULL,
"isCreatorGrant" BOOLEAN NOT NULL DEFAULT false,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "FileLibGrant_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FileLibProjectSettings" (
"nodeId" TEXT NOT NULL,
"independentPermissionsEnabled" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "FileLibProjectSettings_pkey" PRIMARY KEY ("nodeId")
);
CREATE TABLE "FileLibExportJob" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"target" TEXT NOT NULL,
"params" JSONB NOT NULL,
"status" "FileLibExportStatus" NOT NULL DEFAULT 'QUEUED',
"downloadUrl" TEXT,
"error" TEXT,
"createdByUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FileLibExportJob_pkey" PRIMARY KEY ("id")
);
-- CreateIndex (schema-declared)
CREATE INDEX "FileLibNode_organizationId_parentId_deletedAt_idx" ON "FileLibNode"("organizationId", "parentId", "deletedAt");
CREATE INDEX "FileLibNode_organizationId_pathIds_idx" ON "FileLibNode"("organizationId", "pathIds");
CREATE INDEX "FileLibNode_creatorId_idx" ON "FileLibNode"("creatorId");
CREATE INDEX "FileLibGrant_organizationId_revokedAt_idx" ON "FileLibGrant"("organizationId", "revokedAt");
CREATE INDEX "FileLibGrant_principalType_principalId_revokedAt_idx" ON "FileLibGrant"("principalType", "principalId", "revokedAt");
CREATE INDEX "FileLibGrant_nodeId_revokedAt_idx" ON "FileLibGrant"("nodeId", "revokedAt");
CREATE INDEX "FileLibExportJob_organizationId_status_idx" ON "FileLibExportJob"("organizationId", "status");
-- D14:活跃兄弟节点大小写不敏感唯一。root 的 parentId 为 NULL,用 COALESCE 归入同一键空间。
CREATE UNIQUE INDEX "FileLibNode_active_sibling_name_key"
ON "FileLibNode"("organizationId", COALESCE("parentId", ''), "nameLower")
WHERE "deletedAt" IS NULL;
-- 契约 2.3:同一节点上同一 principal 至多一条活跃授权(Postgres 原生 UNIQUE 无法约束 NULL revokedAt)。
CREATE UNIQUE INDEX "FileLibGrant_active_unique"
ON "FileLibGrant"("nodeId", "principalType", "principalId")
WHERE "revokedAt" IS NULL;
-- AddForeignKey
ALTER TABLE "FileLibNode" ADD CONSTRAINT "FileLibNode_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileLibNode" ADD CONSTRAINT "FileLibNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileLibNode"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "FileLibGrant" ADD CONSTRAINT "FileLibGrant_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileLibProjectSettings" ADD CONSTRAINT "FileLibProjectSettings_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileLibExportJob" ADD CONSTRAINT "FileLibExportJob_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,70 +0,0 @@
-- Global, unlimited-depth member group hierarchy (requirement 3.1-3.3).
-- Managed only by the platform super administrator; deliberately NOT
-- org-scoped. Stores membership + nesting only, never permission data.
-- Deletion is soft (archivedAt / revokedAt markers); a group delete
-- cascade-soft-deletes its whole subtree as an application operation.
-- The permission side (GROUP principal, FOLDER resource, grant inheritance)
-- is intentionally deferred to a later migration.
-- CreateTable
CREATE TABLE "MemberGroup" (
"id" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "MemberGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MemberGroupMembership" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "MemberGroupMembership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MemberGroupClosure" (
"ancestorId" TEXT NOT NULL,
"descendantId" TEXT NOT NULL,
"depth" INTEGER NOT NULL,
CONSTRAINT "MemberGroupClosure_pkey" PRIMARY KEY ("ancestorId", "descendantId")
);
-- CreateIndex
CREATE INDEX "MemberGroup_parentId_archivedAt_idx" ON "MemberGroup"("parentId", "archivedAt");
-- CreateIndex
CREATE INDEX "MemberGroupMembership_userId_revokedAt_idx" ON "MemberGroupMembership"("userId", "revokedAt");
-- CreateIndex
CREATE INDEX "MemberGroupMembership_groupId_revokedAt_idx" ON "MemberGroupMembership"("groupId", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "MemberGroupMembership_groupId_userId_revokedAt_key" ON "MemberGroupMembership"("groupId", "userId", "revokedAt");
-- CreateIndex
CREATE INDEX "MemberGroupClosure_descendantId_idx" ON "MemberGroupClosure"("descendantId");
-- AddForeignKey
ALTER TABLE "MemberGroup" ADD CONSTRAINT "MemberGroup_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "MemberGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_ancestorId_fkey" FOREIGN KEY ("ancestorId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_descendantId_fkey" FOREIGN KEY ("descendantId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- 为 FileLibNode 加简介字段,老师在创建/概览页填写。
ALTER TABLE "FileLibNode" ADD COLUMN "description" TEXT;
@@ -1,65 +0,0 @@
-- DropIndex
DROP INDEX "ProjectSearchDocument_normalizedBreadcrumb_trgm_idx";
-- DropIndex
DROP INDEX "ProjectSearchDocument_normalizedCode_trgm_idx";
-- DropIndex
DROP INDEX "ProjectSearchDocument_normalizedName_trgm_idx";
-- DropIndex
DROP INDEX "ProjectSearchDocument_normalizedSearchText_trgm_idx";
-- DropIndex
DROP INDEX "Team_archivedAt_idx";
-- AlterTable
ALTER TABLE "ExternalDirectoryConnection" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "Organization" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "ProjectSearchDocument" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- CreateTable
CREATE TABLE "FileLibRecentVisit" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"filePath" TEXT NOT NULL DEFAULT '',
"openedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FileLibRecentVisit_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "FileLibRecentVisit_organizationId_userId_openedAt_idx" ON "FileLibRecentVisit"("organizationId", "userId", "openedAt");
-- CreateIndex
CREATE UNIQUE INDEX "FileLibRecentVisit_organizationId_userId_nodeId_filePath_key" ON "FileLibRecentVisit"("organizationId", "userId", "nodeId", "filePath");
-- RenameForeignKey
ALTER TABLE "OrganizationFeishuApplicationConnection" RENAME CONSTRAINT "OrganizationFeishuApplicationConnection_activeSecretVersionId_f" TO "OrganizationFeishuApplicationConnection_activeSecretVersio_fkey";
-- RenameIndex
ALTER INDEX "ExternalPrincipalMembership_principalType_principalId_revokedAt" RENAME TO "ExternalPrincipalMembership_principalType_principalId_revok_idx";
-- RenameIndex
ALTER INDEX "ExternalPrincipalMembership_userId_principalType_principalId_co" RENAME TO "ExternalPrincipalMembership_userId_principalType_principalI_key";
-- RenameIndex
ALTER INDEX "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortOrder" RENAME TO "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortO_idx";
-- RenameIndex
ALTER INDEX "OrganizationCapabilityConnection_organizationId_capabilityId_ke" RENAME TO "OrganizationCapabilityConnection_organizationId_capabilityI_key";
-- RenameIndex
ALTER INDEX "OrganizationFeishuApplicationConnection_activeSecretVersionId_k" RENAME TO "OrganizationFeishuApplicationConnection_activeSecretVersion_key";
-- RenameIndex
ALTER INDEX "OrganizationFeishuApplicationConnection_appIdentityFingerprint_" RENAME TO "OrganizationFeishuApplicationConnection_appIdentityFingerpr_key";
-- RenameIndex
ALTER INDEX "TeamExternalBinding_teamId_principalType_principalId_revokedAt_" RENAME TO "TeamExternalBinding_teamId_principalType_principalId_revoke_key";
@@ -1,2 +0,0 @@
-- ADR-0032:最近打开模块移除,删表(今日新建,无生产数据)。
DROP TABLE "FileLibRecentVisit";
+4 -191
View File
@@ -1,6 +1,6 @@
// Prisma schema for Curriculum Project Hub.
//
// Aligns to ADR-0001..0004, 0017. Key divergences from the
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// legacy teaching-material-host-service schema, each deliberate:
//
// - AgentSession is provider/model-bound. Provider runtime cursors such as
@@ -11,8 +11,8 @@
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
// user/chat targets into one binding table.
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (the run-state set is
// open — add states without a schema migration war).
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
// completeness OPEN — add states without a schema migration war).
generator client {
provider = "prisma-client-js"
@@ -50,7 +50,6 @@ model Organization {
projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
fileLibNodes FileLibNode[]
@@index([status])
}
@@ -62,7 +61,7 @@ enum OrganizationStatus {
}
/// Org-scoped membership role. Distinct from project PermissionRole and from
/// the platform administrator surface (ADR-0023),
/// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration),
/// which is a separate control plane not modeled in alpha (ADR-0025).
model OrganizationMembership {
id String @id @default(cuid())
@@ -194,7 +193,6 @@ model User {
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[]
memberGroupMemberships MemberGroupMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
@@ -395,67 +393,6 @@ model TeamExternalBinding {
@@index([teamId, revokedAt])
}
// --- Member groups (global, nestable authorization principal) ------------
/// Global, unlimited-depth member group. Managed only by the platform super administrator (ADR-0023);
/// Deletion is soft: `archivedAt` is a marker.
/// Deleting a group cascade-soft-deletes its whole subtree — an application
/// operation (walk the subtree, stamp archivedAt), not a DB constraint.
model MemberGroup {
id String @id @default(cuid())
parentId String?
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
parent MemberGroup? @relation("groupTree", fields: [parentId], references: [id], onDelete: Restrict)
children MemberGroup[] @relation("groupTree")
memberships MemberGroupMembership[]
asAncestor MemberGroupClosure[] @relation("ancestor")
asDescendant MemberGroupClosure[] @relation("descendant")
@@index([parentId, archivedAt])
}
/// User↔group many-to-many; a user may belong to multiple groups. Soft delete
/// via `revokedAt` (same pattern as TeamMembership) allows re-adding a removed
/// member. `userId, revokedAt` index is the resolution hot path: fetch a user's direct groups.
model MemberGroupMembership {
id String @id @default(cuid())
groupId String
userId String
createdAt DateTime @default(now())
revokedAt DateTime?
group MemberGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([groupId, userId, revokedAt])
@@index([userId, revokedAt])
@@index([groupId, revokedAt])
}
/// Transitive closure of the MemberGroup tree. Every group has a depth=0
/// self row. Turns ancestor/descendant resolution into one indexed join
/// instead of a recursive CTE; maintained only on create / reparent (rare
/// super-admin ops, so the write cost is amortized against hot reads). On soft
/// delete the closure rows are retained; resolution filters by
/// MemberGroup.archivedAt. Reparent must reject a new parent inside the moved
/// subtree (cycle guard).
model MemberGroupClosure {
ancestorId String
descendantId String
depth Int
ancestor MemberGroup @relation("ancestor", fields: [ancestorId], references: [id], onDelete: Cascade)
descendant MemberGroup @relation("descendant", fields: [descendantId], references: [id], onDelete: Cascade)
@@id([ancestorId, descendantId])
@@index([descendantId])
}
/// Locally synchronized Feishu external principal membership.
model ExternalDirectoryConnection {
id String @id @default(cuid())
@@ -986,127 +923,3 @@ model CapabilityCredentialVersion {
@@index([keyId])
@@index([createdByUserId])
}
// --- File library (文件库) -------------------------------------------------
//
// 独立模块,语义由仓库根《文件库-接口契约.md》(C/D 编号)与 .omo/文件库-开工计划.md
// (D11D19)锚定。与上面的 Folder/Project(hub 自己的 explorer,ADR-0021)是两套
// 体系,不复用、不互相引用。
/// 文件库目录树节点:文件夹(容器)或项目(叶子,关联 git 仓库)。
/// parentId 是树的权威关系;pathIds 是 id 编码的物化路径(派生),随 create/move
/// 在事务内维护(计划 D12;name 不入路径,rename 不重写后代)。
model FileLibNode {
id String @id
organizationId String
parentId String?
kind FileLibNodeKind
name String
/// D14:NFC+trim 的小写形式;活跃兄弟节点大小写不敏感唯一(部分唯一索引在迁移 SQL)。
nameLower String
/// id 编码物化路径,形如 "/rootId/childId/selfId"。祖先展开与前缀查询都用它。
pathIds String
/// D11:创建者不可变,自动持有 isCreatorGrant=true 的 MANAGE grant。
/// 故意不建 FK:这是不可变历史事实,不随 User 生命周期变化。
creatorId String
/// 老师可填写的简介,创建/概览页展示,通俗易懂地说明这个节点的用途。
description String?
/// 项目 provisioning 状态机(DB/git 双写协调,Metis 风险#1);文件夹恒 READY。
provisionStatus FileLibProvisionStatus @default(READY)
/// 项目仓库目录(绝对路径);文件夹为 null。
storageDir String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent FileLibNode? @relation("fileLibTree", fields: [parentId], references: [id], onDelete: Restrict)
children FileLibNode[] @relation("fileLibTree")
grants FileLibGrant[]
projectSettings FileLibProjectSettings?
exportJobs FileLibExportJob[]
@@index([organizationId, parentId, deletedAt])
@@index([organizationId, pathIds])
@@index([creatorId])
}
enum FileLibNodeKind {
FOLDER
PROJECT
}
enum FileLibProvisionStatus {
PROVISIONING
READY
FAILED
}
/// 文件库权限级别:MANAGE > EDIT > VIEW(契约 2.2,只取最高、无降权)。
enum FileLibRole {
VIEW
EDIT
MANAGE
}
enum FileLibPrincipalType {
USER
GROUP
}
/// 契约 2.3:grant 直接挂在节点上,最终权限 = max(个人, 递归 Group, 祖先继承)。
/// 活跃授权唯一性由迁移里的部分唯一索引保证(revokedAt IS NULL)。
model FileLibGrant {
id String @id @default(cuid())
organizationId String
nodeId String
principalType FileLibPrincipalType
principalId String
role FileLibRole
/// D11:创建者自动 grant;独立权限开关关闭时,项目级 grant 里只有它仍生效。
isCreatorGrant Boolean @default(false)
createdByUserId String?
createdAt DateTime @default(now())
revokedAt DateTime?
node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@index([organizationId, revokedAt])
@@index([principalType, principalId, revokedAt])
@@index([nodeId, revokedAt])
}
/// 契约 P5/D11:项目独立权限开关。默认关闭(仅继承);关闭时项目级非创建者
/// grant 冻结不删除,重新开启即恢复。
model FileLibProjectSettings {
nodeId String @id
independentPermissionsEnabled Boolean @default(false)
node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
}
enum FileLibExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
/// 契约 D10:导出为异步任务。外部导出工具参数 OPEN-6,adapter 就位前先建模型。
model FileLibExportJob {
id String @id @default(cuid())
organizationId String
nodeId String
target String
params Json
status FileLibExportStatus @default(QUEUED)
downloadUrl String?
error String?
createdByUserId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@index([organizationId, status])
}
+3 -24
View File
@@ -268,30 +268,9 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
}
});
// 退出登录不读 body,但调用方(curl -d、Postman、部分 HTTP 客户端)常给空
// POST 自动带上 Content-Type。Fastify 默认只有 JSON parser,遇到别的媒体类型
// 会在解析阶段以 415 拒掉,进不到 handler —— 对一个"无输入"的端点没有意义。
//
// 这里用 register 起一个封装作用域,catch-all parser 只在其中生效。
// **不要**把 parser 加到外层 app 上:admin plugin 没有 fastify-plugin 封装,
// 那样会让全站每个 POST/PUT/PATCH 都接受 form-urlencoded。而 form-urlencoded
// 是跨站 HTML form 唯一能发出的媒体类型(application/json 会触发 CORS
// preflight),"只认 JSON"本身是一层 CSRF 纵深防御,不能为了这个端点全局放掉。
await app.register(async (scope) => {
// parseAs:"string" 让 Fastify 负责读完流(否则连接不释放),这里直接丢掉内容
// —— 该端点不接受任何输入。
// "*" 只兜没有专属 parser 的媒体类型;内建 JSON parser 优先级更高,空 body
// 会被它判成 FST_ERR_CTP_EMPTY_JSON_BODY(400),所以要在本作用域内覆盖掉。
for (const mediaType of ["*", "application/json"]) {
scope.addContentTypeParser(mediaType, { parseAs: "string" }, (_request, _body, done) => {
done(null, undefined);
});
}
scope.post("/auth/logout", async (_request, reply) => {
reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
return reply.status(204).send();
});
app.post("/auth/logout", async (_request, reply) => {
reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
return reply.status(204).send();
});
app.get("/auth/feishu/complete", async (request, reply) => {
+3 -3
View File
@@ -24,10 +24,10 @@
* denied by default and re-opened only for the workspace plus named system
* runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The
* subprocess gets a minimal environment and SDK credential protection removes
* provider secrets from Bash. This upholds the workspace-bounded file-op
* invariant (ADR-0018) without re-implementing the
* provider secrets from Bash. This upholds `AgentFileOp.Authorized`
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the ADR pins the invariant.
* is the mechanism, the contract pins the invariant.
*/
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* ADR-0022 capacity dimensions.
* The 23 pinned dimensions; exact numeric ceilings are open and calibrated by
* ADR-0022 capacity dimensions (spec `Spec.System.Capacity.CapacityDimension`).
* The 23 PINNED dimensions; exact numeric ceilings are `OPEN` and calibrated by
* capacity testing. This module is the single source of the dimension set shared
* by the platform-ceiling config and the org capacity-policy service.
*/
-169
View File
@@ -1,169 +0,0 @@
# src/database/
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
所以服务器启动时能正确识别这些路由。
**前后端分离**:页面全部在 SvelteKit 静态 SPA `hub/filelib-web/`(与 `hub/admin-web/`
同一套框架)。**老师端 `/app` 与管理后台 `/database` 共用这一份工程和这一份构建产物** ——
两个挂载前缀,一个 SPA。本目录的后端只保留三件事:鉴权透传、JSON 数据端点、
以及把构建产物托管出去。服务端不渲染任何 HTML。
后端路由:
- `GET /database/config` —— 免鉴权。返回 `{ orgSlug, devLoginEnabled }`
给 SPA 登录页拼飞书链接、决定是否显示 dev 按钮用。不含任何敏感数据。
`/database/api/login-info` 是同形状的既有端点,由 `routes/teacherApp.ts` 注册。)
- `GET /database/api/stats` —— 概览页统计。需登录 **且** 是 silo org OWNER/ADMIN。
- `GET /database/dev-login` —— 仅开发。见下。
- `GET /database``GET /database/*``GET /app``GET /app/*` —— SPA shell /
客户端路由 fallback`static.ts``registerDatabaseSpa`)。
- `GET /_filelib/*` —— 构建产物资源。SvelteKit 的 `appDir` 改名为 `_filelib`
以避开 `admin-web` 在根上注册的 `/_app/*`(同名会让 Fastify 启动即抛重复路由)。
SPA 页面(`filelib-web`,真 URL 路由、无 hash):
- `/app` —— 老师端文件库。未登录显示登录卡片。
- `/database/admin` —— 管理员飞书登录页。按钮指向 `/auth/feishu/<orgSlug>`
回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。
- `/database/dashboard` —— 后台外壳(侧栏 + 权限门)。未登录跳登录页;
**登录但非 OWNER/ADMIN 显示无权提示**。六个 tab 都是子路由:
`/database/dashboard`(概览)、`/library``/users``/groups``/search``/settings`
> **注册顺序要点**concrete 路由(`/database/config`、`/database/api/*`、
> `/database/dev-login`、`/app/dev-login-teacher`)必须在 `registerDatabaseSpa` 的
> `/database/*`、`/app/*` fallback 之前注册(已在 `plugin.ts` 保证),
> 否则通配会 shadow 它们。
## 开发模式:用环境变量开启一键登录
本地开发没有真实飞书 app 时,可以用环境变量开启一键登录,跳过飞书 OAuth,
直接以现有 OWNER/ADMIN 身份登入后台。**仅限开发,不是生产登录路径。**
### 怎么开
`hub/.env` 里设:
```sh
HUB_DEV_LOGIN_BYPASS="true"
```
改完重启服务(`npm run dev`,或本地手动 `npx tsx src/server.ts`)。启动日志会
打印一行 `DEV login bypass enabled: /database/dev-login ...` 作为确认。
开启后:
- `/database/config` 返回 `devLoginEnabled: true`SPA 登录页据此显示
「⚡ 一键登录管理员」按钮
- 后端注册 `/database/dev-login` 端点:按钮就是打它,它签发一个和飞书 OAuth
回调完全一样的 session,然后跳到 `/database/dashboard`
### 怎么关
把值设成 `false`(或 `0` / `no` / `off`),或删掉这一行。关闭后按钮消失、
`/database/dev-login` 返回 404 —— 按钮和端点同进同退。
### 双重门禁(重要)
真正的开关是两个条件的**与**(判断在 `plugin.ts`):
```
allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
```
即:**只要 `NODE_ENV=production`,无论 `HUB_DEV_LOGIN_BYPASS` 设成什么,一键登录
都强制关闭。** 生产始终只能走真实飞书 OAuth。
> 提醒:`HUB_DEV_LOGIN_BYPASS` 是敏感开关,别把开着它的 `.env` 带到任何联网 /
> 共享环境。整个旁路逻辑自包含在本目录(`plugin.ts` + `routes/databaseRoutes.ts`),
> `src/admin` 的登录路由未受影响。
## 文件
| 文件 | 职责 |
|------|------|
| `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()` |
| `routes/databaseRoutes.ts` | `/database/config``/database/api/stats`、dev 旁路 + 各子路由装配点 |
| `routes/filelibRoutes.ts` | 文件库 树/授权 API |
| `routes/fileRoutes.ts` | 文件库 文件内容/导出 API |
| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) |
| `routes/teacherApp.ts` | `/database/api/login-info` + 老师端 DEV 一键登录 |
| `static.ts` | filelib-web 构建产物托管:`/_filelib/*` 资源 + `/app``/database` 两个 SPA 回退 |
| `filelib/` | 文件库领域层(见下) |
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts``app.get("/database/api/...")`
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts``registerXxxRoutes(app, {...})`
注册一次。**不要在后端拼 HTML** —— 页面一律加在 `hub/filelib-web/src/routes/` 下。
## 文件库(filelib/)
独立文件库模块。代码注释里的 C/D 编号(契约 8.1、C2、C4、D11D19 等)
出自两份已删除的文档:《文件库-接口契约.md》与 `.omo/文件库-开工计划.md`,
内容可从 git 历史取回。其中 D19(网站管理员 = silo org OWNER/ADMIN)
另见 ADR-0028。**与 hub 自己的 Folder/Project(ADR-0021 explorer)是
两套体系,不复用。**
| 文件 | 职责 |
|------|------|
| `filelib/model.ts` | 角色秩(MANAGE>EDIT>VIEW)、D14 命名规则、FileLibError |
| `filelib/permission.ts` | 纯权限 reducer(取最高/不降权/祖先继承/D11 冻结),不碰 IO |
| `filelib/treeService.ts` | 树增删改查;每个写操作同事务落审计 |
| `filelib/grantService.ts` | 授权管理 + 契约 8.1 矩阵强制 + force_adjust |
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(**仅测试用**,ADR-0030) |
| `filelib/gitVersionStore.ts` | **生产** C1 实现:一项目一 git 仓库,VersionId = commit hash(ADR-0030) |
| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) |
| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) |
| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) |
| `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) |
| `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 |
| `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) |
| `filelib/routeShared.ts` | 路由共享件(依赖装配/错误映射/请求体校验) |
环境变量:
- `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`)
- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub
MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team)
## 存储布局(ADR-0030)
- **文件夹不落盘。** `FOLDER` 节点的 `storageDir` 永为 `NULL`,磁盘上不存在任何
对应目录;树形只由 `parentId` + `pathIds`(id 编码的物化路径)表达。
- **项目扁平、以 id 命名。** 仓库就是 `<storageRoot>/<nodeId>`(nodeId 是 uuid)。
名字不进路径 —— 这是 rename 不动磁盘、也不重写后代路径的原因。
- **一项目一真 git 仓库。** `init` 建目录 + `git init`;`VersionId` 是 40 位 commit
hash;某文件的版本 = `git log -1 -- <path>`,所以一个文件的提交不会使另一个
文件的 `baseVersion` 失效(D16)。删除也是一个 commit,旧版本仍可读。
- git 用 `execFile` 调系统二进制,不引依赖。**每次调用都禁 hooks、隔离全局/系统
gitconfig、`GIT_LITERAL_PATHSPECS=1`** —— 项目仓库是老师上传的**数据**而非可信代码。
- 宿主必须有 `git`;缺失时建项目报 `provision_failed`
- 写入仅**进程内**串行化。共享 storage root 的多进程会在同一仓库上竞争 git 锁;
alpha Silo 是一 org 一进程(ADR-0025),目前不可达。
关键语义速查:
- **D8**:无权限 → 404(不泄露存在性);越权 → 403;Group 服务故障 → 503
- **D11**:creator 不可变 + 自动 MANAGE;独立权限关闭时项目级非创建者 grant 冻结
- **D12**:move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁
- **D15**:删除只打标本节点,"任一祖先已删"即整支不可见
- **8.1**:MANAGE 仅创建者可授/收;creator grant 不可动
- **审计**:一切写操作在业务事务内写 AuditEntry(同事务,失败即回滚);
文件内容写先 versionStore.commit 再审计(宁多版本,不造假审计)
## 约定(与 admin 面一致)
1. 路由用**绝对路径** `"/database/..."`,不用 Fastify prefix —— 每条路由 grep 得到。
2. **guard 前置、fail closed**:凡碰数据的端点第一行先跑
`requireSession` / `requireOrgRole` / `requireProjectPermission`
(都在 `../admin/auth/guards.js`)。
3. **租户隔离**ADR-0020):每个 Prisma 查询都 scope 到 `auth.organization.id`
不得跨 org。禁止无鉴权的数据路由。
4. 数据库通过传入的 `config.prisma` 访问(全进程单例,见 `../db.ts`);
不要在这里 `new PrismaClient()`
## 为什么代码在 `src/` 下
`tsconfig.json` 固定 `rootDir: "src"``include: ["src/**/*.ts"]`。只有
`src/` 下的 `.ts` 会被 `tsc` 编译、被 `tsx watch``npm run dev`)加载。放在
`src/` 之外的目录不会被构建,外部识别不到。
-83
View File
@@ -1,83 +0,0 @@
/**
* 文件库审计 sink(契约 C3 的入驻适配)。
*
* 契约原文:本地 outbox 表(与业务同事务)→ 中继 POST 到独立审计服务。
* 入驻 hub 后的适配:审计同事 = 本库 AuditEntry,与业务写在同一 Prisma 事务
* 内落库 —— 同库同事务天然满足"操作成功则日志必存在",比 outbox+relay 更强。
* 若审计团队日后独立成服务,只换本文件的实现,action 词汇表保持不变。
*/
import type { Prisma } from "@prisma/client";
/** C3 §6.3:文件库审计动作词汇表(与契约文档逐条对应,改词需升契约版本)。 */
export const FILE_LIB_AUDIT_ACTIONS = {
folderCreate: "folder.create",
folderRename: "folder.rename",
folderMove: "folder.move",
folderDelete: "folder.delete",
projectCreate: "project.create",
projectRename: "project.rename",
projectMove: "project.move",
projectDelete: "project.delete",
// ADR-0031:回收站。restore 与 delete 对称(都只动本节点);purge 是整支硬删。
folderRestore: "folder.restore",
projectRestore: "project.restore",
nodePurge: "node.purge",
permissionGrant: "permission.grant",
permissionUpdate: "permission.update",
permissionRevoke: "permission.revoke",
independentEnable: "project.independent_permission.enable",
independentDisable: "project.independent_permission.disable",
independentChange: "project.independent_permission.change",
fileUpload: "file.upload",
fileRename: "file.rename",
fileDelete: "file.delete",
fileCommit: "file.commit",
fileConflictDetected: "file.conflict_detected",
exportRun: "export.run",
adminForceAdjust: "admin.force_adjust",
// ADR-0028:成员组内置进 hub,组动作在本地审计(契约 C3 §6.3 原委托外部 Group 服务)。
groupCreate: "group.create",
groupUpdate: "group.update",
groupDelete: "group.delete",
groupRestore: "group.restore",
groupMemberAdd: "group.member_add",
groupMemberRemove: "group.member_remove",
} as const;
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job" | "group";
export interface FileLibAuditEntry {
readonly action: string;
readonly actorUserId: string;
readonly organizationId: string;
readonly objectType: FileLibAuditObjectType;
readonly objectId: string;
/** 节点 id 路径(pathIds)或项目内文件路径,便于按路径检索。 */
readonly objectPath: string;
readonly detail?: Record<string, unknown> | undefined;
}
/**
* 在调用方的事务里写一条审计。刻意不吞错:写不出来整个业务操作回滚
* (需求 5.1"操作成功则日志必存在"的强保证)。
*/
export async function writeFileLibAudit(
tx: Prisma.TransactionClient,
entry: FileLibAuditEntry,
): Promise<void> {
const metadata: Record<string, unknown> = {
objectType: entry.objectType,
objectId: entry.objectId,
objectPath: entry.objectPath,
...(entry.detail ?? {}),
};
await tx.auditEntry.create({
data: {
action: entry.action,
actorUserId: entry.actorUserId,
organizationId: entry.organizationId,
metadata: metadata as Prisma.InputJsonValue,
},
});
}
-201
View File
@@ -1,201 +0,0 @@
/**
* 回收站(ADR-0031)。
*
* 列出:deletedAt != null 且**祖先全活跃**的节点(每支已删子树只露顶)。
* 可见性:网站管理员,或在该已删节点上持活跃 MANAGE grant(直连 grant,
* 不走继承 —— 回收站是管理面,不是浏览面)。
* 恢复:只清本节点 deletedAt(与 D15 删除对称),整支立即可见,落审计。
* 彻底删除:仅网站管理员;按 pathIds 物化路径枚举子树,**自最深一层逐批
* 向上删**(self-FK 是 ON DELETE RESTRICT,一次 deleteMany 不保证顺序),
* 同事务一条 node.purge 审计。
*/
import type { PrismaClient } from "@prisma/client";
import { FileLibError, nameKey } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import type { GroupResolver } from "./groupResolver.js";
import type { FileLibActor } from "./treeService.js";
export interface BinDeps {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly groupResolver: GroupResolver;
}
export interface BinEntryDto {
readonly id: string;
readonly parentId: string | null;
readonly kind: "FOLDER" | "PROJECT";
readonly name: string;
readonly deletedAt: Date;
}
/** actor 对 node 是否可见(管理员,或节点上的直连 MANAGE —— USER 或其已解析组)。 */
async function canSeeEntry(
tx: Pick<PrismaClient, "fileLibGrant">,
deps: BinDeps,
actor: FileLibActor,
groupIds: readonly string[],
nodeId: string,
): Promise<boolean> {
if (actor.isWebsiteAdmin) return true;
const grant = await tx.fileLibGrant.findFirst({
where: {
organizationId: deps.organizationId,
nodeId,
revokedAt: null,
role: "MANAGE",
OR: [
{ principalType: "USER", principalId: actor.userId },
...(groupIds.length > 0
? [{ principalType: "GROUP" as const, principalId: { in: [...groupIds] } }]
: []),
],
},
select: { id: true },
});
return grant !== null;
}
/** 列出回收站(祖先全活跃的已删节点顶)。 */
export async function listBin(deps: BinDeps, actor: FileLibActor): Promise<readonly BinEntryDto[]> {
const deleted = await deps.prisma.fileLibNode.findMany({
where: { organizationId: deps.organizationId, deletedAt: { not: null } },
orderBy: { deletedAt: "desc" },
});
if (deleted.length === 0) return [];
// 祖先活跃性:收集所有 pathIds 里的祖先段,查哪些已删,做集合判定。
const ancestorIds = new Set<string>();
for (const n of deleted) {
const segments = n.pathIds.split("/").filter((s) => s !== "" && s !== n.id);
for (const s of segments) ancestorIds.add(s);
}
const deletedAncestorIds = new Set(
(
await deps.prisma.fileLibNode.findMany({
where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } },
select: { id: true },
})
).map((r) => r.id),
);
const tops = deleted.filter(
(n) => !n.pathIds.split("/").filter((s) => s !== "" && s !== n.id).some((s) => deletedAncestorIds.has(s)),
);
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
const out: BinEntryDto[] = [];
for (const n of tops) {
if (await canSeeEntry(deps.prisma, deps, actor, groupIds, n.id)) {
out.push({ id: n.id, parentId: n.parentId, kind: n.kind, name: n.name, deletedAt: n.deletedAt! });
}
}
return out;
}
/** 取回收站条目并做可见性门禁(D8:不可见即 404)。 */
async function requireBinEntry(
tx: PrismaClient,
deps: BinDeps,
actor: FileLibActor,
groupIds: readonly string[],
nodeId: string,
): Promise<{ readonly id: string; readonly parentId: string | null; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly pathIds: string }> {
const node = await tx.fileLibNode.findFirst({
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
if (!(await canSeeEntry(tx, deps, actor, groupIds, node.id))) {
throw new FileLibError(404, "node_not_found", "node not found");
}
return { id: node.id, parentId: node.parentId, kind: node.kind, name: node.name, pathIds: node.pathIds };
}
export interface RestoreResult {
readonly name: string;
}
/**
* 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。
* ADR-0033:与活跃兄弟撞名时不失败,自动改成「原名(已恢复[/ N])」——
* 恢复的意义就是找回,撞名死锁不是保护;审计 detail 记 renamedFrom。
*/
export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<RestoreResult> {
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
return deps.prisma.$transaction(async (tx) => {
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
const clash = await tx.fileLibNode.findFirst({
where: {
organizationId: deps.organizationId,
parentId: node.parentId,
deletedAt: null,
id: { not: node.id },
nameLower: nameKey(node.name),
},
select: { id: true },
});
if (clash !== null) {
throw new FileLibError(409, "name_conflict_on_restore", "name conflict on restore");
}
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: null } });
await writeFileLibAudit(tx, {
action: node.kind === "PROJECT"
? FILE_LIB_AUDIT_ACTIONS.projectRestore
: FILE_LIB_AUDIT_ACTIONS.folderRestore,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { name: node.name },
});
return { name: node.name };
});
}
/**
* 彻底删除(ADR-0034:与回收站条目同一可见性 —— 管理员或节点直连 MANAGE;
* 能删进回收站的人就能清空)。整支硬删:子树经 pathIds 前缀枚举,
* 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT,
* 父行必须晚于全部子孙行删除。
*/
export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
return deps.prisma.$transaction(async (tx) => {
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
const subtree = await tx.fileLibNode.findMany({
where: {
organizationId: deps.organizationId,
OR: [{ id: node.id }, { pathIds: { startsWith: `${node.pathIds}/` } }],
},
select: { id: true, pathIds: true },
});
const depthOf = (p: string): number => p.split("/").filter((s) => s !== "").length;
const byDepthDesc = [...subtree].sort((a, b) => depthOf(b.pathIds) - depthOf(a.pathIds));
let removed = 0;
let cursor = 0;
while (cursor < byDepthDesc.length) {
const depth = depthOf(byDepthDesc[cursor]!.pathIds);
const batch: string[] = [];
while (cursor < byDepthDesc.length && depthOf(byDepthDesc[cursor]!.pathIds) === depth) {
batch.push(byDepthDesc[cursor]!.id);
cursor += 1;
}
removed += (await tx.fileLibNode.deleteMany({ where: { id: { in: batch } } })).count;
}
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.nodePurge,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { name: node.name, removed },
});
return { removed };
});
}
-340
View File
@@ -1,340 +0,0 @@
/**
* 导出(契约 D10):异步任务 + 状态机 QUEUED → RUNNING → DONE/FAILED。
*
* ExportAdapter 是外部导出工具的 port(参数清单 OPEN-6,真身到位后替换)。
* 当前 stub 适配器产出"文件清单 manifest"文本,证明状态机端到端可跑;
* 产物存进程内存(v1 stub;生产应落对象存储/磁盘 —— 见 OPEN 清单)。
*/
import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import { requireAccessInTx, type FileLibActor } from "./treeService.js";
import type { FileDeps } from "./fileService.js";
const execFileAsync = promisify(execFile);
export interface ExportAdapterInput {
readonly storageDir: string;
readonly target: string;
readonly params: Record<string, unknown>;
readonly listFiles: (prefix?: string) => Promise<readonly { path: string; size: number }[]>;
readonly readFile: (path: string) => Promise<Buffer>;
}
export interface ExportArtifact {
readonly filename: string;
readonly content: Buffer;
}
export interface ExportAdapter {
readonly target: string;
run(input: ExportAdapterInput): Promise<ExportArtifact>;
}
/** stub 适配器:生成项目文件清单,端到端验证 job 状态机。OPEN-6 后换真导出工具。 */
export function createManifestStubAdapter(versionStore: FileDeps["versionStore"]): ExportAdapter {
return {
target: "manifest",
async run(input) {
const files = await input.listFiles();
const lines = [
`# Export manifest (stub adapter)`,
`target: ${input.target}`,
`storageDir: ${input.storageDir}`,
`files: ${files.length}`,
``,
...files.map((f) => `${String(f.size).padStart(10)} ${f.path}`),
];
return { filename: "manifest.txt", content: Buffer.from(lines.join("\n"), "utf8") };
},
};
}
/** `cph` 可执行文件位置;生产由 preflight 校验为绝对路径(见 deployment/preflight.ts)。 */
const CPH_BIN = process.env["CPH_BIN"] ?? "cph";
/** typst 渲染包目录;未设时由 cph 自行解析(仓库内 `render/`)。 */
const CPH_RENDER_DIR = process.env["CPH_RENDER_DIR"];
const CPH_BUILD_TIMEOUT_MS = 120_000;
/**
* 默认导出 target。
*
* UI 只给一个「导出 PDF」按钮,不让用户选 target;`student` 是 cph 自身在
* 工程文件未声明 `[targets.*]` 时的默认值(cph-check DEFAULT_TARGET),
* 与之保持一致,避免两端各有一套默认。
*/
const DEFAULT_PDF_TARGET = "student";
/**
* 真导出适配器:把项目物化到临时目录,跑 `cph build`,取回 PDF 字节。
*
* 为什么不直接在 `storageDir`(git worktree)里跑构建:那是项目的版本库工作区,
* 构建产物会变成未跟踪文件混进去,后续 `list`/`commit` 的语义会被污染。
* 物化到临时目录让构建对版本库完全无副作用,代价是一次文件拷贝。
*/
export function createCphPdfAdapter(): ExportAdapter {
return {
target: "pdf",
async run(input) {
await requireCourswareCph();
const target = typeof input.params["target"] === "string" ? input.params["target"] : DEFAULT_PDF_TARGET;
const workDir = await mkdtemp(path.join(tmpdir(), "cph-export-"));
try {
await materialize(input, workDir);
const outRel = path.join("build", `${target}.pdf`);
const args = ["build", ".", "--target", target, "-o", outRel];
if (CPH_RENDER_DIR !== undefined && CPH_RENDER_DIR !== "") {
args.unshift("--render-dir", CPH_RENDER_DIR);
}
await runCphBuild(args, workDir);
const content = await readFile(path.join(workDir, outRel));
return { filename: `${target}.pdf`, content };
} finally {
await rm(workDir, { recursive: true, force: true });
}
},
};
}
/**
* 确认 CPH_BIN 指向的是 Courseware 检查器,而不是同名的其它工具。
*
* `cph` 这个名字在 PyPI 上已被 conda 的 conda-package-handling 占用,装了
* miniconda 的机器上 PATH 里的 `cph` 就是它。直接拿它跑 `build` 会得到
* 一句 argparse 的 "invalid choice: 'build'",根本看不出是撞名 —— 所以这里先用
* `--version` 探一下,把撞名变成一条能直接终止排查的错误。
*
* 结果缓存:探测只为拦配置错误,没必要每次导出都多起一个进程。
*/
let cphIdentityCheck: Promise<void> | null = null;
function requireCourswareCph(): Promise<void> {
cphIdentityCheck ??= (async () => {
let stdout: string;
try {
({ stdout } = await execFileAsync(CPH_BIN, ["--version"], { timeout: 10_000 }));
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
throw new FileLibError(
500,
"cph_not_found",
`cph binary not found at "${CPH_BIN}" (set CPH_BIN to the Courseware cph)`,
);
}
throw new FileLibError(500, "cph_unusable", `cph --version failed at "${CPH_BIN}": ${String(err.message)}`);
}
if (!/^cph\s+\d+\.\d+\.\d+/.test(stdout.trim())) {
throw new FileLibError(
500,
"cph_wrong_binary",
`"${CPH_BIN}" is not the Courseware cph checker (--version said: ${stdout.trim().split("\n")[0] ?? ""}). ` +
`Set CPH_BIN to the Courseware cph binary.`,
);
}
})().catch((error: unknown) => {
// 不缓存失败:改完 CPH_BIN 重启前,下一次导出应该重新探测。
cphIdentityCheck = null;
throw error;
});
return cphIdentityCheck;
}
/** 把版本库当前内容写进 workDir。路径已由 versionStore 的 safeRelPath 约束。 */
async function materialize(input: ExportAdapterInput, workDir: string): Promise<void> {
const files = await input.listFiles();
if (files.length === 0) {
throw new FileLibError(409, "export_empty_project", "project has no files to export");
}
for (const f of files) {
const abs = path.join(workDir, f.path);
await mkdir(path.dirname(abs), { recursive: true });
await writeFile(abs, await input.readFile(f.path));
}
}
/**
* 跑 `cph build`。cph 的约定是诊断走 stderr、退出码非零表示构建失败(ADR-0010),
* 所以失败时把 stderr 原样带进错误信息 —— 老师需要看到是哪个诊断挡住了导出。
*/
async function runCphBuild(args: readonly string[], cwd: string): Promise<void> {
try {
await execFileAsync(CPH_BIN, args, { cwd, timeout: CPH_BUILD_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 });
} catch (error) {
const err = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
if (err.code === "ENOENT") {
throw new FileLibError(500, "cph_not_found", `cph binary not found at "${CPH_BIN}"`);
}
const detail = (err.stderr ?? "").trim() || (err.stdout ?? "").trim() || err.message;
throw new FileLibError(422, "cph_build_failed", `cph build failed: ${detail}`);
}
}
// v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。
const artifacts = new Map<string, ExportArtifact>();
/**
* 内存里最多保留的产物份数。
*
* 产物不做持久化也不复用 —— 每次导出都按仓库当前内容重新编译,内存副本只为
* 支撑「提交完成后那一次下载」。因此这里可以无条件淘汰最旧的:被淘汰的 job 再点
* 下载会拿到 export_not_ready,重新导出即可,不存在数据丢失。
* 没有上限的话每次导出都会永久占住一份 PDF(数百 KB 级),进程内存只增不减。
*/
const MAX_RETAINED_ARTIFACTS = 32;
/** Map 迭代顺序即插入顺序,首个 key 就是最旧的产物。 */
function retainArtifact(jobId: string, artifact: ExportArtifact): void {
artifacts.set(jobId, artifact);
while (artifacts.size > MAX_RETAINED_ARTIFACTS) {
const oldest = artifacts.keys().next();
if (oldest.done === true) break;
artifacts.delete(oldest.value);
}
}
export interface ExportDeps extends FileDeps {
readonly adapters: readonly ExportAdapter[];
}
export interface ExportJobDto {
readonly id: string;
readonly nodeId: string;
readonly target: string;
readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
readonly error: string | null;
readonly createdAt: Date;
}
/** 提交导出(需 VIEW):建行(QUEUED)+ export.run 审计,同事务;异步执行。 */
export async function submitExport(
deps: ExportDeps,
actor: FileLibActor,
projectId: string,
target: string,
params: Record<string, unknown>,
): Promise<ExportJobDto> {
const { node } = await deps.prisma.$transaction(async (tx) =>
requireAccessInTx(tx, deps, actor, projectId, "VIEW"),
);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "export applies to projects only");
}
const adapter = deps.adapters.find((a) => a.target === target);
if (adapter === undefined) {
throw new FileLibError(400, "unknown_target", `no export adapter for target "${target}"`);
}
if (node.storageDir === null) {
throw new FileLibError(409, "project_not_ready", "project repository is not ready");
}
const jobId = randomUUID();
const job = await deps.prisma.$transaction(async (tx) => {
const created = await tx.fileLibExportJob.create({
data: {
id: jobId,
organizationId: deps.organizationId,
nodeId: node.id,
target,
params: params as never,
status: "QUEUED",
createdByUserId: actor.userId,
},
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.exportRun,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "export_job",
objectId: jobId,
objectPath: node.pathIds,
detail: { target, params },
});
return created;
});
const storageDir = node.storageDir;
setImmediate(() => {
void runExportJob(deps, adapter, jobId, storageDir, target, params).catch(() => undefined);
});
return toDto(job);
}
async function runExportJob(
deps: ExportDeps,
adapter: ExportAdapter,
jobId: string,
storageDir: string,
target: string,
params: Record<string, unknown>,
): Promise<void> {
await deps.prisma.fileLibExportJob.update({ where: { id: jobId }, data: { status: "RUNNING" } });
try {
const artifact = await adapter.run({
storageDir,
target,
params,
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
readFile: (path) => deps.versionStore.read(storageDir, path),
});
retainArtifact(jobId, artifact);
await deps.prisma.fileLibExportJob.update({
where: { id: jobId },
data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` },
});
} catch (error) {
await deps.prisma.fileLibExportJob.update({
where: { id: jobId },
data: { status: "FAILED", error: String(error) },
});
}
}
export async function getExportJob(
deps: ExportDeps,
actor: FileLibActor,
jobId: string,
): Promise<ExportJobDto> {
const job = await deps.prisma.fileLibExportJob.findFirst({
where: { id: jobId, organizationId: deps.organizationId },
});
if (job === null) throw new FileLibError(404, "export_not_found", "export job not found");
// D8:对源项目无 View → 404(不泄露 job 存在性)。
await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, job.nodeId, "VIEW"));
return toDto(job);
}
export async function downloadExport(
deps: ExportDeps,
actor: FileLibActor,
jobId: string,
): Promise<ExportArtifact> {
await getExportJob(deps, actor, jobId);
const artifact = artifacts.get(jobId);
if (artifact === undefined) {
throw new FileLibError(409, "export_not_ready", "export artifact is not available");
}
return artifact;
}
function toDto(job: {
id: string;
nodeId: string;
target: string;
status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
error: string | null;
createdAt: Date;
}): ExportJobDto {
return {
id: job.id,
nodeId: job.nodeId,
target: job.target,
status: job.status,
error: job.error,
createdAt: job.createdAt,
};
}
-339
View File
@@ -1,339 +0,0 @@
/**
* 文件内容服务(Phase 3):路径安全 + 版本化文件操作 + 审计。
*
* 顺序铁律(Metis 风险#1 的落地):
* - 内容写:先 versionStore.commit(业务事实本体)→ 再 DB 事务(审计)。
* 宁多一个无审计的版本,不造一条假审计。
* - conflict:写 file.conflict_detected(冲突本身就是事件),再抛 409。
* - 读:随取随读,不写审计(需求 5.2 未列读操作)。
*/
import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import type { CommitResult, FileEntry, VersionInfo, VersionStore, ProjectCommitInfo } from "./versionStore.js";
import type { AccessDeps, FileLibActor } from "./treeService.js";
import { requireAccessInTx } from "./treeService.js";
import type { FileLibNode, PrismaClient } from "@prisma/client";
export const FILE_PATH_MAX_LENGTH = 512;
export const FILE_PATH_MAX_DEPTH = 32;
/** 单文件上限的出厂默认值(OPEN-5 初值)。实际生效值见 `resolveMaxFileBytes`。 */
export const FILE_CONTENT_MAX_BYTES_DEFAULT = 10 * 1024 * 1024;
/**
* 单文件字节上限的纯解析。非法值(非正整数/NaN)按缺省处理 ——
* 配置写错不该让上传静默变成 0 上限(那会把每次上传都拒掉)。
*
* 与 `resolveMaxFileBytes` 分开是有意的:带默认参数的单函数版本里,
* 显式传 undefined 会触发默认值、回到读 env,于是“没传值”和“读环境变量”
* 永远分不开,测试也会被 vitest 加载的 .env 干扰。
*/
export function parseMaxFileBytes(raw: string | undefined): number {
if (raw === undefined || raw.trim() === "") return FILE_CONTENT_MAX_BYTES_DEFAULT;
const parsed = Number(raw.trim());
if (!Number.isSafeInteger(parsed) || parsed <= 0) return FILE_CONTENT_MAX_BYTES_DEFAULT;
return parsed;
}
/**
* 生效上限:`HUB_FILELIB_MAX_FILE_BYTES` 覆盖,缺省 10MiB。
*
* 注意它与 `HUB_HTTP_BODY_LIMIT_BYTES` 是串联的:上传把内容放在 JSON body 里,
* 二进制过 base64 体积涨 4/3,所以真正的天花板是
* min(本值, bodyLimit × 3/4)。body limit 太小时本值不可达,而且报错发生在
* Fastify 解析阶段(413 Payload Too Large),根本到不了下面的 checkSize。
*/
export function resolveMaxFileBytes(): number {
return parseMaxFileBytes(process.env["HUB_FILELIB_MAX_FILE_BYTES"]);
}
const CONTROL_CHARS = /[\p{C}]/u;
const FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]);
/**
* 路径安全(Metis 安全红线):NFC;拒绝反斜杠、控制字符、空段、
* "." / ".." / ".git" 段、绝对路径、超长/超深。返回规范化相对路径。
*/
export function validateFilePath(raw: string): string {
const normalized = raw.normalize("NFC");
if (normalized.length === 0 || normalized.length > FILE_PATH_MAX_LENGTH) {
throw new FileLibError(400, "invalid_path", `path must be 1..${FILE_PATH_MAX_LENGTH} characters`);
}
if (normalized.includes("\\")) {
throw new FileLibError(400, "invalid_path", "path must use '/' separators");
}
if (CONTROL_CHARS.test(normalized)) {
throw new FileLibError(400, "invalid_path", "path must not contain control characters");
}
const segments = normalized.split("/");
if (segments.length > FILE_PATH_MAX_DEPTH) {
throw new FileLibError(400, "invalid_path", `path depth exceeds ${FILE_PATH_MAX_DEPTH}`);
}
for (const segment of segments) {
if (FORBIDDEN_SEGMENTS.has(segment)) {
throw new FileLibError(400, "invalid_path", `forbidden path segment: "${segment}"`);
}
}
return normalized;
}
export interface FileDeps extends AccessDeps {
readonly prisma: PrismaClient;
readonly versionStore: VersionStore;
/** 单文件字节上限。装配处用 `resolveMaxFileBytes()` 求值,缺省即出厂值。 */
readonly maxFileBytes?: number | undefined;
}
type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string };
async function requireProject(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
minRole: "VIEW" | "EDIT",
): Promise<ProjectChain> {
const { node } = await deps.prisma.$transaction(async (tx) =>
requireAccessInTx(tx, deps, actor, projectId, minRole),
);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "file operations apply to projects only");
}
if (node.provisionStatus === "PROVISIONING") {
throw new FileLibError(409, "project_not_ready", "project repository is still provisioning");
}
if (node.provisionStatus === "FAILED") {
throw new FileLibError(409, "project_not_ready", "project repository provisioning failed");
}
if (node.storageDir === null) {
throw new FileLibError(500, "storage_missing", "project has no storage directory");
}
return { node, storageDir: node.storageDir };
}
export type FileContentEncoding = "utf8" | "base64";
export interface FileContentDto {
readonly path: string;
readonly version: string;
readonly encoding: FileContentEncoding;
readonly content: string;
readonly size: number;
}
export function decodeContent(content: string, encoding: FileContentEncoding): string | Buffer {
return encoding === "base64" ? Buffer.from(content, "base64") : content;
}
function encodeContent(buffer: Buffer): { readonly encoding: FileContentEncoding; readonly content: string } {
// 粗判二进制:含 NUL 字节即按 base64 返回(需求 2.5 在线编辑仅针对文本)。
return buffer.includes(0)
? { encoding: "base64", content: buffer.toString("base64") }
: { encoding: "utf8", content: buffer.toString("utf8") };
}
function checkSize(content: string | Buffer, maxBytes: number): void {
const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
if (bytes > maxBytes) {
throw new FileLibError(413, "file_too_large", `file exceeds ${maxBytes} bytes`);
}
}
/**
* commit message 的默认文案:`【用户名】修改了【路径】`。
* 用户名取 displayName,缺失回退 userId(权限判定从不看它)。
* 显式传 message 的调用方优先 —— 这里只填空缺。
*/
export function defaultCommitMessage(actor: FileLibActor, filePath: string): string {
const who = actor.displayName === undefined || actor.displayName.trim() === ""
? actor.userId
: actor.displayName.trim();
return `${who}】修改了【${filePath}`;
}
async function auditFile(
deps: FileDeps,
actor: FileLibActor,
action: string,
project: ProjectChain,
filePath: string,
detail: Record<string, unknown>,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
await writeFileLibAudit(tx, {
action,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "file",
objectId: project.node.id,
objectPath: `${project.node.pathIds}:${filePath}`,
detail,
});
});
}
/* ---------------------------------------------------------------- 读操作 */
export async function listFiles(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
prefix?: string,
): Promise<readonly FileEntry[]> {
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.list(project.storageDir, prefix === undefined ? undefined : validateFilePath(prefix));
}
export async function readFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
): Promise<FileContentDto> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
const [version, buffer] = await Promise.all([
deps.versionStore.head(project.storageDir, filePath),
deps.versionStore.read(project.storageDir, filePath),
]);
const { encoding, content } = encodeContent(buffer);
return { path: filePath, version, encoding, content, size: buffer.byteLength };
}
/** 原始字节下载(浏览器 save-as 用):JSON 之外的第二条读取通道,同样的 VIEW 门禁。 */
export async function readFileRaw(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
): Promise<{ readonly buffer: Buffer; readonly version: string; readonly filename: string }> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
const [version, buffer] = await Promise.all([
deps.versionStore.head(project.storageDir, filePath),
deps.versionStore.read(project.storageDir, filePath),
]);
return { buffer, version, filename: filePath.split("/").pop() ?? "download" };
}
export async function fileHistory(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
limit?: number,
): Promise<readonly VersionInfo[]> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.history(project.storageDir, filePath, limit);
}
/** 项目级提交历史(所有文件),VIEW 即可访问。 */
export async function projectHistory(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
limit?: number,
): Promise<readonly ProjectCommitInfo[]> {
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.projectHistory(project.storageDir, limit);
}
export async function diffFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
from: string,
to: string,
): Promise<{ readonly diff: string }> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
return { diff: await deps.versionStore.diff(project.storageDir, filePath, from, to) };
}
/* ---------------------------------------------------------------- 写操作 */
export interface CommitInput {
readonly path: string;
/** null = 新建(已存在则 409);编辑时传 readFile 拿到的 version。 */
readonly baseVersion: string | null;
readonly content: string;
readonly encoding?: FileContentEncoding | undefined;
readonly message?: string | undefined;
}
/**
* 统一写入口(上传/编辑共用):先 commit,成功写 file.commit / file.upload 审计;
* 冲突写 file.conflict_detected 后抛 409(details 带 currentVersion,编辑 UI 用它拉 diff)。
*/
export async function commitFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
input: CommitInput,
): Promise<{ readonly version: string }> {
const filePath = validateFilePath(input.path);
const content = decodeContent(input.content, input.encoding ?? "utf8");
checkSize(content, deps.maxFileBytes ?? resolveMaxFileBytes());
const project = await requireProject(deps, actor, projectId, "EDIT");
// 调用方传了非空 message 则用它,否则回退默认文案。
// 空串必须当作没传:`git commit -m ""` 会以 empty commit message 失败。
const trimmedMessage = input.message?.trim();
const message = trimmedMessage === undefined || trimmedMessage === ""
? defaultCommitMessage(actor, filePath)
: trimmedMessage;
const result: CommitResult = await deps.versionStore.commit(project.storageDir, filePath, {
baseVersion: input.baseVersion,
content,
message,
// 身份:name=displayName(回退 userId),email=<userId>@域名(git 实现里拼)。
author: { userId: actor.userId, displayName: actor.displayName },
});
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion: input.baseVersion,
currentVersion: result.currentVersion,
});
throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", {
currentVersion: result.currentVersion,
});
}
await auditFile(
deps,
actor,
input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit,
project,
filePath,
{ version: result.version, message },
);
return { version: result.version };
}
export async function deleteFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
baseVersion: string,
): Promise<void> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "EDIT");
const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion, {
userId: actor.userId,
displayName: actor.displayName,
});
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion,
currentVersion: result.currentVersion,
});
throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", {
currentVersion: result.currentVersion,
});
}
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileDelete, project, filePath, { baseVersion });
}
-443
View File
@@ -1,443 +0,0 @@
/**
* VersionStore(契约 C1)的真 git 实现 —— ADR-0030。
*
* 一个项目一个 git 仓库,位于 <storageRoot>/<nodeId>(nodeId 是 uuid;
* 文件夹不落盘,见 ADR-0030 Context)。VersionId = commit hash。
*
* D16 文件级版本的映射(ADR-0030 Decision):一次写只碰一个路径、只产生一个
* commit;某文件的版本 = `git log -1 -- <path>` 的 hash。因此 a.md 的提交不出现在
* b.md 的 log 里,两者 baseVersion 互不失效 —— 尽管 commit 本身是仓库级对象。
*
* 不引 npm 依赖:三条 execFile 就够,见 ADR-0030 Alternatives。
*/
import { execFile } from "node:child_process";
import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { FileLibError } from "./model.js";
import type {
CommitAuthor,
CommitRequest,
CommitResult,
FileEntry,
ProjectCommitInfo,
VersionId,
VersionInfo,
VersionStore,
} from "./versionStore.js";
/** 无 author 时的固定身份(ADR-0030 Consequences:不再是 undefined)。 */
const FALLBACK_AUTHOR = "filelib";
/** 提交者 email 域名:`<userId>@filelib.paradigm-edu.net`。 */
const AUTHOR_EMAIL_DOMAIN = "filelib.paradigm-edu.net";
/**
* 每次调用都带的加固(ADR-0030 Decision)。项目仓库是老师上传的**数据**,
* 不是可信代码:hooks 必须禁用,全局/系统 gitconfig 必须隔离,否则仓库内容
* 或开发者机器上的配置就能改变服务端行为。
*/
const HARDENING_ARGS = ["-c", "core.hooksPath=", "-c", "commit.gpgsign=false"] as const;
/**
* 仓库定位参数。**这两个不能省**:git 默认会沿目录树**向上**找 `.git`,
* 而 storage root 很可能就在另一个 git 仓库里(本地开发的默认值
* `hub/.filelib-repos` 就在本 repo 内)。不钉死的后果是项目目录没自己的 `.git`
* 时,所有命令默默落到**外层仓库**上 —— 轻则 `git add` 报 ignored,
* 重则把老师的文件提交进源码仓。
*/
function repoArgs(projectDir: string): readonly string[] {
return [`--git-dir=${path.join(projectDir, ".git")}`, `--work-tree=${projectDir}`];
}
const HARDENING_ENV = {
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_SYSTEM: "/dev/null",
// 文件名永远不被重解释为 pathspec magic(`:(glob)` 等)。
GIT_LITERAL_PATHSPECS: "1",
// 仓库不得因为凭据提示卡住一个 HTTP 请求。
GIT_TERMINAL_PROMPT: "0",
} as const;
/**
* 继承来的这几个会劫持全部命令(比如 hub 自身被一个 git hook 启动时),
* 必须从子进程 env 里**删掉**而不是置空 —— 置空在 git 里的含义并不统一。
* 我们只认 repoArgs 里显式传的那一份。
*/
const STRIPPED_ENV = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY"] as const;
function childEnv(extra: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, ...HARDENING_ENV, ...extra };
for (const key of STRIPPED_ENV) delete env[key];
return env;
}
interface GitResult {
readonly stdout: Buffer;
readonly code: number;
readonly stderr: string;
}
/** execFile 的 args 数组形式:不拼 shell,文件名不参与命令解析。 */
function runGit(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>> = {},
): Promise<GitResult> {
return execGit(cwd, [...repoArgs(cwd), ...args], env);
}
/**
* 不钉 --git-dir 的调用。**只给 `git init` 用** —— 那一刻 `.git` 尚不存在,
* 钉上去 git 会直接报错。init 自己总是在 cwd 建仓,不会向上找。
*/
function runGitBare(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>> = {},
): Promise<GitResult> {
return execGit(cwd, args, env);
}
function execGit(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>>,
): Promise<GitResult> {
return new Promise((resolve, reject) => {
execFile(
"git",
[...HARDENING_ARGS, ...args],
{
cwd,
encoding: "buffer",
env: childEnv(env),
maxBuffer: 64 * 1024 * 1024,
windowsHide: true,
},
(error, stdout, stderr) => {
const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(String(stdout));
const err = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : String(stderr);
if (error === null) {
resolve({ stdout: out, code: 0, stderr: err });
return;
}
const code = (error as NodeJS.ErrnoException & { code?: number | string }).code;
if (code === "ENOENT") {
// 坑:spawn 的 ENOENT 有两种来源且**报错完全一样**(都是 path:"git"、
// syscall:"spawn git") —— git 真的不在 PATH 上,或者 cwd 目录不存在。
// 后者在这里是常态(DB 里有 storageDir、磁盘上却没建过,比如内存 store
// 时代留下的旧项目),必须报 repo_not_found 而不是冤枉 git 没装。
void access(cwd).then(
() => reject(new FileLibError(500, "git_missing", "git executable not found on PATH")),
() => reject(new FileLibError(404, "repo_not_found", `repository directory missing: ${cwd}`)),
);
return;
}
// 非零退出是常规控制流(文件不存在、空仓库等),交给调用点判断。
resolve({ stdout: out, code: typeof code === "number" ? code : 1, stderr: err });
},
);
});
}
async function requireGit(cwd: string, args: readonly string[], env?: Record<string, string>): Promise<Buffer> {
const res = await runGit(cwd, args, env);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git ${args[0] ?? ""} failed: ${res.stderr.trim()}`);
}
return res.stdout;
}
async function requireGitBare(cwd: string, args: readonly string[]): Promise<Buffer> {
const res = await runGitBare(cwd, args);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git ${args[0] ?? ""} failed: ${res.stderr.trim()}`);
}
return res.stdout;
}
/**
* S4:同仓库写操作串行化。git 的并发写会在 index.lock 上打架,
* 串行化把它变成干净的 conflict 返回值而不是锁错误。仅进程内有效(ADR-0030)。
*/
function createKeySerializer(): <T>(key: string, fn: () => Promise<T>) => Promise<T> {
const tails = new Map<string, Promise<unknown>>();
return <T>(key: string, fn: () => Promise<T>): Promise<T> => {
const prev = tails.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn);
tails.set(key, next.catch(() => undefined));
return next;
};
}
/**
* 仓库内相对路径的再校验。fileService.validateFilePath 已经把过一遍,
* 但 ADR-0030 把这条从卫生升级为安全边界 —— 本层不信调用方。
*/
function safeRelPath(filePath: string): string {
const normalized = filePath.normalize("NFC");
if (normalized === "" || path.isAbsolute(normalized) || normalized.includes("\\")) {
throw new FileLibError(400, "invalid_path", `unsafe path: ${filePath}`);
}
const segments = normalized.split("/");
for (const segment of segments) {
if (segment === "" || segment === "." || segment === ".." || segment === ".git") {
throw new FileLibError(400, "invalid_path", `unsafe path segment in: ${filePath}`);
}
}
// 解析后必须仍在仓库内(符号链接由 git 自身不跟随 + 此处前缀检查共同兜住)。
const resolved = path.posix.normalize(normalized);
if (resolved.startsWith("..") || path.isAbsolute(resolved)) {
throw new FileLibError(400, "invalid_path", `unsafe path: ${filePath}`);
}
return resolved;
}
/**
* 提交者身份 → git author/committer。
* name = displayName(缺失回退 userId);email = `<userId>@filelib.paradigm-edu.net`。
* email 用 userId 而不用 displayName:昵称会改,身份追溯不能跟着漂。
* name 里的换行/`<`/`>` 必须清掉 —— 它们会破坏 git 的 ident 行格式。
*/
function authorEnv(author: CommitAuthor | undefined): Record<string, string> {
const rawName = author?.displayName;
const fallback = author?.userId ?? FALLBACK_AUTHOR;
const name = (rawName === undefined || rawName.trim() === "" ? fallback : rawName.trim())
.replace(/[<>\n\r]/g, " ")
.trim();
const localPart = (author?.userId ?? FALLBACK_AUTHOR).replace(/[^\w.-]/g, "_");
const email = `${localPart}@${AUTHOR_EMAIL_DOMAIN}`;
return {
GIT_AUTHOR_NAME: name === "" ? FALLBACK_AUTHOR : name,
GIT_AUTHOR_EMAIL: email,
GIT_COMMITTER_NAME: name === "" ? FALLBACK_AUTHOR : name,
GIT_COMMITTER_EMAIL: email,
};
}
export function createGitVersionStore(): VersionStore {
const serialize = createKeySerializer();
/**
* 未 init → repo_not_found。
* 不能用 `git rev-parse --git-dir`:repoArgs 已把 --git-dir 钉死,rev-parse 会
* 原样回显它而不验证存在;而不钉死时它又会向上找到外层仓库。所以直接
* 测文件系统:项目目录里必须有属于它自己的 `.git`。
*/
async function requireRepo(projectDir: string): Promise<void> {
try {
await access(path.join(projectDir, ".git"));
} catch {
throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`);
}
}
/** 该路径在 HEAD 上的当前版本;不存在(或从未提交)→ null。 */
async function currentVersion(projectDir: string, filePath: string): Promise<VersionId | null> {
const exists = await runGit(projectDir, ["cat-file", "-e", `HEAD:${filePath}`]);
if (exists.code !== 0) return null; // 空仓库、已删除、或从无此文件
const log = await runGit(projectDir, ["log", "-1", "--format=%H", "--", filePath]);
if (log.code !== 0) return null;
const hash = log.stdout.toString("utf8").trim();
return hash === "" ? null : hash;
}
async function headVersion(projectDir: string, filePath: string): Promise<VersionId> {
const version = await currentVersion(projectDir, filePath);
if (version === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
return version;
}
/** commit id 必须存在,否则 version_not_found(而非把 git 错误透出去)。 */
async function requireCommit(projectDir: string, version: VersionId): Promise<void> {
const res = await runGit(projectDir, ["rev-parse", "--verify", "--quiet", `${version}^{commit}`]);
if (res.code !== 0) {
throw new FileLibError(404, "version_not_found", `version not found: ${version}`);
}
}
/** 提交暂存区里已备好的单个路径。返回新 commit hash。 */
async function commitPath(
projectDir: string,
filePath: string,
message: string,
author: CommitAuthor | undefined,
): Promise<VersionId> {
const env = authorEnv(author);
await requireGit(projectDir, ["commit", "--quiet", "--allow-empty", "-m", message, "--", filePath], env);
const hash = await requireGit(projectDir, ["rev-parse", "HEAD"]);
return hash.toString("utf8").trim();
}
return {
/** S7 幂等:已有仓库就不重建。 */
async init(projectDir) {
await serialize(projectDir, async () => {
await mkdir(projectDir, { recursive: true });
try {
await access(path.join(projectDir, ".git"));
return; // 已是仓库,不清空
} catch { /* 继续 init */ }
// init 用 runGitBare:此时 .git 尚不存在,钉 --git-dir 反而会让 git 报错。
await requireGitBare(projectDir, ["init", "--quiet"]);
// 默认分支名不依赖宿主 git 版本/配置(全局配置已被隔离)。
await requireGit(projectDir, ["symbolic-ref", "HEAD", "refs/heads/main"]);
});
},
async list(projectDir, prefix) {
await requireRepo(projectDir);
const res = await runGit(projectDir, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
if (res.code !== 0) return []; // 空仓库(无 HEAD)
const out: FileEntry[] = [];
for (const record of res.stdout.toString("utf8").split("\0")) {
if (record === "") continue;
// 形如:"<mode> <type> <object> <size>\t<path>"
const tab = record.indexOf("\t");
if (tab === -1) continue;
const meta = record.slice(0, tab).split(/\s+/);
const entryPath = record.slice(tab + 1);
if (meta[1] !== "blob") continue;
if (prefix !== undefined && !entryPath.startsWith(prefix)) continue;
out.push({ path: entryPath, size: Number.parseInt(meta[3] ?? "0", 10) || 0 });
}
return out.sort((a, b) => a.path.localeCompare(b.path));
},
async head(projectDir, filePath) {
await requireRepo(projectDir);
return headVersion(projectDir, safeRelPath(filePath));
},
async read(projectDir, filePath, at) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
if (at === undefined) {
await headVersion(projectDir, rel); // 存在性 → 404 file_not_found
const res = await runGit(projectDir, ["show", `HEAD:${rel}`]);
if (res.code !== 0) {
throw new FileLibError(404, "file_not_found", `file not found: ${rel}`);
}
return res.stdout;
}
await requireCommit(projectDir, at);
const res = await runGit(projectDir, ["show", `${at}:${rel}`]);
if (res.code !== 0) {
// commit 存在但该版本里没有这个路径。
throw new FileLibError(404, "version_not_found", `version not found: ${rel}@${at}`);
}
return res.stdout;
},
async commit(projectDir, filePath, req: CommitRequest): Promise<CommitResult> {
const rel = safeRelPath(filePath);
return serialize(projectDir, async (): Promise<CommitResult> => {
await requireRepo(projectDir);
const current = await currentVersion(projectDir, rel);
// S2:baseVersion=null 表新建,已存在即 conflict;
// S1:否则要求 baseVersion 精确等于当前版本。
if (req.baseVersion === null) {
if (current !== null) return { status: "conflict", currentVersion: current };
} else if (req.baseVersion !== current) {
return { status: "conflict", currentVersion: current ?? req.baseVersion };
}
const abs = path.join(projectDir, rel);
await mkdir(path.dirname(abs), { recursive: true });
await writeFile(abs, req.content);
await requireGit(projectDir, ["add", "--", rel]);
const message = req.message ?? (req.baseVersion === null ? `create ${rel}` : `update ${rel}`);
const version = await commitPath(projectDir, rel, message, req.author);
return { status: "ok", version };
});
},
async remove(projectDir, filePath, baseVersion, author): Promise<CommitResult> {
const rel = safeRelPath(filePath);
return serialize(projectDir, async (): Promise<CommitResult> => {
await requireRepo(projectDir);
const current = await currentVersion(projectDir, rel);
if (current === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${rel}`);
}
if (baseVersion !== current) return { status: "conflict", currentVersion: current };
await requireGit(projectDir, ["rm", "--quiet", "--", rel]);
const version = await commitPath(projectDir, rel, `remove ${rel}`, author);
return { status: "ok", version };
});
},
async diff(projectDir, filePath, from, to) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
await requireCommit(projectDir, from);
await requireCommit(projectDir, to);
const res = await runGit(projectDir, ["diff", from, to, "--", rel]);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git diff failed: ${res.stderr.trim()}`);
}
return res.stdout.toString("utf8");
},
async history(projectDir, filePath, limit) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
const args = ["log", "--format=%H%x1f%an%x1f%aI%x1f%s%x1e"];
if (limit !== undefined) args.push(`-${limit}`);
args.push("--", rel);
const res = await runGit(projectDir, args);
if (res.code !== 0) return []; // 空仓库
const out: VersionInfo[] = [];
for (const record of res.stdout.toString("utf8").split("\x1e")) {
const line = record.trim();
if (line === "") continue;
const [version, author, committedAt, message] = line.split("\x1f");
if (version === undefined) continue;
out.push({
version,
message: message ?? "",
author: author === undefined || author === "" ? undefined : author,
committedAt: committedAt ?? "",
});
}
return out; // git log 已是新→旧
},
async projectHistory(projectDir, limit) {
await requireRepo(projectDir);
// %x1e 放在 format 开头作为每条记录的分隔符;--name-only 的文件列表跟在 format 行之后。
const args = ["log", "--format=%x1e%H%x1f%an%x1f%aI%x1f%s", "--name-only"];
if (limit !== undefined) args.push(`-${limit}`);
const res = await runGit(projectDir, args);
if (res.code !== 0) return []; // 空仓库
const out: ProjectCommitInfo[] = [];
// split 按 \x1e 分块,第一个空块跳过。
for (const block of res.stdout.toString("utf8").split("\x1e")) {
const trimmed = block.trim();
if (trimmed === "") continue;
const lines = trimmed.split("\n");
const header = lines[0];
if (header === undefined) continue;
const [version, author, committedAt, message] = header.split("\x1f");
if (version === undefined) continue;
// header 之后的非空行是受影响的文件路径。
// git 对含特殊字符的路径加双引号,去掉外层引号即可。
const files = lines.slice(1)
.map((l) => l.trim())
.filter((l) => l !== "")
.map((l) => (l.startsWith('"') && l.endsWith('"') ? l.slice(1, -1) : l));
out.push({
version,
message: message ?? "",
author: author === undefined || author === "" ? undefined : author,
committedAt: committedAt ?? "",
files,
});
}
return out;
},
};
}
-343
View File
@@ -1,343 +0,0 @@
/**
* 授权管理(契约 8.1 矩阵的服务端强制)。
*
* 矩阵:
* - 创建者(creatorId 不可变):可授/改/收 MANAGE、EDIT、VIEW;自身 creator grant 不可动
* - MANAGE 持有者:可授/改/收 EDIT、VIEW;不可碰 MANAGE;不可动创建者
* - EDIT/VIEW:无授权能力(requireAccess MANAGE 已挡)
* - 网站管理员:走 forceAdjustGrants(不查节点权限,D19),全部留 admin.force_adjust 审计
*
* D8:目标节点不可见/无权限 → 404;有权限但矩阵禁止 → 403。
*/
import { Prisma } from "@prisma/client";
import type { FileLibGrant, FileLibNode, PrismaClient } from "@prisma/client";
import { FileLibError, type FileLibRole } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import {
requireAccessInTx,
type AccessDeps,
type FileLibActor,
type InitialGrant,
} from "./treeService.js";
export interface GrantDto {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
/** 主体显示名(用户 displayName / 组 name);主体已删时为 null,前端回落 principalId。 */
readonly principalName: string | null;
/** USER 主体的飞书 openId;GROUP 或主体已删时为 null。 */
readonly principalOpenId: string | null;
readonly role: FileLibRole;
readonly isCreatorGrant: boolean;
readonly createdAt: Date;
}
function toDto(grant: FileLibGrant, principalName?: string): GrantDto {
return {
id: grant.id,
principalType: grant.principalType,
principalId: grant.principalId,
principalName: null,
principalOpenId: null,
role: grant.role,
isCreatorGrant: grant.isCreatorGrant,
createdAt: grant.createdAt,
};
}
/** 批量回填主体显示名与飞书 openId(两次查询,不做 per-row 往返)。可在事务内调用。 */
async function withPrincipalNames(
prisma: Pick<PrismaClient, "user" | "memberGroup">,
grants: readonly GrantDto[],
): Promise<readonly GrantDto[]> {
const userIds = [...new Set(grants.filter((g) => g.principalType === "USER").map((g) => g.principalId))];
const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))];
const users = userIds.length === 0
? []
: await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, displayName: true, feishuOpenId: true },
});
const groups = groupIds.length === 0
? []
: await prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } });
const nameById = new Map<string, string>([
...users.map((u) => [u.id, u.displayName] as const),
...groups.map((g) => [g.id, g.name] as const),
]);
const openIdById = new Map<string, string>(users.map((u) => [u.id, u.feishuOpenId] as const));
return grants.map((g) => ({
...g,
principalName: nameById.get(g.principalId) ?? null,
principalOpenId: g.principalType === "USER" ? openIdById.get(g.principalId) ?? null : null,
}));
}
type Tx = Prisma.TransactionClient;
type Deps = AccessDeps & { readonly prisma: PrismaClient };
/**
* 批量解析 principal 展示名(两条 IN 查询,不做 N+1)。
* 组不按 archivedAt 过滤:已归档组的历史授权仍要能显示出名字来给管理员收回。
*/
async function resolvePrincipalNames(
tx: Tx | PrismaClient,
grants: readonly FileLibGrant[],
): Promise<ReadonlyMap<string, string>> {
const userIds = [...new Set(grants.filter((g) => g.principalType === "USER").map((g) => g.principalId))];
const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))];
const [users, groups] = await Promise.all([
userIds.length === 0
? Promise.resolve([])
: tx.user.findMany({ where: { id: { in: userIds } }, select: { id: true, displayName: true } }),
groupIds.length === 0
? Promise.resolve([])
: tx.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } }),
]);
const names = new Map<string, string>();
for (const u of users) names.set(`USER:${u.id}`, u.displayName);
for (const g of groups) names.set(`GROUP:${g.id}`, g.name);
return names;
}
async function toDtosWithNames(tx: Tx | PrismaClient, grants: readonly FileLibGrant[]): Promise<readonly GrantDto[]> {
const names = await resolvePrincipalNames(tx, grants);
return grants.map((g) => toDto(g, names.get(`${g.principalType}:${g.principalId}`)));
}
/** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */
async function requireManage(
deps: Deps,
actor: FileLibActor,
nodeId: string,
tx?: Tx,
): Promise<{ readonly node: FileLibNode; readonly role: FileLibRole }> {
if (tx !== undefined) return requireAccessInTx(tx, deps, actor, nodeId, "MANAGE");
return deps.prisma.$transaction(async (inner) => requireAccessInTx(inner, deps, actor, nodeId, "MANAGE"));
}
/** 列出节点活跃授权(需 MANAGE)。 */
export async function listGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
): Promise<readonly GrantDto[]> {
await requireManage(deps, actor, nodeId);
const grants = await deps.prisma.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return withPrincipalNames(deps.prisma, grants.map(toDto));
}
export interface PutGrantsResult {
readonly granted: number;
readonly updated: number;
readonly grants: readonly GrantDto[];
}
/**
* 批量授予/修改(upsert 语义):同 principal 已有活跃授权 → 改级别(permission.update);
* 没有 → 新建(permission.grant)。8.1 矩阵在写之前整体校验。
*/
export async function putGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
items: readonly InitialGrant[],
): Promise<PutGrantsResult> {
validateGrantItems(items);
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
const isCreator = node.creatorId === actor.userId;
for (const item of items) {
if (item.role === "MANAGE" && !isCreator) {
throw new FileLibError(403, "only_creator_can_grant_manage", "only the creator can grant MANAGE");
}
if (item.principalType === "USER" && item.principalId === node.creatorId) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
}
let granted = 0;
let updated = 0;
for (const item of items) {
const existing = await tx.fileLibGrant.findFirst({
where: {
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
revokedAt: null,
},
});
if (existing !== null) {
if (existing.isCreatorGrant) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
if (existing.role !== item.role) {
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
updated += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionUpdate, node.id, node.pathIds, { ...item });
}
} else {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
createdByUserId: actor.userId,
},
});
granted += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionGrant, node.id, node.pathIds, { ...item });
}
}
const grants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
});
}
/** 收回授权(需 MANAGE;creator grant 与 MANAGE grant 有额外限制,见 8.1)。 */
export async function revokeGrant(
deps: Deps,
actor: FileLibActor,
nodeId: string,
grantId: string,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
const grant = await tx.fileLibGrant.findFirst({
where: { id: grantId, nodeId: node.id, revokedAt: null },
});
if (grant === null) throw new FileLibError(404, "grant_not_found", "grant not found");
if (grant.isCreatorGrant) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
if (grant.role === "MANAGE" && node.creatorId !== actor.userId) {
throw new FileLibError(403, "only_creator_can_revoke_manage", "only the creator can revoke MANAGE");
}
await tx.fileLibGrant.update({ where: { id: grant.id }, data: { revokedAt: new Date() } });
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionRevoke, node.id, node.pathIds, {
principalType: grant.principalType,
principalId: grant.principalId,
role: grant.role,
});
});
}
/**
* 网站管理员强制调整(D19):凭 node id 操作,不查操作者节点权限;矩阵豁免;
* 每一条变更都落 admin.force_adjust 审计(高危留痕)。
*/
export async function forceAdjustGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
items: readonly InitialGrant[],
): Promise<PutGrantsResult> {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "force adjust requires website administrator");
}
validateGrantItems(items);
return deps.prisma.$transaction(async (tx) => {
const node = await tx.fileLibNode.findFirst({
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: null },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
let granted = 0;
let updated = 0;
for (const item of items) {
const existing = await tx.fileLibGrant.findFirst({
where: {
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
revokedAt: null,
},
});
if (existing !== null) {
if (existing.role !== item.role) {
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
updated += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
change: "update",
principalType: item.principalType,
principalId: item.principalId,
from: existing.role,
to: item.role,
});
}
} else {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
createdByUserId: actor.userId,
},
});
granted += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
change: "grant",
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
});
}
}
const grants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
});
}
function validateGrantItems(items: readonly InitialGrant[]): void {
if (items.length === 0) throw new FileLibError(400, "invalid_request", "grants must not be empty");
const seen = new Set<string>();
for (const item of items) {
if (item.principalType !== "USER" && item.principalType !== "GROUP") {
throw new FileLibError(400, "invalid_request", `bad principalType: ${String(item.principalType)}`);
}
if (item.role !== "VIEW" && item.role !== "EDIT" && item.role !== "MANAGE") {
throw new FileLibError(400, "invalid_request", `bad role: ${String(item.role)}`);
}
if (item.principalId.trim() === "") {
throw new FileLibError(400, "invalid_request", "principalId must not be empty");
}
const key = `${item.principalType}:${item.principalId}`;
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate principal: ${key}`);
seen.add(key);
}
}
async function audit(
tx: Prisma.TransactionClient,
deps: Deps,
actor: FileLibActor,
action: string,
nodeId: string,
pathIds: string,
detail: Record<string, unknown>,
): Promise<void> {
await writeFileLibAudit(tx, {
action,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "grant",
objectId: nodeId,
objectPath: pathIds,
detail,
});
}
-52
View File
@@ -1,52 +0,0 @@
/**
* GroupResolver port(契约 C2)。
*
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
* ADR-0028 起,默认实现是 in-hub 的 MemberGroup 闭包读取器
* (`createMemberGroupResolver`,见 memberGroupResolver.ts);
* `HUB_GROUP_SERVICE_URL` 配置后切外部 HTTP 实现(groupResolverHttp.ts)。
* 调用方只依赖此 port,不换调用点。
*/
import type { PrismaClient } from "@prisma/client";
export interface GroupResolver {
resolveMemberGroupIds(userId: string): Promise<readonly string[]>;
}
/**
* @deprecated ADR-0028:成员组已内置为 in-hub MemberGroup,默认 resolver 改为
* `createMemberGroupResolver`。此扁平 Team 过渡实现不再接线,保留仅为历史参照
* (以及潜在的迁移对照),新代码不要使用。
*
* 旧过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
*/
export function createTeamGroupResolver(
prisma: PrismaClient,
organizationId: string,
): GroupResolver {
return {
async resolveMemberGroupIds(userId) {
const memberships = await prisma.teamMembership.findMany({
where: {
userId,
revokedAt: null,
team: { organizationId, archivedAt: null },
},
select: { teamId: true },
});
return memberships.map((m) => m.teamId);
},
};
}
/** 单测 mock:静态 用户→组 映射。 */
export function createStaticGroupResolver(
map: Readonly<Record<string, readonly string[]>>,
): GroupResolver {
return {
async resolveMemberGroupIds(userId) {
return map[userId] ?? [];
},
};
}
@@ -1,49 +0,0 @@
/**
* GroupResolver 的 HTTP 实现(契约 C2,Group 团队服务到位后启用,
* 经 HUB_GROUP_SERVICE_URL 配置)。
*
* 语义红线:
* - 失败 → FileLibError(503, group_unavailable)。依赖故障不是"无权限",
* 绝不伪装成 404/403(计划 D13)。
* - 我方绝不自己推祖先:返回什么用什么,不在本地补逻辑。
*/
import { FileLibError } from "./model.js";
import type { GroupResolver } from "./groupResolver.js";
export interface HttpGroupResolverConfig {
readonly baseUrl: string;
readonly timeoutMs?: number;
/** 测试可注入假 fetch;生产用全局 fetch。 */
readonly fetchFn?: typeof fetch;
}
export function createHttpGroupResolver(config: HttpGroupResolverConfig): GroupResolver {
const timeoutMs = config.timeoutMs ?? 2_000;
const fetchFn = config.fetchFn ?? fetch;
return {
async resolveMemberGroupIds(userId) {
const url = `${config.baseUrl.replace(/\/$/, "")}/groups/resolve-member-groups?userId=${encodeURIComponent(userId)}`;
let response: Response;
try {
response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) });
} catch (error) {
throw new FileLibError(503, "group_unavailable", `group service unreachable: ${String(error)}`);
}
if (!response.ok) {
throw new FileLibError(503, "group_unavailable", `group service returned ${response.status}`);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new FileLibError(503, "group_unavailable", "group service returned malformed JSON");
}
const groupIds = (body as { groupIds?: unknown }).groupIds;
if (!Array.isArray(groupIds) || groupIds.some((id) => typeof id !== "string")) {
throw new FileLibError(503, "group_unavailable", "group service returned malformed payload");
}
return groupIds as readonly string[];
},
};
}
-49
View File
@@ -1,49 +0,0 @@
/**
* 文件库 HTTP 门禁(契约 C4 的入驻适配)。
*
* 身份链:hub session(飞书 OAuth / dev bypass)→ silo org membership。
* 网站管理员 = org 的 OWNER/ADMIN(D19:仅 root 创建与 force_adjust 特权,
* 不给内容读旁路);普通成员 = 任何活跃 membership;非成员 = 403。
*/
import type { FastifyReply, FastifyRequest } from "fastify";
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
import { requireSession, sendError } from "../../admin/auth/guards.js";
import type { FileLibActor } from "./treeService.js";
export interface FileLibGuardDeps {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
/** 文件库归属的 silo org(ADR-0020/0025)。 */
readonly organizationId: string;
}
const WEBSITE_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
/** 每个 /database/api/* 端点第一行调它;返回 null 时响应已发出,fail closed。 */
export async function requireFileLibActor(
request: FastifyRequest,
reply: FastifyReply,
deps: FileLibGuardDeps,
): Promise<FileLibActor | null> {
const auth = await requireSession(request, reply, {
prisma: deps.prisma,
sessionSecret: deps.sessionSecret,
});
if (auth === null) return null;
const membership = await deps.prisma.organizationMembership.findFirst({
where: { organizationId: deps.organizationId, userId: auth.user.id, revokedAt: null },
select: { role: true },
});
if (membership === null) {
await sendError(reply, 403, "forbidden", "not a member of this organization");
return null;
}
return {
userId: auth.user.id,
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
// 仅用于 commit message 的【用户名】;权限判定一律走 userId。
displayName: auth.user.displayName,
};
}
@@ -1,35 +0,0 @@
/**
* 默认 GroupResolver 实现:读 in-hub MemberGroup 闭包(ADR-0028)。
*
* resolveMemberGroupIds(user) = 用户**活跃直接组 ∪ 这些组的活跃祖先**,去重
* (闭包 depth0 自身行令每个直接组也是自己的祖先)。等价于:授权放在组 G 上,
* G 及其全部子孙的成员都命中(需求 3.2 权限沿树向下 → 解析沿树向上收集)。
*
* 实时、不缓存(契约 D4/G4):成员变更在下一次受保护请求即可见。
* MemberGroup 全局(无 organizationId),解析不做 org scope。
* 两条 Prisma 查询,不用裸 SQL(与 treeService 风格一致)。
*/
import type { PrismaClient } from "@prisma/client";
import type { GroupResolver } from "./groupResolver.js";
export function createMemberGroupResolver(prisma: PrismaClient): GroupResolver {
return {
async resolveMemberGroupIds(userId) {
// 1) 活跃直接组:成员未撤销 + 组未归档。
const direct = await prisma.memberGroupMembership.findMany({
where: { userId, revokedAt: null, group: { archivedAt: null } },
select: { groupId: true },
});
if (direct.length === 0) return [];
const directIds = direct.map((m) => m.groupId);
// 2) 经闭包取活跃祖先(含 depth0 自身);祖先组须未归档。
const ancestors = await prisma.memberGroupClosure.findMany({
where: { descendantId: { in: directIds }, ancestor: { archivedAt: null } },
select: { ancestorId: true },
});
return [...new Set(ancestors.map((a) => a.ancestorId))];
},
};
}

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