forked from EduCraft/curriculum-project-hub
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc271ff9f3 | |||
| 3cfbe55070 | |||
| 53ef364af2 | |||
| 94dba4e672 | |||
| 06aa6f0dfb | |||
| 4fc72541be | |||
| 672f05c66a | |||
| e173aa18e0 | |||
| e9cecbf071 | |||
| 55f29523a0 | |||
| d9cde19bdf | |||
| a463ab48e6 | |||
| d39ebed62e | |||
| 270275c367 | |||
| 09338f355a | |||
| 8bc5dbf9e2 | |||
| beaa92de2e | |||
| b1fd2e8f7b | |||
| 9e38d1e011 | |||
| f99c8ea4d8 | |||
| 83a6b012b7 | |||
| 475b397ca8 | |||
| d072e9ec1e | |||
| 04fa383286 | |||
| 0fd21e51f7 | |||
| ab5e03823c | |||
| 02b46e2dcf | |||
| 834f4c380c | |||
| 4c68c7db0b | |||
| 39a2be6347 | |||
| 87bbf0bd57 | |||
| 8eda04f1d9 | |||
| 1dab83f9db | |||
| 8c26c42e0c | |||
| fe38d0b8a9 | |||
| e194670d74 | |||
| 3b544d99f4 | |||
| c72f8c7050 | |||
| 947f969967 | |||
| 60856d7cc1 | |||
| 2e08c0a734 | |||
| 2225c6d43b | |||
| fc908eaf3b | |||
| 2395671693 | |||
| cc4d9d907c | |||
| ef02428bb6 | |||
| ad1a464f22 | |||
| 2dfe72cd5e | |||
| 12a1246a7a | |||
| ce5fbfb9a6 | |||
| 405312b36b | |||
| be17f74fc2 | |||
| 4849a765da | |||
| 82241afb56 | |||
| fccae5dacb | |||
| 0dd2ae347e | |||
| a306c58db2 | |||
| a4c07d1a5d | |||
| 91afd3c1b1 | |||
| 50ddf32cc2 | |||
| e6e23294a2 |
@@ -15,3 +15,5 @@ node_modules/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
|
||||
.omo/
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,43 @@
|
||||
# ADR 0030: Project Grants Are Always Live; The Independent-Permission Toggle Is Removed
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Supersedes the file-library contract rule **D11 / P5** (《文件库-接口契约.md》,
|
||||
since deleted; recoverable from git history) which introduced the per-project
|
||||
"独立权限" (independent permission) switch.
|
||||
|
||||
## Context
|
||||
|
||||
D11 gave each PROJECT a toggle (`FileLibProjectSettings.independentPermissionsEnabled`,
|
||||
default off). While off, project-level non-creator grants were **frozen** — present in
|
||||
`FileLibGrant` but excluded from `effectiveRole`; ancestor-chain grants and the creator's
|
||||
auto-grant were unaffected. The intent was to support two workflows: "project follows the
|
||||
folder's ACL" (off) vs "project has its own ACL" (on).
|
||||
|
||||
In practice the toggle surprised operators twice: grants appeared to "not work" until
|
||||
someone found and flipped a per-project switch buried in the 概览 tab, and the frozen state
|
||||
was indistinguishable from missing grants in the UI. The product decision is that
|
||||
project-level grants should simply always be live.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Project-level grants always participate in `effectiveRole`.** The freeze branch in
|
||||
`hub/src/database/filelib/permission.ts` is deleted; `EffectiveRoleInput` no longer
|
||||
carries `independentPermissionsEnabled`.
|
||||
- **The toggle surface is removed end-to-end**: `PUT /database/api/projects/:id/independent-permission`,
|
||||
`grantService.setIndependentPermission`, the `independentPermission` field in the node
|
||||
detail DTO, and the 概览 tab switch in `filelib-web`.
|
||||
- **`FileLibProjectSettings` becomes vestigial.** The table stays (existing rows are
|
||||
ignored, no data migration); new projects no longer get a default row. It may be dropped
|
||||
in a future migration once nothing references it.
|
||||
- Audit action vocabulary `independent_enable` / `independent_disable` is retained for
|
||||
reading historical audit entries; no new entries are produced.
|
||||
|
||||
Behavior change for existing deployments: projects whose toggle was off now have their
|
||||
project-level grants effective immediately — this is the intended effect of the decision.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Permission semantics shrink to the single P6 rule: `effective = max(grants on self ∪
|
||||
ancestors for user ∪ resolved groups)`, no exceptions by node kind.
|
||||
- One less state dimension in tests and in the admin UI.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR 0031: File Library Recycle Bin And Recent-Visit Tracking
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
The teacher app (`/app`) gains a left navigation rail with three entries: 文件库 /
|
||||
最近打开 / 回收站. Two of them need semantics that no prior decision covers:
|
||||
|
||||
- **回收站 (recycle bin)**: D15 defined soft delete (mark `deletedAt` on the node only;
|
||||
a node is invisible when any ancestor is deleted) but never defined listing, restore,
|
||||
or permanent deletion.
|
||||
- **最近打开 (recent visits)**: nothing tracks opens.
|
||||
|
||||
## Decision
|
||||
|
||||
### Recycle bin
|
||||
|
||||
- **List**: shows nodes with `deletedAt != null` whose **ancestors are all active**
|
||||
(the topmost deleted node per branch; descendants of a deleted node are represented
|
||||
by it and not listed separately).
|
||||
- **Visibility/auth**: a bin entry is visible to (a) the website administrator, or
|
||||
(b) any actor holding an active MANAGE grant **on the deleted node itself**
|
||||
(grants stay live through soft delete, so this is a plain grant query — no chain
|
||||
walk, no inheritance; the bin is a management surface, not a browsing surface).
|
||||
- **Restore** clears `deletedAt` on that node only (D15 symmetry: delete marks one
|
||||
node, restore unmarks one node). The subtree becomes visible again immediately.
|
||||
Same auth as the list entry. Audited (`folder_restore` / `project_restore`).
|
||||
- **Permanent delete (彻底删除)** is **website-administrator only**: hard-deletes the
|
||||
node **and its whole subtree** (descendants enumerated via the `pathIds` materialized
|
||||
path, deleted deepest-first because the self-FK is `ON DELETE RESTRICT`), in one
|
||||
transaction, with one audit entry (`node_purge`, detail carries removed count).
|
||||
Grants/settings/export-jobs cascade. There is no recovery; the UI must confirm
|
||||
explicitly.
|
||||
|
||||
### Recent visits
|
||||
|
||||
- **Model**: `FileLibRecentVisit(organizationId, userId, nodeId, filePath, openedAt)`,
|
||||
unique on `(organizationId, userId, nodeId, filePath)` with `filePath` defaulting to
|
||||
`""` (Postgres unique indexes treat NULLs as distinct). `filePath = ""` means the
|
||||
visit is the node itself (drill into folder/project); non-empty means a file preview
|
||||
inside that project.
|
||||
- **Recording is client-driven**: the teacher app POSTs after a successful open
|
||||
(folder drill, project open, file preview). The endpoint requires VIEW on the node
|
||||
(D8: no VIEW → 404, leaking nothing). Upsert semantics: re-opening refreshes
|
||||
`openedAt`. No audit entries — this is a per-user read model, not a权限-sensitive
|
||||
mutation.
|
||||
- **List**: the actor's own most recent 20, `openedAt` desc. Entries whose node is
|
||||
deleted **or has any deleted ancestor** are filtered out (D8/D15 visibility holds
|
||||
on every surface). Names are read live from `FileLibNode` (no denormalization).
|
||||
|
||||
## Consequences
|
||||
|
||||
- No change to existing permission algebra; both features are additive surfaces.
|
||||
- The bin deliberately does not offer per-owner bins or inherited-MANAGE visibility —
|
||||
if real usage demands it, that is a new decision.
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR 0032: Remove The Recent-Visit Module
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. **Supersedes the "Recent visits" half of ADR-0031** (the recycle-bin half
|
||||
is unaffected and remains in force).
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0031 (same day) introduced 最近打开: a `FileLibRecentVisit` table, client-driven
|
||||
visit recording, and a rail entry in the teacher app. After seeing it live, the product
|
||||
call is that the module is not wanted — it adds a tracking surface, a table, and rail
|
||||
noise without a compelling teacher workflow behind it.
|
||||
|
||||
## Decision
|
||||
|
||||
The recent-visit module is removed end-to-end:
|
||||
|
||||
- `FileLibRecentVisit` is dropped (hand-written migration
|
||||
`20260731090000_drop_filelib_recent_visit`; the table was created the same day and
|
||||
held no production data).
|
||||
- `recentService` / `recentRoutes` (`/database/api/recent`) and the `RecentView`
|
||||
component are deleted; the rail in `/app` keeps only 文件库 / 回收站.
|
||||
- `GridLibraryView` visit recording and the `navTarget` navigation entry go with it.
|
||||
- The `role` field added to breadcrumb entries for ADR-0031 is **kept** — it is a
|
||||
cheap, additive field on an existing API and independent of the removed module.
|
||||
|
||||
If recent-visit tracking comes back as a requirement, it is a new decision (and
|
||||
should then define why client-driven tracking is worth its surface) rather than a
|
||||
revival of this one.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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.
|
||||
+17
-1
@@ -23,12 +23,18 @@ 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="1048576"
|
||||
HUB_HTTP_BODY_LIMIT_BYTES="73400320"
|
||||
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.
|
||||
@@ -45,8 +51,18 @@ 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"
|
||||
|
||||
Generated
+302
-47
@@ -7,6 +7,17 @@
|
||||
"": {
|
||||
"name": "filelib-web",
|
||||
"version": "0.1.0",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
@@ -19,11 +30,161 @@
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-markdown": {
|
||||
"version": "6.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.1.tgz",
|
||||
"integrity": "sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.7.1",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/markdown": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
|
||||
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -35,7 +196,6 @@
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -46,7 +206,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -103,11 +262,94 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
|
||||
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/markdown": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz",
|
||||
"integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
|
||||
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -126,7 +368,6 @@
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
@@ -146,7 +387,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -163,7 +403,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -180,7 +419,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -197,7 +435,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -214,7 +451,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -231,7 +467,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -251,7 +486,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -271,7 +505,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -291,7 +524,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -311,7 +543,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -331,7 +562,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -351,7 +581,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -368,7 +597,6 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -387,7 +615,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -404,7 +631,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -418,7 +644,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
@@ -808,7 +1033,6 @@
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -895,6 +1119,34 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror-lang-typst": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/codemirror-lang-typst/-/codemirror-lang-typst-0.4.0.tgz",
|
||||
"integrity": "sha512-jpHz5qQRC3LE48JH+C24qZJAEAhjqRzWA4MFodPaxriz7UwLVuTLCQ672rdDx9ziObBQ2BvHaRIm5/Upz4KoBQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.11.2",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.1",
|
||||
"@lezer/common": "^1.2.3",
|
||||
"@lezer/highlight": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -905,6 +1157,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -919,7 +1177,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -975,7 +1232,6 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -993,7 +1249,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1025,7 +1280,7 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
@@ -1045,7 +1300,6 @@
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
@@ -1078,7 +1332,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1099,7 +1352,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1120,7 +1372,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1141,7 +1392,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1162,7 +1412,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1183,7 +1432,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1207,7 +1455,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -1231,7 +1478,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
@@ -1255,7 +1501,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
@@ -1279,7 +1524,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1300,7 +1544,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1355,7 +1598,6 @@
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1388,14 +1630,12 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -1408,7 +1648,6 @@
|
||||
"version": "8.5.22",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
|
||||
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1451,7 +1690,6 @@
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
@@ -1520,12 +1758,17 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.56.7",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.7.tgz",
|
||||
@@ -1604,7 +1847,6 @@
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -1631,7 +1873,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
@@ -1653,7 +1894,6 @@
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
@@ -1727,6 +1967,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-wasm": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz",
|
||||
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
||||
@@ -1747,6 +1996,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
|
||||
@@ -20,5 +20,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<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>
|
||||
@@ -0,0 +1,121 @@
|
||||
<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>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts" module>
|
||||
import type { IconName } from "./Icon.svelte";
|
||||
|
||||
export interface MenuItem {
|
||||
readonly label: string;
|
||||
readonly icon?: IconName;
|
||||
readonly danger?: boolean;
|
||||
readonly onclick: () => void;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 通用右键菜单:光标处弹出,点任意处/再次右键关闭。
|
||||
* 位置做视口夹取(右侧/底部溢出时向内收)。
|
||||
*/
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
let { x, y, items, onclose }: { x: number; y: number; items: readonly MenuItem[]; onclose: () => void } = $props();
|
||||
|
||||
const MENU_W = 178;
|
||||
const ITEM_H = 34;
|
||||
const px = $derived(
|
||||
typeof window === "undefined" ? x : Math.max(4, Math.min(x, window.innerWidth - MENU_W - 8)),
|
||||
);
|
||||
const py = $derived(
|
||||
typeof window === "undefined" ? y : Math.max(4, Math.min(y, window.innerHeight - items.length * ITEM_H - 20)),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50"
|
||||
role="presentation"
|
||||
onclick={onclose}
|
||||
oncontextmenu={(e) => { e.preventDefault(); onclose(); }}
|
||||
>
|
||||
<div
|
||||
class="fixed rounded-xl border border-line-soft bg-panel py-1.5 shadow-[0_8px_28px_rgba(26,26,24,.12)]"
|
||||
style="left:{px}px;top:{py}px;width:{MENU_W}px"
|
||||
>
|
||||
{#each items as item (item.label)}
|
||||
<button
|
||||
class="flex w-full items-center gap-2.5 px-3.5 py-[7px] text-left text-[12.5px] transition {item.danger
|
||||
? 'text-danger hover:bg-hover'
|
||||
: 'text-ink hover:bg-hover'}"
|
||||
onclick={() => { onclose(); item.onclick(); }}
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class={item.danger ? "" : "text-ink-3"}><Icon name={item.icon} size={14} /></span>
|
||||
{/if}
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +1,22 @@
|
||||
<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 { 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("");
|
||||
@@ -15,6 +26,7 @@
|
||||
let history = $state<VersionInfo[]>([]);
|
||||
|
||||
const canEdit = $derived(role !== "VIEW");
|
||||
const filename = $derived(path.split("/").pop() ?? "");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
@@ -35,6 +47,11 @@
|
||||
|
||||
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",
|
||||
@@ -81,6 +98,7 @@
|
||||
toastOk("已删除");
|
||||
file = null;
|
||||
onchanged();
|
||||
onclose();
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
@@ -104,58 +122,80 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loadError}
|
||||
<div class="panel text-xs text-danger">{loadError}</div>
|
||||
{:else if file}
|
||||
<div class="panel">
|
||||
<div class="mb-2.5 flex items-center justify-between">
|
||||
<span class="file-meta">{file.path} @ {file.version}</span>
|
||||
<!-- 主编辑器模态框:宽屏 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">
|
||||
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>下载</a>
|
||||
<button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button>
|
||||
{#if canEdit}
|
||||
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除文件</button>
|
||||
{/if}
|
||||
{#if onclose}
|
||||
<button class="btn !px-2.5" onclick={onclose} title="关闭预览" aria-label="关闭预览">✕</button>
|
||||
{#if 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>
|
||||
|
||||
{#if file.encoding === "base64"}
|
||||
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
|
||||
{:else}
|
||||
<textarea rows="14" class="textarea !leading-7" bind:value={draft} readonly={!canEdit}></textarea>
|
||||
{/if}
|
||||
<!-- 编辑器主体 -->
|
||||
<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 canEdit && file.encoding !== "base64"}
|
||||
<div class="mt-3 flex justify-end">
|
||||
{#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}
|
||||
|
||||
{#if conflict}
|
||||
<div class="mt-3.5 rounded-xl border border-[#E8E2C8] bg-[#FCFBF4] p-4">
|
||||
<div class="mb-2 text-[13px] font-semibold text-[#6E6329]">冲突:他人已提交 {conflict.currentVersion},差异如下(你的基版 → 最新版)</div>
|
||||
<pre class="diff rounded-lg border border-line-soft bg-panel p-3">{@html renderDiff(conflict.diff)}</pre>
|
||||
<div class="mt-2 text-[11.5px] text-[#8A8059]">请人工合并后,以最新内容为全文重新提交(基版将更新为 {conflict.currentVersion})</div>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button class="btn" onclick={acceptLatest}>载入最新内容</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panel quiet">加载中…</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showHistory}
|
||||
<Modal title="版本历史" onclose={() => (showHistory = false)}>
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
{#each history as v (v.version)}
|
||||
<div class="border-t border-line-soft py-2 text-xs first:border-t-0">
|
||||
<span class="font-mono text-accent">{v.version}</span> {v.message}
|
||||
<div class="text-ink-3">{new Date(v.committedAt).toLocaleString("zh-CN")}{v.author ? " · " + v.author : ""}</div>
|
||||
<div 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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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";
|
||||
@@ -13,11 +14,56 @@
|
||||
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`);
|
||||
@@ -34,17 +80,86 @@
|
||||
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",
|
||||
body: { path, content: newContent },
|
||||
// message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
|
||||
body: message === "" ? { path, content: newContent } : { path, content: newContent, message },
|
||||
});
|
||||
toastOk("已创建");
|
||||
showNewFile = false;
|
||||
newPath = ""; newContent = "";
|
||||
newPath = ""; newContent = ""; newMessage = "";
|
||||
await loadFiles();
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
@@ -60,28 +175,51 @@
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
async function doUpload(e: Event): Promise<void> {
|
||||
/** 选文件只负责暂存与预填;真正上传在弹窗确认后。 */
|
||||
function pickFile(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toastErr("文件超过 10MB 上限");
|
||||
// 上限取后端值;拉不到就不在前端拦 —— 后端横竖会以 413 file_too_large 兼底,
|
||||
// 前端这道只是省一次往返。
|
||||
if (maxFileBytes !== null && file.size > maxFileBytes) {
|
||||
toastErr(`文件超过 ${maxLabel} 上限`);
|
||||
return;
|
||||
}
|
||||
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
|
||||
if (!targetPath) return;
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const isBinary = bytes.includes(0);
|
||||
const body = isBinary
|
||||
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
|
||||
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
|
||||
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);
|
||||
toastOk(`已上传 ${file.name}`);
|
||||
cancelUpload();
|
||||
await loadFiles();
|
||||
} catch (err) {
|
||||
toastErr(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -91,9 +229,9 @@
|
||||
<div class="section-title">项目文件({files?.length ?? 0})</div>
|
||||
{#if canEdit}
|
||||
<div class="flex gap-1.5">
|
||||
<button class="btn" onclick={() => (showNewFile = true)}><Icon name="plus" size={13} /> 新建文件</button>
|
||||
<button class="btn" 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={doUpload} />
|
||||
<input bind:this={uploadInput} type="file" class="hidden" onchange={pickFile} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -104,20 +242,91 @@
|
||||
<div class="py-5 text-center text-xs text-danger">{loadError}</div>
|
||||
{:else if files && files.length === 0}
|
||||
<div class="quiet py-5 text-center">空仓库 · 可新建或上传文件</div>
|
||||
{:else if files}
|
||||
<table class="list">
|
||||
<tbody>
|
||||
{#each files as f (f.path)}
|
||||
<tr
|
||||
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
|
||||
{: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)}
|
||||
>
|
||||
<td class="font-mono text-[12.5px] text-ink">{f.path}</td>
|
||||
<td class="file-meta text-right">{f.size} B</td>
|
||||
</tr>
|
||||
<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}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -131,9 +340,36 @@
|
||||
<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}
|
||||
|
||||
@@ -1,37 +1,70 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 节点授权面板。迁自已删除的 routes/libraryBrowser.ts `renderGrantsTab`(ADR-0029)。
|
||||
* 节点授权面板(表格化改版)。迁自已删除的 routes/libraryBrowser.ts
|
||||
* `renderGrantsTab`(ADR-0029)。
|
||||
*
|
||||
* 迁移时整个「授权」tab 连同这四个端点一起漏掉了 —— 后端一直可用,只是前端没入口。
|
||||
* 布局:顶部工具条(左:授权成员搜索框;右:「+ 添加授权」弹窗入口);
|
||||
* 下方一行一条授权 —— 成员(名称+id,点击跳用户管理/Group 管理)、
|
||||
* 类型(个人/Group)、权限(下拉可改)、加入时间、操作(删除)。
|
||||
*
|
||||
* 语义(契约 8.1 / ADR-0021):
|
||||
* 语义(契约 8.1 / ADR-0021 / ADR-0028):
|
||||
* - 创建者授权(isCreatorGrant)不可收回、不可改;
|
||||
* - MANAGE 仅创建者可授,这里不做前端拦截 —— 后端 fail closed,报错原样呈现;
|
||||
* - GROUP 主体走 in-hub MemberGroup(ADR-0028),用 /groups/search 选,不手敲 id。
|
||||
* - 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 { currentNode } from "./browser.js";
|
||||
import type { Grant, MemberGroupSearchResult, NodeDetail, Role } from "./types.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 userIdInput = $state("");
|
||||
let groupId = $state("");
|
||||
let groupOptions = $state<MemberGroupSearchResult[] | null>(null);
|
||||
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();
|
||||
@@ -48,33 +81,102 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 切到 GROUP 时懒加载候选组(活跃组 + breadcrumb)。 */
|
||||
async function onTypeChange(): Promise<void> {
|
||||
if (principalType !== "GROUP" || groupOptions !== null) return;
|
||||
/** 成员单元格跳转: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 {
|
||||
const r = await api<{ groups: MemberGroupSearchResult[] }>("/database/api/groups/search?q=");
|
||||
groupOptions = r.groups;
|
||||
if (r.groups.length > 0 && groupId === "") groupId = r.groups[0]!.id;
|
||||
} catch (e) {
|
||||
toastErr(errText(e));
|
||||
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 = principalType === "GROUP" ? groupId : userIdInput.trim();
|
||||
const principalId = selectedPrincipal?.id ?? manualId.trim();
|
||||
if (principalId === "") {
|
||||
toastErr("请填写主体");
|
||||
toastErr("请选择或填写授权主体");
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
// PUT /grants 是增量语义(putGrants),不是整表替换。
|
||||
// PUT /grants 是 upsert 语义(putGrants):同主体已有授权则改级别,否则新建。
|
||||
await api(`/database/api/nodes/${node.id}/grants`, {
|
||||
method: "PUT",
|
||||
body: { grants: [{ principalType, principalId, role }] },
|
||||
});
|
||||
toastOk("已授予");
|
||||
userIdInput = "";
|
||||
showAdd = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
toastErr(errText(e));
|
||||
@@ -83,30 +185,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 权限下拉改级别:复用 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.principalId}」的 ${g.role} 授权?`)) return;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/** 独立权限开关(仅 PROJECT;关闭时只继承父级,创建者除外)。 */
|
||||
async function toggleIndependent(): Promise<void> {
|
||||
try {
|
||||
await api(`/database/api/projects/${node.id}/independent-permission`, {
|
||||
method: "PUT",
|
||||
body: { enabled: !node.independentPermission },
|
||||
});
|
||||
toastOk("已切换");
|
||||
currentNode.update((n) =>
|
||||
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
|
||||
);
|
||||
toastOk("已删除");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toastErr(errText(e));
|
||||
@@ -115,34 +216,92 @@
|
||||
</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 grants === null}
|
||||
{:else if shown === null}
|
||||
<div class="quiet py-[18px] text-center">加载中…</div>
|
||||
{:else}
|
||||
<table class="list">
|
||||
<!-- 内容不可断行(头像名/id/日期/下拉)可能超宽:溢出时横向滚动,
|
||||
而不是把表格挤变形或顶出 panel 右边界(文件预览打开时主区变窄)。 -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="list">
|
||||
<thead>
|
||||
<tr><th>主体</th><th>级别</th><th></th></tr>
|
||||
<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 grants.length === 0}
|
||||
<tr><td colspan="3" class="quiet !py-[18px] text-center">暂无显式授权</td></tr>
|
||||
{#if shown.length === 0}
|
||||
<tr>
|
||||
<td colspan="7" class="quiet !py-[18px] text-center">
|
||||
{searchText.trim() === "" ? "暂无授权" : `无匹配「${searchText.trim()}」的授权`}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each grants as g (g.id)}
|
||||
{#each shown as g (g.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="flex text-ink-3"><Icon name={g.principalType === "USER" ? "user" : "group"} size={14} /></span>
|
||||
<span class="font-mono text-[12px]">{g.principalId}</span>
|
||||
{#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if}
|
||||
</span>
|
||||
<td 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">{g.role}</td>
|
||||
<td class="text-right">
|
||||
<!-- 创建者授权不可动(契约 8.1);非 MANAGE 也不给收回入口。 -->
|
||||
<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="minus" size={12} /> 收回
|
||||
<Icon name="trash" size={12} /> 删除
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
@@ -150,42 +309,73 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if canManage}
|
||||
<div class="my-3.5 border-t border-line-soft"></div>
|
||||
<div class="section-title mb-2.5">新增授权</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select class="select !w-[110px]" bind:value={principalType} onchange={onTypeChange}>
|
||||
<option value="USER">用户</option>
|
||||
{#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>
|
||||
|
||||
{#if principalType === "USER"}
|
||||
<input class="input min-w-0 flex-1" placeholder="用户 id" bind:value={userIdInput} />
|
||||
{:else if groupOptions === null}
|
||||
<span class="quiet flex-1">加载 Group 列表…</span>
|
||||
{:else if groupOptions.length === 0}
|
||||
<span class="quiet flex-1">暂无可选 Group · 先到「Group 管理」建一个</span>
|
||||
{:else}
|
||||
<select class="select min-w-0 flex-1" bind:value={groupId}>
|
||||
{#each groupOptions as g (g.id)}
|
||||
<option value={g.id}>{g.breadcrumb}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<select class="select !w-[110px]" bind:value={role}>
|
||||
</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}>{r}</option>
|
||||
<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 ? "授予中…" : "授予"}
|
||||
{saving ? "授予中…" : "添加"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="section-note mt-1.5">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 网盘式大图标卡片:文件夹(琥珀填充)/项目(立方体描边)/文件(文档描边)。
|
||||
* 单击选中、onopen(双击)、oncontextmenu(右键,回传光标坐标)。
|
||||
*/
|
||||
let {
|
||||
kind,
|
||||
name,
|
||||
meta = null,
|
||||
selected = false,
|
||||
onselect,
|
||||
onopen,
|
||||
oncontextmenu,
|
||||
}: {
|
||||
kind: "FOLDER" | "PROJECT" | "FILE";
|
||||
name: string;
|
||||
meta?: string | null;
|
||||
selected?: boolean;
|
||||
onselect: () => void;
|
||||
onopen: () => void;
|
||||
oncontextmenu: (x: number, y: number) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="flex cursor-pointer flex-col items-center gap-1.5 rounded-xl px-2 pb-2 pt-3 select-none {selected
|
||||
? 'bg-selected'
|
||||
: 'hover:bg-hover'}"
|
||||
onclick={onselect}
|
||||
ondblclick={onopen}
|
||||
oncontextmenu={(e) => { e.preventDefault(); 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>
|
||||
@@ -0,0 +1,527 @@
|
||||
<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}
|
||||
@@ -5,6 +5,7 @@
|
||||
* 归档组展示与恢复 / 右键菜单 / 面包屑 / 统计条 / 成员表(头像·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";
|
||||
@@ -172,7 +173,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadGroups);
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<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>
|
||||
@@ -16,9 +16,22 @@
|
||||
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;
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import { api } from "./api.js";
|
||||
import { me, toastErr, toastOk } from "./stores.js";
|
||||
import { logout } from "./session.js";
|
||||
import { treeVersion, bumpTree, currentNode, selectedFilePath, clearSelectedFile, bumpFiles } from "./browser.js";
|
||||
import type { NodeChild } from "./types.js";
|
||||
import { 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";
|
||||
@@ -35,7 +35,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadRoots);
|
||||
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();
|
||||
@@ -63,7 +78,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase());
|
||||
const initial = $derived((($me?.displayName ?? $me?.userId) ?? "U").slice(0, 1).toUpperCase());
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
@@ -79,10 +94,10 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-2 py-2 text-[13px]">
|
||||
{#if roots === null}
|
||||
<div class="px-3 py-6 text-center text-xs text-ink-3">加载中…</div>
|
||||
{:else if treeError}
|
||||
{#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 ? "空文件库 · 点上方「+ 根目录」开始" : "文件库为空,请联系管理员创建根目录"}
|
||||
@@ -97,7 +112,8 @@
|
||||
{#if showUserFooter}
|
||||
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
|
||||
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
|
||||
<span class="flex-1 truncate text-ink">{$me?.userId ?? ""}</span>
|
||||
<!-- 展示名优先;/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}
|
||||
@@ -108,17 +124,15 @@
|
||||
<NodeDetailPanel />
|
||||
</main>
|
||||
|
||||
<!-- 右侧:文件预览/编辑栏(选中文件时出现) -->
|
||||
<!-- 文件编辑器(模态框) -->
|
||||
{#if $selectedFilePath && $currentNode?.kind === "PROJECT"}
|
||||
<section class="flex w-[46%] min-w-[420px] shrink-0 flex-col overflow-y-auto border-l border-line-soft bg-bg p-4">
|
||||
<FileEditor
|
||||
projectId={$currentNode.id}
|
||||
path={$selectedFilePath}
|
||||
role={$currentNode.role}
|
||||
onchanged={bumpFiles}
|
||||
onclose={clearSelectedFile}
|
||||
/>
|
||||
</section>
|
||||
<FileEditor
|
||||
projectId={$currentNode.id}
|
||||
path={$selectedFilePath}
|
||||
role={$currentNode.role}
|
||||
onchanged={bumpFiles}
|
||||
onclose={clearSelectedFile}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
|
||||
let { title, onclose, children, maxW = 440 }: { title: string; onclose: () => void; children: Snippet; maxW?: number } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -9,7 +9,7 @@
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
||||
>
|
||||
<div class="w-full max-w-[440px] rounded-2xl border border-line-soft bg-panel p-6 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
|
||||
<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>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { api } from "./api.js";
|
||||
import { currentNode, breadcrumb, bumpTree, clearSelectedFile } from "./browser.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" | "grants";
|
||||
let tab = $state<Tab>("detail");
|
||||
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");
|
||||
@@ -20,21 +27,38 @@
|
||||
const canManage = $derived(node?.role === "MANAGE");
|
||||
const canEdit = $derived(canManage || node?.role === "EDIT");
|
||||
|
||||
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;授权仅 MANAGE
|
||||
// 与旧 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(() => {
|
||||
void node?.id;
|
||||
tab = "detail";
|
||||
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;
|
||||
@@ -87,7 +111,7 @@
|
||||
{#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-[880px] px-9 py-9">
|
||||
<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}
|
||||
@@ -99,7 +123,7 @@
|
||||
<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">{node.role}</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"}
|
||||
@@ -129,6 +153,8 @@
|
||||
<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"}
|
||||
|
||||
@@ -2,38 +2,42 @@
|
||||
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 }: { node: NodeDetail } = $props();
|
||||
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(node.role === "MANAGE" ? "可管理" : node.role === "EDIT" ? "可编辑" : "只读");
|
||||
const roleLabel = $derived(ROLE_LABEL[node.role]);
|
||||
|
||||
/** 独立权限开关(仅 PROJECT;关闭时只继承父级权限,创建者除外)。 */
|
||||
async function toggleIndependent(): Promise<void> {
|
||||
/** 删除(进回收站,可恢复;ADR-0031)。MANAGE 专属,与右键菜单同语义。 */
|
||||
async function deleteNode(): Promise<void> {
|
||||
if (!confirm(`删除「${node.name}」?移入回收站,可在回收站恢复。`)) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await api(`/database/api/projects/${node.id}/independent-permission`, {
|
||||
method: "PUT",
|
||||
body: { enabled: !node.independentPermission },
|
||||
});
|
||||
toastOk("已切换");
|
||||
currentNode.update((n) =>
|
||||
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
|
||||
);
|
||||
await 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 {
|
||||
@@ -57,31 +61,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function submitExport(): Promise<void> {
|
||||
/**
|
||||
* 导出 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: "manifest" },
|
||||
body: { target: "pdf" },
|
||||
});
|
||||
toastOk("导出已提交");
|
||||
void pollExport(r.jobId);
|
||||
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 {
|
||||
const job = await api<ExportJob>(`/database/api/exports/${jobId}`);
|
||||
exportJob = job;
|
||||
if (job.status === "DONE" || job.status === "FAILED") break;
|
||||
} catch {
|
||||
break;
|
||||
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">
|
||||
@@ -108,37 +144,36 @@
|
||||
<div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
|
||||
</div>
|
||||
|
||||
<!-- 独立权限与导出都只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
|
||||
<!-- 导出只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
|
||||
{#if node.kind === "PROJECT"}
|
||||
<div class="my-4 border-t border-line-soft"></div>
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<span class="quiet">独立权限</span>
|
||||
<b class="text-[13px]">{node.independentPermission ? "开启" : "关闭"}</b>
|
||||
{#if canManage}
|
||||
<button class="btn" onclick={toggleIndependent}>{node.independentPermission ? "关闭" : "开启"}</button>
|
||||
{/if}
|
||||
<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>
|
||||
</div>
|
||||
|
||||
<div class="my-4 border-t border-line-soft"></div>
|
||||
|
||||
<div class="section-title mb-2">导出</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<select class="select !w-auto"><option value="manifest">manifest(stub)</option></select>
|
||||
<button class="btn" onclick={submitExport}>开始导出</button>
|
||||
{#if exportJob}
|
||||
<button class="btn" onclick={exportPdf} disabled={exporting}>
|
||||
<Icon name="download" size={13} />
|
||||
{exporting ? "导出中…" : "导出 PDF"}
|
||||
</button>
|
||||
{#if exportJob?.status === "DONE"}
|
||||
<span class="file-meta">
|
||||
{#if exportJob.status === "DONE"}
|
||||
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
|
||||
{:else if exportJob.status === "FAILED"}
|
||||
失败:{exportJob.error ?? ""}
|
||||
{:else}
|
||||
{exportJob.status}…
|
||||
{/if}
|
||||
完成 · <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}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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();
|
||||
@@ -64,7 +65,7 @@
|
||||
</span>
|
||||
<span class="truncate">{node.name}</span>
|
||||
{#if node.role !== "MANAGE"}
|
||||
<span class="ml-auto pr-1 font-mono text-[10px] text-ink-3">{node.role}</span>
|
||||
<span class="ml-auto pr-1 text-[10px] text-ink-3">{ROLE_LABEL[node.role]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,62 @@
|
||||
import { writable } from "svelte/store";
|
||||
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());
|
||||
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>(null);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** 展示层文案(与 API 枚举值解耦;传参仍用英文枚举)。 */
|
||||
|
||||
import type { Role } from "./types.js";
|
||||
|
||||
/** 文件库权限级(契约 8.1 MANAGE>EDIT>VIEW)的中文展示名。 */
|
||||
export const ROLE_LABEL: Record<Role, string> = {
|
||||
VIEW: "只读",
|
||||
EDIT: "可编辑",
|
||||
MANAGE: "可管理",
|
||||
};
|
||||
@@ -18,6 +18,8 @@ export interface BreadcrumbEntry {
|
||||
readonly id: string | null;
|
||||
readonly name: string | null;
|
||||
readonly kind: NodeKind;
|
||||
/** 该节点对调用者的 effective role;无 View 为 null。 */
|
||||
readonly role: Role | null;
|
||||
}
|
||||
|
||||
export interface NodeDetail {
|
||||
@@ -28,7 +30,6 @@ export interface NodeDetail {
|
||||
readonly description: string | null;
|
||||
readonly role: Role;
|
||||
readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED";
|
||||
readonly independentPermission: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
@@ -63,6 +64,15 @@ export interface VersionInfo {
|
||||
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;
|
||||
@@ -72,21 +82,13 @@ export interface ExportJob {
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface Grant {
|
||||
readonly id: string;
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
readonly role: Role;
|
||||
readonly isCreatorGrant: boolean;
|
||||
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;
|
||||
@@ -114,6 +116,10 @@ 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;
|
||||
@@ -136,9 +142,17 @@ export interface UserSearchResult {
|
||||
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;
|
||||
export interface DashboardStats { readonly folders: number;
|
||||
readonly projects: number;
|
||||
readonly files: number;
|
||||
readonly grants: number;
|
||||
|
||||
@@ -1,21 +1,70 @@
|
||||
<script lang="ts">
|
||||
/** 老师端。未登录显示登录卡片;登录后直接是文件库浏览器。 */
|
||||
/** 老师端。未登录显示登录卡片;登录后是带左栏导航的文件库(ADR-0031)。 */
|
||||
import { onMount } from "svelte";
|
||||
import { me, authChecked } from "$lib/stores.js";
|
||||
import { loadSession } from "$lib/session.js";
|
||||
import { loadSession, logout } from "$lib/session.js";
|
||||
import LoginView from "$lib/LoginView.svelte";
|
||||
import LibraryView from "$lib/LibraryView.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>
|
||||
<svelte:head><title>教研数据库</title></svelte:head>
|
||||
|
||||
{#if !$authChecked}
|
||||
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
|
||||
{:else if $me}
|
||||
<div class="flex h-full flex-col">
|
||||
<LibraryView showUserFooter />
|
||||
<div 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 />
|
||||
|
||||
@@ -28,9 +28,18 @@
|
||||
|
||||
const BASE = "/database/dashboard";
|
||||
|
||||
onMount(async () => {
|
||||
await loadSession();
|
||||
if ($me === null) void goto("/database/admin", { replaceState: true });
|
||||
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 {
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
* 这里管的是 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: "所有者",
|
||||
@@ -23,6 +25,9 @@
|
||||
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");
|
||||
@@ -30,6 +35,19 @@
|
||||
|
||||
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 {
|
||||
@@ -121,14 +139,28 @@
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="section-title mb-2.5">成员列表</div>
|
||||
<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 members === null}
|
||||
{:else if shown === null}
|
||||
<div class="quiet py-[18px] text-center">加载中…</div>
|
||||
{:else if members.length === 0}
|
||||
<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>
|
||||
@@ -140,7 +172,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each members as m (m.userId)}
|
||||
{#each shown as m (m.userId)}
|
||||
<tr>
|
||||
<td class="text-ink">{m.displayName || m.userId}</td>
|
||||
<td class="file-meta">{m.userId}</td>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// 老师端 /app + 管理后台 /database/* 的唯一前端工程;构建产物由 hub 后端静态托管。
|
||||
@@ -8,7 +9,7 @@ import { defineConfig } from "vite";
|
||||
const backend = "http://127.0.0.1:8788";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
plugins: [wasm(), tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
-- 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";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- ADR-0032:最近打开模块移除,删表(今日新建,无生产数据)。
|
||||
DROP TABLE "FileLibRecentVisit";
|
||||
@@ -268,9 +268,30 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/auth/logout", async (_request, reply) => {
|
||||
reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
|
||||
return reply.status(204).send();
|
||||
// 退出登录不读 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.get("/auth/feishu/complete", async (request, reply) => {
|
||||
|
||||
@@ -110,7 +110,8 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
| `filelib/grantService.ts` | 授权管理 + 契约 8.1 矩阵强制 + force_adjust |
|
||||
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
|
||||
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
|
||||
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) |
|
||||
| `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) |
|
||||
@@ -125,9 +126,20 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub
|
||||
MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team)
|
||||
|
||||
> ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**,
|
||||
> 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的
|
||||
> 持久化 git 包到位后此问题消失。
|
||||
## 存储布局(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),目前不可达。
|
||||
|
||||
关键语义速查:
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export const FILE_LIB_AUDIT_ACTIONS = {
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 回收站(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 };
|
||||
});
|
||||
}
|
||||
@@ -7,11 +7,18 @@
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -49,8 +56,146 @@ export function createManifestStubAdapter(versionStore: FileDeps["versionStore"]
|
||||
};
|
||||
}
|
||||
|
||||
/** `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[];
|
||||
@@ -136,7 +281,7 @@ async function runExportJob(
|
||||
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
|
||||
readFile: (path) => deps.versionStore.read(storageDir, path),
|
||||
});
|
||||
artifacts.set(jobId, artifact);
|
||||
retainArtifact(jobId, artifact);
|
||||
await deps.prisma.fileLibExportJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` },
|
||||
|
||||
@@ -10,14 +10,43 @@
|
||||
|
||||
import { FileLibError } from "./model.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
import type { CommitResult, FileEntry, VersionInfo, VersionStore } from "./versionStore.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;
|
||||
export const FILE_CONTENT_MAX_BYTES = 10 * 1024 * 1024; // OPEN-5 初值
|
||||
|
||||
/** 单文件上限的出厂默认值(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"]);
|
||||
@@ -52,6 +81,8 @@ export function validateFilePath(raw: string): string {
|
||||
export interface FileDeps extends AccessDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly versionStore: VersionStore;
|
||||
/** 单文件字节上限。装配处用 `resolveMaxFileBytes()` 求值,缺省即出厂值。 */
|
||||
readonly maxFileBytes?: number | undefined;
|
||||
}
|
||||
|
||||
type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string };
|
||||
@@ -101,13 +132,25 @@ function encodeContent(buffer: Buffer): { readonly encoding: FileContentEncoding
|
||||
: { encoding: "utf8", content: buffer.toString("utf8") };
|
||||
}
|
||||
|
||||
function checkSize(content: string | Buffer): void {
|
||||
function checkSize(content: string | Buffer, maxBytes: number): void {
|
||||
const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
|
||||
if (bytes > FILE_CONTENT_MAX_BYTES) {
|
||||
throw new FileLibError(413, "file_too_large", `file exceeds ${FILE_CONTENT_MAX_BYTES} bytes`);
|
||||
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,
|
||||
@@ -185,6 +228,17 @@ export async function fileHistory(
|
||||
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,
|
||||
@@ -221,14 +275,21 @@ export async function commitFile(
|
||||
): Promise<{ readonly version: string }> {
|
||||
const filePath = validateFilePath(input.path);
|
||||
const content = decodeContent(input.content, input.encoding ?? "utf8");
|
||||
checkSize(content);
|
||||
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: input.message,
|
||||
author: actor.userId,
|
||||
message,
|
||||
// 身份:name=displayName(回退 userId),email=<userId>@域名(git 实现里拼)。
|
||||
author: { userId: actor.userId, displayName: actor.displayName },
|
||||
});
|
||||
|
||||
if (result.status === "conflict") {
|
||||
@@ -247,7 +308,7 @@ export async function commitFile(
|
||||
input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit,
|
||||
project,
|
||||
filePath,
|
||||
{ version: result.version, message: input.message ?? null },
|
||||
{ version: result.version, message },
|
||||
);
|
||||
return { version: result.version };
|
||||
}
|
||||
@@ -261,7 +322,10 @@ export async function deleteFile(
|
||||
): Promise<void> {
|
||||
const filePath = validateFilePath(rawPath);
|
||||
const project = await requireProject(deps, actor, projectId, "EDIT");
|
||||
const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion);
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,25 +25,88 @@ 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): GrantDto {
|
||||
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,
|
||||
@@ -66,7 +129,7 @@ export async function listGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return grants.map(toDto);
|
||||
return withPrincipalNames(deps.prisma, grants.map(toDto));
|
||||
}
|
||||
|
||||
export interface PutGrantsResult {
|
||||
@@ -137,7 +200,7 @@ export async function putGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: grants.map(toDto) };
|
||||
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,46 +299,7 @@ export async function forceAdjustGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: grants.map(toDto) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 项目独立权限开关(P5/D11):需 MANAGE;状态不变则空操作。 */
|
||||
export async function setIndependentPermission(
|
||||
deps: Deps,
|
||||
actor: FileLibActor,
|
||||
nodeId: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ readonly enabled: boolean }> {
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireManage(deps, actor, nodeId, tx);
|
||||
if (node.kind !== "PROJECT") {
|
||||
throw new FileLibError(400, "invalid_node_kind", "independent permission applies to projects only");
|
||||
}
|
||||
const current = await tx.fileLibProjectSettings.findUnique({
|
||||
where: { nodeId: node.id },
|
||||
select: { independentPermissionsEnabled: true },
|
||||
});
|
||||
if ((current?.independentPermissionsEnabled ?? false) === enabled) {
|
||||
return { enabled }; // 状态未变:空操作,不产生审计
|
||||
}
|
||||
await tx.fileLibProjectSettings.upsert({
|
||||
where: { nodeId: node.id },
|
||||
update: { independentPermissionsEnabled: enabled },
|
||||
create: { nodeId: node.id, independentPermissionsEnabled: enabled },
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: enabled
|
||||
? FILE_LIB_AUDIT_ACTIONS.independentEnable
|
||||
: FILE_LIB_AUDIT_ACTIONS.independentDisable,
|
||||
actorUserId: actor.userId,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "project",
|
||||
objectId: node.id,
|
||||
objectPath: node.pathIds,
|
||||
detail: { enabled },
|
||||
});
|
||||
return { enabled };
|
||||
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -43,5 +43,7 @@ export async function requireFileLibActor(
|
||||
return {
|
||||
userId: auth.user.id,
|
||||
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
|
||||
// 仅用于 commit message 的【用户名】;权限判定一律走 userId。
|
||||
displayName: auth.user.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 纯权限 reducer(契约 P6 / D11 / D8)。
|
||||
* 纯权限 reducer(契约 P6 / D8)。
|
||||
*
|
||||
* 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链
|
||||
* 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离
|
||||
@@ -23,8 +23,6 @@ export interface EffectiveRoleInput {
|
||||
readonly nodeKind: "FOLDER" | "PROJECT";
|
||||
/** 目标的全部祖先 id(不含 self,顺序无关)。 */
|
||||
readonly ancestorIds: readonly string[];
|
||||
/** 项目独立权限开关(D11/P5);文件夹忽略此值。 */
|
||||
readonly independentPermissionsEnabled: boolean;
|
||||
readonly userId: string;
|
||||
/** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */
|
||||
readonly groupIds: readonly string[];
|
||||
@@ -37,20 +35,16 @@ export interface EffectiveRoleInput {
|
||||
* r ∈ {R} ∪ ancestors(R) };无匹配 → null(无任何权限)。
|
||||
* "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。
|
||||
*
|
||||
* D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant
|
||||
* 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的
|
||||
* grant 不受开关影响。
|
||||
* 项目级 grant 恒参与计算(ADR-0030):原 D11 独立权限开关已废除,
|
||||
* FileLibProjectSettings 不再被读取。
|
||||
*/
|
||||
export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null {
|
||||
const onChain = new Set<string>([input.nodeId, ...input.ancestorIds]);
|
||||
const groups = new Set(input.groupIds);
|
||||
const freezeProjectGrants =
|
||||
input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled;
|
||||
|
||||
let best: FileLibRole | null = null;
|
||||
for (const grant of input.grants) {
|
||||
if (!onChain.has(grant.nodeId)) continue;
|
||||
if (freezeProjectGrants && grant.nodeId === input.nodeId && !grant.isCreatorGrant) continue;
|
||||
if (grant.principalType === "USER" && grant.principalId !== input.userId) continue;
|
||||
if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue;
|
||||
if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role;
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface FileLibRouteDeps {
|
||||
readonly groupResolver: GroupResolver;
|
||||
readonly versionStore: VersionStore;
|
||||
readonly exportAdapters: readonly ExportAdapter[];
|
||||
/** 单文件字节上限(`HUB_FILELIB_MAX_FILE_BYTES`)。 */
|
||||
readonly maxFileBytes: number;
|
||||
}
|
||||
|
||||
/** 组装 treeService 依赖(路由处理内直接使用)。 */
|
||||
|
||||
@@ -35,6 +35,11 @@ export interface FileLibActor {
|
||||
readonly userId: string;
|
||||
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
|
||||
readonly isWebsiteAdmin: boolean;
|
||||
/**
|
||||
* 展示名(飞书昵称)。只用于生成 commit message 的【用户名】部分;
|
||||
* 权限判定一律用 userId。缺失时回退到 userId。
|
||||
*/
|
||||
readonly displayName?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TreeServiceDeps {
|
||||
@@ -94,7 +99,7 @@ async function loadVisibleChain(
|
||||
return { node, ancestors: ordered };
|
||||
}
|
||||
|
||||
/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
|
||||
/** 数据获取层:把 chain、grants、groups 装配成纯 reducer 的输入。 */
|
||||
async function resolveRole(
|
||||
tx: Tx,
|
||||
deps: AccessDeps,
|
||||
@@ -106,20 +111,11 @@ async function resolveRole(
|
||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
||||
});
|
||||
let independentPermissionsEnabled = false;
|
||||
if (chain.node.kind === "PROJECT") {
|
||||
const settings = await tx.fileLibProjectSettings.findUnique({
|
||||
where: { nodeId: chain.node.id },
|
||||
select: { independentPermissionsEnabled: true },
|
||||
});
|
||||
independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false;
|
||||
}
|
||||
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||
return effectiveRole({
|
||||
nodeId: chain.node.id,
|
||||
nodeKind: chain.node.kind,
|
||||
ancestorIds: chain.ancestors.map((a) => a.id),
|
||||
independentPermissionsEnabled,
|
||||
userId: actor.userId,
|
||||
groupIds,
|
||||
grants,
|
||||
@@ -229,6 +225,8 @@ export async function createNode(
|
||||
}
|
||||
|
||||
if (input.kind === "PROJECT") {
|
||||
// ADR-0030:项目扁平居于同一根下、以 uuid 命名;名字不进路径(所以 rename
|
||||
// 不动磁盘)。FOLDER 永不赋值 —— 文件夹不落盘,只存在于 DB 的 parentId/pathIds。
|
||||
storageDir = path.join(deps.storageRoot, id);
|
||||
}
|
||||
|
||||
@@ -278,11 +276,6 @@ export async function createNode(
|
||||
},
|
||||
});
|
||||
}
|
||||
if (input.kind === "PROJECT") {
|
||||
await tx.fileLibProjectSettings.create({
|
||||
data: { nodeId: id, independentPermissionsEnabled: false },
|
||||
});
|
||||
}
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(input.kind, "Create"),
|
||||
@@ -308,6 +301,7 @@ export async function createNode(
|
||||
});
|
||||
|
||||
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
|
||||
// ADR-0030:这一步真的建目录并 `git init`;宿主无 git 则此处 provision_failed。
|
||||
if (input.kind === "PROJECT" && storageDir !== null) {
|
||||
try {
|
||||
await deps.versionStore.init(storageDir);
|
||||
@@ -457,10 +451,12 @@ export async function getEffectiveRole(
|
||||
|
||||
export interface BreadcrumbEntry {
|
||||
readonly depth: number;
|
||||
/** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */
|
||||
/** D17:无 View 的祖先 id/name 置为 null(不泄露)。 */
|
||||
readonly id: string | null;
|
||||
readonly name: string | null;
|
||||
readonly kind: "FOLDER" | "PROJECT";
|
||||
/** 该节点对调用者的 effective role;无 View 为 null。 */
|
||||
readonly role: FileLibRole | null;
|
||||
}
|
||||
|
||||
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
|
||||
@@ -482,20 +478,12 @@ export async function breadcrumb(
|
||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
||||
});
|
||||
const settings = chain.node.kind === "PROJECT"
|
||||
? await tx.fileLibProjectSettings.findUnique({
|
||||
where: { nodeId: chain.node.id },
|
||||
select: { independentPermissionsEnabled: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
return chainNodes.map((current, depth) => {
|
||||
const role = effectiveRole({
|
||||
nodeId: current.id,
|
||||
nodeKind: current.kind,
|
||||
ancestorIds: chainNodes.slice(0, depth).map((n) => n.id),
|
||||
independentPermissionsEnabled:
|
||||
current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false,
|
||||
userId: actor.userId,
|
||||
groupIds,
|
||||
grants: allGrants,
|
||||
@@ -506,6 +494,7 @@ export async function breadcrumb(
|
||||
id: visible ? current.id : null,
|
||||
name: visible ? current.name : null,
|
||||
kind: current.kind,
|
||||
role,
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -545,14 +534,6 @@ export async function listChildren(
|
||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } },
|
||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
||||
});
|
||||
const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id);
|
||||
const settingsRows = projectIds.length === 0
|
||||
? []
|
||||
: await tx.fileLibProjectSettings.findMany({
|
||||
where: { nodeId: { in: projectIds } },
|
||||
select: { nodeId: true, independentPermissionsEnabled: true },
|
||||
});
|
||||
const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled]));
|
||||
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||
|
||||
const out: ChildNodeDto[] = [];
|
||||
@@ -561,7 +542,6 @@ export async function listChildren(
|
||||
nodeId: child.id,
|
||||
nodeKind: child.kind,
|
||||
ancestorIds: parentAncestorIds,
|
||||
independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
|
||||
userId: actor.userId,
|
||||
groupIds,
|
||||
grants: allGrants,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* VersionStore port(契约 C1)+ 开发用内存实现。
|
||||
* VersionStore port(契约 C1)+ **仅测试用**的内存实现。
|
||||
*
|
||||
* 版本团队交付 npm 工具包后,用同一接口替换 createInMemoryVersionStore。
|
||||
* 生产实现是 `gitVersionStore.ts`(一项目一 git 仓库,VersionId = commit hash),
|
||||
* 见 ADR-0030。本文件留下来只为让不关心版本落盘的测试快速起个 store;它的
|
||||
* VersionId 是每仓库计数器(`v1`/`v2`),与生产**不同形**,不要据此写断言。
|
||||
* 语义红线(计划"Mock 保真红线"):冲突走返回值(S1)、baseVersion=null 表新建(S2)、
|
||||
* init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。
|
||||
*
|
||||
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复 —— 纯粹为开发期
|
||||
* demo 稳定,不改变任何语义;生产由真包替换,此文件不参与。
|
||||
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复。ADR-0030 之后
|
||||
* 已无生产调用点 —— 生产走 git,不再有这份进程级 JSON 快照。
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
@@ -15,12 +17,21 @@ import { FileLibError } from "./model.js";
|
||||
|
||||
export type VersionId = string;
|
||||
|
||||
/**
|
||||
* 提交者身份。`userId` 是稳定主键(追溯用),`displayName` 只影响展示。
|
||||
* git 实现把它们映射成 `name <userId@域名>`(ADR-0030)。
|
||||
*/
|
||||
export interface CommitAuthor {
|
||||
readonly userId: string;
|
||||
readonly displayName?: string | undefined;
|
||||
}
|
||||
|
||||
export interface CommitRequest {
|
||||
/** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */
|
||||
readonly baseVersion: VersionId | null;
|
||||
readonly content: string | Buffer;
|
||||
readonly message?: string | undefined;
|
||||
readonly author?: string | undefined;
|
||||
readonly author?: CommitAuthor | undefined;
|
||||
}
|
||||
|
||||
export type CommitResult =
|
||||
@@ -34,6 +45,16 @@ export interface VersionInfo {
|
||||
readonly committedAt: string;
|
||||
}
|
||||
|
||||
/** 项目级提交历史条目(包含受影响文件路径)。 */
|
||||
export interface ProjectCommitInfo {
|
||||
readonly version: VersionId;
|
||||
readonly message: string;
|
||||
readonly author: string | undefined;
|
||||
readonly committedAt: string;
|
||||
/** 本次提交修改的文件路径列表。 */
|
||||
readonly files: readonly string[];
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
@@ -45,9 +66,17 @@ export interface VersionStore {
|
||||
head(projectDir: string, filePath: string): Promise<VersionId>;
|
||||
read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>;
|
||||
commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>;
|
||||
remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise<CommitResult>;
|
||||
/** 删除也是一次提交,所以同样带提交者身份。 */
|
||||
remove(
|
||||
projectDir: string,
|
||||
filePath: string,
|
||||
baseVersion: VersionId,
|
||||
author?: CommitAuthor,
|
||||
): Promise<CommitResult>;
|
||||
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>;
|
||||
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
|
||||
/** 项目级提交历史(所有文件),新→旧排列。 */
|
||||
projectHistory(projectDir: string, limit?: number): Promise<ProjectCommitInfo[]>;
|
||||
}
|
||||
|
||||
interface StoredVersion {
|
||||
@@ -80,6 +109,13 @@ function toBuffer(content: string | Buffer): Buffer {
|
||||
return typeof content === "string" ? Buffer.from(content, "utf8") : content;
|
||||
}
|
||||
|
||||
/** VersionInfo.author 是展示字符串;语义与 git 实现对齐(取 displayName,回退 userId)。 */
|
||||
function authorLabel(author: CommitAuthor | undefined): string | undefined {
|
||||
if (author === undefined) return undefined;
|
||||
const name = author.displayName;
|
||||
return name === undefined || name.trim() === "" ? author.userId : name.trim();
|
||||
}
|
||||
|
||||
/** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */
|
||||
function naiveDiff(fromText: string, toText: string): string {
|
||||
const a = fromText.split("\n");
|
||||
@@ -221,7 +257,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
version,
|
||||
content: toBuffer(req.content),
|
||||
message: req.message ?? `commit ${version}`,
|
||||
author: req.author,
|
||||
author: authorLabel(req.author),
|
||||
committedAt: new Date().toISOString(),
|
||||
deleted: false,
|
||||
});
|
||||
@@ -231,7 +267,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
});
|
||||
},
|
||||
|
||||
async remove(projectDir, filePath, baseVersion) {
|
||||
async remove(projectDir, filePath, baseVersion, author) {
|
||||
return serialize(projectDir, async (): Promise<CommitResult> => {
|
||||
const repo = requireRepo(projectDir);
|
||||
const chain = repo.files.get(filePath) ?? [];
|
||||
@@ -247,7 +283,7 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
version,
|
||||
content: Buffer.alloc(0),
|
||||
message: `remove ${filePath}`,
|
||||
author: undefined,
|
||||
author: authorLabel(author),
|
||||
committedAt: new Date().toISOString(),
|
||||
deleted: true,
|
||||
});
|
||||
@@ -276,5 +312,24 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
|
||||
const ordered = infos.reverse();
|
||||
return limit !== undefined ? ordered.slice(0, limit) : ordered;
|
||||
},
|
||||
|
||||
async projectHistory(projectDir, limit) {
|
||||
const repo = requireRepo(projectDir);
|
||||
// 汇集所有文件的所有版本,按提交时间新→旧排序。
|
||||
const all: ProjectCommitInfo[] = [];
|
||||
for (const [filePath, chain] of repo.files) {
|
||||
for (const v of chain) {
|
||||
all.push({
|
||||
version: v.version,
|
||||
message: v.message,
|
||||
author: v.author,
|
||||
committedAt: v.committedAt,
|
||||
files: [filePath],
|
||||
});
|
||||
}
|
||||
}
|
||||
all.sort((a, b) => b.committedAt.localeCompare(a.committedAt));
|
||||
return limit !== undefined ? all.slice(0, limit) : all;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* /database/api/bin/* 回收站端点(ADR-0031)。
|
||||
* 约定:绝对路径;actorOrNull 前置;业务全走 binService;错误统一 sendRouteError。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { listBin, purgeBinEntry, restoreBinEntry } from "../filelib/binService.js";
|
||||
import { actorOrNull, sendRouteError, type FileLibRouteDeps } from "../filelib/routeShared.js";
|
||||
|
||||
export async function registerBinRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise<void> {
|
||||
const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
|
||||
|
||||
app.get("/database/api/bin", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
return { entries: await listBin(svc, actor) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/database/api/bin/:id/restore", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return await restoreBinEntry(svc, actor, id);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/database/api/bin/:id", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return await purgeBinEntry(svc, actor, id);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -28,11 +28,13 @@ import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
|
||||
import { registerFileLibRoutes } from "./filelibRoutes.js";
|
||||
import { registerFileRoutes } from "./fileRoutes.js";
|
||||
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
|
||||
import { registerBinRoutes } from "./binRoutes.js";
|
||||
import { registerTeacherApp } from "./teacherApp.js";
|
||||
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
|
||||
import { createGitVersionStore } from "../filelib/gitVersionStore.js";
|
||||
import { resolveMaxFileBytes } from "../filelib/fileService.js";
|
||||
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
||||
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
|
||||
import { createManifestStubAdapter } from "../filelib/exportService.js";
|
||||
import { createCphPdfAdapter, createManifestStubAdapter } from "../filelib/exportService.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
|
||||
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
|
||||
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
|
||||
@@ -60,6 +62,8 @@ export async function registerDatabaseRoutes(
|
||||
app.get("/database/config", async () => ({
|
||||
orgSlug: config.siloOrganizationSlug,
|
||||
devLoginEnabled: config.allowDevLoginBypass,
|
||||
// 上传上限由后端下发,前端不再写死 —— 两处各自硬编码会随配置漂。
|
||||
maxFileBytes: resolveMaxFileBytes(),
|
||||
}));
|
||||
|
||||
// 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的
|
||||
@@ -132,7 +136,8 @@ export async function registerDatabaseRoutes(
|
||||
}
|
||||
|
||||
// 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。
|
||||
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
|
||||
// 依赖装配:VersionStore 是真 git —— 一项目一仓库 <storageRoot>/<nodeId>,
|
||||
// VersionId = commit hash(ADR-0030);
|
||||
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
|
||||
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
|
||||
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
|
||||
@@ -145,7 +150,7 @@ export async function registerDatabaseRoutes(
|
||||
return;
|
||||
}
|
||||
const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos");
|
||||
const versionStore = createInMemoryVersionStore(path.join(storageRoot, ".version-store.json"));
|
||||
const versionStore = createGitVersionStore();
|
||||
const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"];
|
||||
const filelibDeps: FileLibRouteDeps = {
|
||||
prisma: config.prisma,
|
||||
@@ -156,11 +161,13 @@ export async function registerDatabaseRoutes(
|
||||
? createMemberGroupResolver(config.prisma)
|
||||
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
||||
versionStore,
|
||||
exportAdapters: [createManifestStubAdapter(versionStore)],
|
||||
exportAdapters: [createCphPdfAdapter(), createManifestStubAdapter(versionStore)],
|
||||
maxFileBytes: resolveMaxFileBytes(),
|
||||
};
|
||||
await registerFileLibRoutes(app, filelibDeps);
|
||||
await registerFileRoutes(app, filelibDeps);
|
||||
await registerMemberGroupRoutes(app, filelibDeps);
|
||||
await registerBinRoutes(app, filelibDeps);
|
||||
await registerTeacherApp(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
diffFile,
|
||||
fileHistory,
|
||||
listFiles,
|
||||
projectHistory,
|
||||
readFile,
|
||||
readFileRaw,
|
||||
type FileContentEncoding,
|
||||
@@ -39,6 +40,7 @@ export async function registerFileRoutes(
|
||||
organizationId: deps.organizationId,
|
||||
groupResolver: deps.groupResolver,
|
||||
versionStore: deps.versionStore,
|
||||
maxFileBytes: deps.maxFileBytes,
|
||||
};
|
||||
const exportDeps = { ...fileDeps, adapters: deps.exportAdapters };
|
||||
|
||||
@@ -182,6 +184,22 @@ export async function registerFileRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/projects/:id/history", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const rawLimit = (request.query as { limit?: string }).limit;
|
||||
const limit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
|
||||
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) {
|
||||
throw new FileLibError(400, "invalid_request", "limit must be a positive integer");
|
||||
}
|
||||
return { history: await projectHistory(fileDeps, actor, id, limit) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 导出(D10 异步) */
|
||||
|
||||
app.post("/database/api/projects/:id/exports", async (request, reply) => {
|
||||
@@ -224,9 +242,13 @@ export async function registerFileRoutes(
|
||||
try {
|
||||
const { jobId } = request.params as { jobId: string };
|
||||
const artifact = await downloadExport(exportDeps, actor, jobId);
|
||||
// PDF 报真实 MIME,浏览器才能内联预览/正确命名;其余产物保守为二进制流。
|
||||
const contentType = artifact.filename.toLowerCase().endsWith(".pdf")
|
||||
? "application/pdf"
|
||||
: "application/octet-stream";
|
||||
return reply
|
||||
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
|
||||
.type("application/octet-stream")
|
||||
.type(contentType)
|
||||
.send(artifact.content);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
listGrants,
|
||||
putGrants,
|
||||
revokeGrant,
|
||||
setIndependentPermission,
|
||||
} from "../filelib/grantService.js";
|
||||
import { FileLibError } from "../filelib/model.js";
|
||||
import {
|
||||
@@ -114,9 +113,6 @@ export async function registerFileLibRoutes(
|
||||
where: { id, organizationId: deps.organizationId },
|
||||
});
|
||||
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
||||
const settings = node.kind === "PROJECT"
|
||||
? await deps.prisma.fileLibProjectSettings.findUnique({ where: { nodeId: node.id } })
|
||||
: null;
|
||||
return {
|
||||
node: {
|
||||
id: node.id,
|
||||
@@ -126,7 +122,6 @@ export async function registerFileLibRoutes(
|
||||
description: node.description,
|
||||
role,
|
||||
provisionStatus: node.provisionStatus,
|
||||
independentPermission: settings?.independentPermissionsEnabled ?? false,
|
||||
createdAt: node.createdAt,
|
||||
updatedAt: node.updatedAt,
|
||||
},
|
||||
@@ -258,21 +253,6 @@ export async function registerFileLibRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/database/api/projects/:id/independent-permission", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
if (typeof body["enabled"] !== "boolean") {
|
||||
throw new FileLibError(400, "invalid_request", "enabled must be a boolean");
|
||||
}
|
||||
return await setIndependentPermission(grantDeps, actor, id, body["enabled"]);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
|
||||
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 回收站集成测试(真实 Postgres,ADR-0031;最近打开已由 ADR-0032 移除)。
|
||||
* 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义
|
||||
* 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)。
|
||||
* 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma, resetDb, DEFAULT_ORG_ID } from "./helpers.js";
|
||||
import {
|
||||
createNode,
|
||||
softDeleteNode,
|
||||
listChildren,
|
||||
renameNode,
|
||||
type FileLibActor,
|
||||
type TreeServiceDeps,
|
||||
} from "../../src/database/filelib/treeService.js";
|
||||
import { listBin, purgeBinEntry, restoreBinEntry, type BinDeps } from "../../src/database/filelib/binService.js";
|
||||
import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js";
|
||||
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS } from "../../src/database/filelib/audit.js";
|
||||
|
||||
const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true };
|
||||
const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false };
|
||||
const BOB: FileLibActor = { userId: "u_bob", isWebsiteAdmin: false };
|
||||
|
||||
function treeDeps(): TreeServiceDeps {
|
||||
return {
|
||||
prisma,
|
||||
groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
|
||||
versionStore: createInMemoryVersionStore(),
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
storageRoot: "/tmp/filelib-test",
|
||||
};
|
||||
}
|
||||
|
||||
function binDeps(): BinDeps {
|
||||
return {
|
||||
prisma,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) {
|
||||
await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } });
|
||||
}
|
||||
});
|
||||
|
||||
describe("binService · 列出与可见性", () => {
|
||||
it("只露每支已删子树的顶;管理员全见,直连 MANAGE 可见,无关者不见", async () => {
|
||||
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||
const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
||||
await createNode(treeDeps(), ADMIN, { parentId: child.id, kind: "PROJECT", name: "TH-141" });
|
||||
// alice 在 child 上直连 MANAGE。
|
||||
const own = await createNode(treeDeps(), ADMIN, {
|
||||
parentId: null, kind: "PROJECT", name: "alice 项目",
|
||||
grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
|
||||
});
|
||||
|
||||
await softDeleteNode(treeDeps(), ADMIN, child.id); // 删中间层:child 是顶,孙项目不单列
|
||||
await softDeleteNode(treeDeps(), ADMIN, own.id);
|
||||
|
||||
const adminBin = await listBin(binDeps(), ADMIN);
|
||||
expect(adminBin.map((e) => e.name).sort()).toEqual(["alice 项目", "必修一"]);
|
||||
|
||||
const aliceBin = await listBin(binDeps(), ALICE);
|
||||
expect(aliceBin.map((e) => e.name)).toEqual(["alice 项目"]); // 只见自己 MANAGE 的
|
||||
|
||||
const bobBin = await listBin(binDeps(), BOB);
|
||||
expect(bobBin).toEqual([]);
|
||||
|
||||
void root;
|
||||
});
|
||||
});
|
||||
|
||||
describe("binService · 恢复", () => {
|
||||
it("restore 只清本节点:整支立即可见,落 folder.restore 审计;无权者 404", async () => {
|
||||
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||
const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
||||
await softDeleteNode(treeDeps(), ADMIN, child.id);
|
||||
|
||||
await expect(restoreBinEntry(binDeps(), BOB, child.id)).rejects.toMatchObject({ statusCode: 404 });
|
||||
await restoreBinEntry(binDeps(), ADMIN, child.id);
|
||||
|
||||
const visible = await listChildren(treeDeps(), ADMIN, root.id);
|
||||
expect(visible.map((c) => c.name)).toContain("必修一");
|
||||
|
||||
const audits = await prisma.auditEntry.findMany({
|
||||
where: { action: FILE_LIB_AUDIT_ACTIONS.folderRestore },
|
||||
});
|
||||
expect(audits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ADR-0035:撞名时恢复报 name_conflict_on_restore(不自动改名),清名后可恢复", async () => {
|
||||
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||
const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
||||
await softDeleteNode(treeDeps(), ADMIN, child.id);
|
||||
// 删除后同名新建 -> 活跃兄弟占了名字
|
||||
await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
||||
|
||||
await expect(restoreBinEntry(binDeps(), ADMIN, child.id)).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
code: "name_conflict_on_restore",
|
||||
});
|
||||
|
||||
// 改名现有节点后恢复 -> 成功,保留原名
|
||||
const active = (await listChildren(treeDeps(), ADMIN, root.id)).find((c) => c.name === "必修一")!;
|
||||
await renameNode(treeDeps(), ADMIN, active.id, "必修一(新)");
|
||||
const result = await restoreBinEntry(binDeps(), ADMIN, child.id);
|
||||
expect(result.name).toBe("必修一");
|
||||
expect(result.renamedFrom).toBeUndefined();
|
||||
|
||||
const visible = await listChildren(treeDeps(), ADMIN, root.id);
|
||||
expect(visible.map((c) => c.name).sort()).toEqual(["必修一", "必修一(新)"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binService · 彻底删除", () => {
|
||||
it("ADR-0034:与条目可见性同权 —— 直连 MANAGE 可清空,无关者 404;整支硬删 + node.purge 审计", async () => {
|
||||
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||
const child = await createNode(treeDeps(), ADMIN, {
|
||||
parentId: root.id, kind: "PROJECT", name: "TH-141",
|
||||
grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
|
||||
});
|
||||
await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶;alice 在 root 上无直连 MANAGE
|
||||
|
||||
await expect(purgeBinEntry(binDeps(), BOB, root.id)).rejects.toMatchObject({ statusCode: 404 });
|
||||
const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id);
|
||||
expect(removed).toBe(2);
|
||||
|
||||
expect(await prisma.fileLibNode.count({ where: { id: { in: [root.id, child.id] } } })).toBe(0);
|
||||
expect(await prisma.fileLibGrant.count({ where: { nodeId: child.id } })).toBe(0);
|
||||
|
||||
const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } });
|
||||
expect(audits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ADR-0034:非管理员的直连 MANAGE 持有者也能彻底删除", async () => {
|
||||
const own = await createNode(treeDeps(), ADMIN, {
|
||||
parentId: null, kind: "PROJECT", name: "alice 项目",
|
||||
grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
|
||||
});
|
||||
await softDeleteNode(treeDeps(), ADMIN, own.id);
|
||||
|
||||
const { removed } = await purgeBinEntry(binDeps(), ALICE, own.id);
|
||||
expect(removed).toBe(1);
|
||||
expect(await prisma.fileLibNode.count({ where: { id: own.id } })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ beforeEach(async () => {
|
||||
groupResolver: createStaticGroupResolver({}),
|
||||
versionStore,
|
||||
exportAdapters: [createManifestStubAdapter(versionStore)],
|
||||
maxFileBytes: 10 * 1024 * 1024,
|
||||
};
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
@@ -149,6 +150,42 @@ describe("filelib http · 8.1 授权矩阵", () => {
|
||||
expect(revoke.statusCode).toBe(403);
|
||||
expect(revoke.json().error.code).toBe("cannot_touch_creator");
|
||||
});
|
||||
|
||||
it("grants 返回 principalName:USER→displayName / GROUP→组名;取不到行时回落为 id", async () => {
|
||||
const rootId = await createRoot(ADMIN_COOKIE());
|
||||
const group = await prisma.memberGroup.create({ data: { name: "物理组" } });
|
||||
|
||||
const put = await app.inject({
|
||||
method: "PUT", url: `/database/api/nodes/${rootId}/grants`,
|
||||
headers: { cookie: ADMIN_COOKIE() },
|
||||
payload: {
|
||||
grants: [
|
||||
{ principalType: "USER", principalId: "u_alice", role: "EDIT" },
|
||||
{ principalType: "GROUP", principalId: group.id, role: "VIEW" },
|
||||
// 数据库里没有对应 User 行(已删/脏数据)→ 回落为 principalId
|
||||
{ principalType: "USER", principalId: "u_ghost", role: "VIEW" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
|
||||
const list = await app.inject({
|
||||
method: "GET", url: `/database/api/nodes/${rootId}/grants`,
|
||||
headers: { cookie: ADMIN_COOKIE() },
|
||||
});
|
||||
expect(list.statusCode).toBe(200);
|
||||
const byPrincipal = new Map<string, string>(
|
||||
list.json().grants.map((g: { principalId: string; principalName: string }) => [g.principalId, g.principalName]),
|
||||
);
|
||||
expect(byPrincipal.get("u_alice")).toBe("Alice");
|
||||
expect(byPrincipal.get(group.id)).toBe("物理组");
|
||||
expect(byPrincipal.get("u_ghost")).toBe("u_ghost");
|
||||
// 创建者授权也要解析出名字
|
||||
const creator = list.json().grants.find((g: { isCreatorGrant: boolean }) => g.isCreatorGrant);
|
||||
expect(creator.principalName).toBe("Admin");
|
||||
// PUT 响应与 GET 同形状
|
||||
expect(put.json().grants.every((g: { principalName?: string }) => typeof g.principalName === "string")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filelib http · 文件冲突流", () => {
|
||||
|
||||
@@ -80,20 +80,15 @@ describe("treeService · 创建规则", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("treeService · D11 独立权限开关", () => {
|
||||
it("关闭时项目级非创建者 grant 冻结,创建者仍 MANAGE", async () => {
|
||||
describe("treeService · 项目级 grant 恒生效(ADR-0030)", () => {
|
||||
it("项目级非创建者 grant 创建即生效,创建者仍 MANAGE", async () => {
|
||||
const project = await createNode(deps(), ADMIN, {
|
||||
parentId: null, kind: "PROJECT", name: "TH-141",
|
||||
grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }],
|
||||
});
|
||||
await expect(getEffectiveRole(deps(), ALICE, project.id))
|
||||
.rejects.toMatchObject({ statusCode: 404 }); // 冻结 = 无权限 = D8 不可见
|
||||
// 无开关、无冻结:alice 的项目级 EDIT 立即可见。
|
||||
expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT");
|
||||
expect(await getEffectiveRole(deps(), ADMIN, project.id)).toBe("MANAGE");
|
||||
await prisma.fileLibProjectSettings.update({
|
||||
where: { nodeId: project.id },
|
||||
data: { independentPermissionsEnabled: true },
|
||||
});
|
||||
expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT"); // 恢复
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ export async function resetDb(): Promise<void> {
|
||||
// two tables have no FK to Project and must be cleared explicitly.
|
||||
prisma.permissionGrant.deleteMany(),
|
||||
prisma.permissionSettings.deleteMany(),
|
||||
// MemberGroup is global (ADR-0028): no FK to the org/user roots, so the
|
||||
// cascade above never reaches it. Clear explicitly — closure/membership
|
||||
// first (they FK into MemberGroup), groups last.
|
||||
prisma.memberGroupClosure.deleteMany(),
|
||||
prisma.memberGroupMembership.deleteMany(),
|
||||
prisma.memberGroup.deleteMany(),
|
||||
prisma.user.deleteMany(),
|
||||
prisma.organization.deleteMany(),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 单文件上限的环境变量解析 + commit message 默认文案。
|
||||
*
|
||||
* 上限:`HUB_FILELIB_MAX_FILE_BYTES` 覆盖,缺省 10MiB;非法值按缺省 ——
|
||||
* 配置写错不该让上传静默变成 0 上限(那会把每次上传都拒掉)。
|
||||
* 文案:`【用户名】修改了【路径】`,用户名取 displayName、缺失回退 userId。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FILE_CONTENT_MAX_BYTES_DEFAULT,
|
||||
defaultCommitMessage,
|
||||
parseMaxFileBytes,
|
||||
} from "../../src/database/filelib/fileService.js";
|
||||
|
||||
describe("parseMaxFileBytes", () => {
|
||||
it("缺省 10MiB", () => {
|
||||
expect(FILE_CONTENT_MAX_BYTES_DEFAULT).toBe(10 * 1024 * 1024);
|
||||
expect(parseMaxFileBytes(undefined)).toBe(FILE_CONTENT_MAX_BYTES_DEFAULT);
|
||||
expect(parseMaxFileBytes("")).toBe(FILE_CONTENT_MAX_BYTES_DEFAULT);
|
||||
expect(parseMaxFileBytes(" ")).toBe(FILE_CONTENT_MAX_BYTES_DEFAULT);
|
||||
});
|
||||
|
||||
it("合法正整数生效(可上调也可下调)", () => {
|
||||
expect(parseMaxFileBytes("1048576")).toBe(1024 * 1024);
|
||||
expect(parseMaxFileBytes(" 52428800 ")).toBe(50 * 1024 * 1024);
|
||||
expect(parseMaxFileBytes("1")).toBe(1);
|
||||
});
|
||||
|
||||
it("非法值一律回退缺省,不产生 0 或负数上限", () => {
|
||||
for (const bad of ["0", "-1", "abc", "1.5", "NaN", "Infinity", "1e999", "10MB"]) {
|
||||
expect(parseMaxFileBytes(bad)).toBe(FILE_CONTENT_MAX_BYTES_DEFAULT);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultCommitMessage", () => {
|
||||
it("【用户名】修改了【路径】,用户名取 displayName", () => {
|
||||
expect(defaultCommitMessage({ userId: "u1", isWebsiteAdmin: false, displayName: "张老师" }, "讲义/第一课.md"))
|
||||
.toBe("【张老师】修改了【讲义/第一课.md】");
|
||||
});
|
||||
|
||||
it("displayName 缺失/空白时回退 userId", () => {
|
||||
expect(defaultCommitMessage({ userId: "u_alice", isWebsiteAdmin: false }, "a.md"))
|
||||
.toBe("【u_alice】修改了【a.md】");
|
||||
expect(defaultCommitMessage({ userId: "u_alice", isWebsiteAdmin: false, displayName: " " }, "a.md"))
|
||||
.toBe("【u_alice】修改了【a.md】");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* message 优先级的纯函数复刻(commitFile 里的那三行):
|
||||
* 非空则用调用方的,否则回退默认文案。空串必须算“没传” ——
|
||||
* `git commit -m ""` 会以 empty commit message 失败。
|
||||
*/
|
||||
function resolveMessage(input: string | undefined, fallback: string): string {
|
||||
const trimmed = input?.trim();
|
||||
return trimmed === undefined || trimmed === "" ? fallback : trimmed;
|
||||
}
|
||||
|
||||
describe("commit message 优先级", () => {
|
||||
const fallback = "【张老师】修改了【a.md】";
|
||||
|
||||
it("手填了就用手填的", () => {
|
||||
expect(resolveMessage("补上第三课的课件", fallback)).toBe("补上第三课的课件");
|
||||
expect(resolveMessage(" 前后有空格 ", fallback)).toBe("前后有空格");
|
||||
});
|
||||
|
||||
it("未传/空/约空白一律回退默认文案", () => {
|
||||
for (const raw of [undefined, "", " ", "\n\t"]) {
|
||||
expect(resolveMessage(raw, fallback)).toBe(fallback);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 真 git VersionStore 语义单测(ADR-0030)。
|
||||
*
|
||||
* 与 filelib-version-store.test.ts(内存实现)逐条对齐同一份 C1 语义:
|
||||
* S1/S2 冲突走返回值、S7 init 幂等、D16 文件级版本互不影响、S4 同仓库写串行。
|
||||
* 差别只在 VersionId 是 commit hash 而非计数器 —— 断言因此不写死字面量。
|
||||
*
|
||||
* 每个用例一个真临时仓库,跑真 git;没有 mock。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createGitVersionStore } from "../../src/database/filelib/gitVersionStore.js";
|
||||
import { FileLibError } from "../../src/database/filelib/model.js";
|
||||
|
||||
const HEX40 = /^[0-9a-f]{40}$/;
|
||||
|
||||
let root: string;
|
||||
let dir: string;
|
||||
const store = createGitVersionStore();
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), "filelib-git-"));
|
||||
dir = path.join(root, "11111111-2222-3333-4444-555555555555");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** 取 ok 版本号;conflict 直接让用例失败(比 as 断言更早暴露问题)。 */
|
||||
function okVersion(result: { status: string; version?: string }): string {
|
||||
expect(result.status).toBe("ok");
|
||||
return result.version as string;
|
||||
}
|
||||
|
||||
describe("GitVersionStore · C1 语义(ADR-0030)", () => {
|
||||
it("init 建目录 + git init;幂等(S7);未 init → repo_not_found", async () => {
|
||||
await expect(store.head(dir, "a.md")).rejects.toThrowError(FileLibError);
|
||||
await store.init(dir);
|
||||
expect(existsSync(path.join(dir, ".git"))).toBe(true);
|
||||
await store.init(dir); // 幂等
|
||||
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "hello" }));
|
||||
await store.init(dir); // 已有内容后再 init 不清空
|
||||
expect(await store.head(dir, "a.md")).toBe(v1);
|
||||
expect((await store.read(dir, "a.md")).toString()).toBe("hello");
|
||||
});
|
||||
|
||||
it("VersionId 是 40 位 commit hash,且真的落到磁盘工作区", async () => {
|
||||
await store.init(dir);
|
||||
const v = okVersion(await store.commit(dir, "docs/a.md", { baseVersion: null, content: "内容" }));
|
||||
expect(v).toMatch(HEX40);
|
||||
// 工作区里是一个真文件(不只是 git 对象)。
|
||||
expect(await readFile(path.join(dir, "docs/a.md"), "utf8")).toBe("内容");
|
||||
});
|
||||
|
||||
it("对已存在文件再次『新建』 → conflict(S2)", async () => {
|
||||
await store.init(dir);
|
||||
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
|
||||
expect(await store.commit(dir, "a.md", { baseVersion: null, content: "2" })).toEqual({
|
||||
status: "conflict",
|
||||
currentVersion: v1,
|
||||
});
|
||||
});
|
||||
|
||||
it("baseVersion 落后于当前 → conflict 并带 currentVersion(S1)", async () => {
|
||||
await store.init(dir);
|
||||
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
|
||||
const v2 = okVersion(await store.commit(dir, "a.md", { baseVersion: v1, content: "2" }));
|
||||
expect(await store.commit(dir, "a.md", { baseVersion: v1, content: "3" })).toEqual({
|
||||
status: "conflict",
|
||||
currentVersion: v2,
|
||||
});
|
||||
});
|
||||
|
||||
it("旧版本仍可按 hash 读回", async () => {
|
||||
await store.init(dir);
|
||||
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "一" }));
|
||||
await store.commit(dir, "a.md", { baseVersion: v1, content: "二" });
|
||||
expect((await store.read(dir, "a.md", v1)).toString()).toBe("一");
|
||||
expect((await store.read(dir, "a.md")).toString()).toBe("二");
|
||||
});
|
||||
|
||||
it("D16 文件级版本:a.md 的提交不推进 b.md 的 base", async () => {
|
||||
await store.init(dir);
|
||||
const a1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "A1" }));
|
||||
const b1 = okVersion(await store.commit(dir, "b.md", { baseVersion: null, content: "B1" }));
|
||||
await store.commit(dir, "a.md", { baseVersion: a1, content: "A2" }); // 仓库 HEAD 前进
|
||||
// b.md 仍以自己的版本为 base —— 这正是 commit 级 hash 做文件级版本的关键。
|
||||
expect((await store.commit(dir, "b.md", { baseVersion: b1, content: "B2" })).status).toBe("ok");
|
||||
expect(await store.head(dir, "b.md")).not.toBe(b1);
|
||||
});
|
||||
|
||||
it("二进制内容原样往返(NUL 字节不被破坏)", async () => {
|
||||
await store.init(dir);
|
||||
const bytes = Buffer.from([0x00, 0x01, 0xff, 0x00, 0x7f]);
|
||||
await store.commit(dir, "blob.bin", { baseVersion: null, content: bytes });
|
||||
expect(Buffer.compare(await store.read(dir, "blob.bin"), bytes)).toBe(0);
|
||||
});
|
||||
|
||||
it("remove:正确 base → ok;head 随后 404;stale/缺失 base → conflict/404", async () => {
|
||||
await store.init(dir);
|
||||
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
|
||||
const v2 = okVersion(await store.commit(dir, "a.md", { baseVersion: v1, content: "2" }));
|
||||
expect(await store.remove(dir, "a.md", v1)).toEqual({ status: "conflict", currentVersion: v2 });
|
||||
const v3 = okVersion(await store.remove(dir, "a.md", v2));
|
||||
await expect(store.head(dir, "a.md")).rejects.toThrowError(FileLibError);
|
||||
expect(existsSync(path.join(dir, "a.md"))).toBe(false);
|
||||
// 删除是一个 commit:旧版本内容仍可取回(ADR-0030)。
|
||||
expect((await store.read(dir, "a.md", v1)).toString()).toBe("1");
|
||||
expect(v3).toMatch(HEX40);
|
||||
await expect(store.remove(dir, "a.md", v3)).rejects.toThrowError(FileLibError);
|
||||
});
|
||||
|
||||
it("history 新→旧,支持 limit,author 取 displayName", async () => {
|
||||
await store.init(dir);
|
||||
const v1 = okVersion(
|
||||
await store.commit(dir, "docs/a.md", {
|
||||
baseVersion: null,
|
||||
content: "1",
|
||||
message: "初版",
|
||||
author: { userId: "u1", displayName: "张老师" },
|
||||
}),
|
||||
);
|
||||
await store.commit(dir, "docs/a.md", {
|
||||
baseVersion: v1,
|
||||
content: "2",
|
||||
message: "二版",
|
||||
author: { userId: "u2", displayName: "李老师" },
|
||||
});
|
||||
await store.commit(dir, "other/b.md", { baseVersion: null, content: "x" });
|
||||
const h = await store.history(dir, "docs/a.md");
|
||||
expect(h.map((v) => v.message)).toEqual(["二版", "初版"]);
|
||||
expect(h.map((v) => v.author)).toEqual(["李老师", "张老师"]);
|
||||
expect(h[0]?.version).toMatch(HEX40);
|
||||
expect((await store.history(dir, "docs/a.md", 1)).map((v) => v.message)).toEqual(["二版"]);
|
||||
// other/b.md 的提交不出现在 docs/a.md 的历史里。
|
||||
expect(h).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("list 排除已删、按前缀过滤、空仓库返回空", async () => {
|
||||
await store.init(dir);
|
||||
expect(await store.list(dir)).toEqual([]);
|
||||
await store.commit(dir, "docs/a.md", { baseVersion: null, content: "1" });
|
||||
const bv = okVersion(await store.commit(dir, "other/b.md", { baseVersion: null, content: "xy" }));
|
||||
expect((await store.list(dir)).map((f) => f.path)).toEqual(["docs/a.md", "other/b.md"]);
|
||||
expect((await store.list(dir, "docs/")).map((f) => f.path)).toEqual(["docs/a.md"]);
|
||||
expect((await store.list(dir)).find((f) => f.path === "other/b.md")?.size).toBe(2);
|
||||
await store.remove(dir, "other/b.md", bv);
|
||||
expect((await store.list(dir)).map((f) => f.path)).toEqual(["docs/a.md"]);
|
||||
});
|
||||
|
||||
it("S4 同仓库写串行:并发同 base 提交,恰好一成一冲突", async () => {
|
||||
await store.init(dir);
|
||||
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
|
||||
const [r1, r2] = await Promise.all([
|
||||
store.commit(dir, "a.md", { baseVersion: v1, content: "x" }),
|
||||
store.commit(dir, "a.md", { baseVersion: v1, content: "y" }),
|
||||
]);
|
||||
expect([r1.status, r2.status].sort()).toEqual(["conflict", "ok"]);
|
||||
});
|
||||
|
||||
it("目录不存在 → repo_not_found,不该报 git_missing", async () => {
|
||||
// 回归:spawn 的 ENOENT 有两种来源且报错一模一样 —— git 不在 PATH 上,或 cwd 不存在。
|
||||
// 早先把两者一律归为 git_missing,于是“项目目录没建过”会被误报成“git 没装”。
|
||||
// 内存 store 时代建的旧项目就是这个形态:DB 有 storageDir,磁盘上什么都没有。
|
||||
const missing = path.join(root, "never-created");
|
||||
for (const op of [
|
||||
() => store.head(missing, "a.md"),
|
||||
() => store.list(missing),
|
||||
() => store.read(missing, "a.md"),
|
||||
() => store.commit(missing, "a.md", { baseVersion: null, content: "x" }),
|
||||
() => store.remove(missing, "a.md", "0".repeat(40)),
|
||||
() => store.history(missing, "a.md"),
|
||||
]) {
|
||||
await expect(op()).rejects.toMatchObject({ code: "repo_not_found", statusCode: 404 });
|
||||
}
|
||||
});
|
||||
|
||||
it("目录存在但不是 git 仓库 → repo_not_found", async () => {
|
||||
const { mkdir } = await import("node:fs/promises");
|
||||
const plain = path.join(root, "plain-dir");
|
||||
await mkdir(plain, { recursive: true });
|
||||
await expect(store.head(plain, "a.md")).rejects.toMatchObject({ code: "repo_not_found" });
|
||||
});
|
||||
|
||||
it("仍然能从零开始:init 会把缺失的目录建出来", async () => {
|
||||
const deep = path.join(root, "a", "b", "c-uuid");
|
||||
await store.init(deep);
|
||||
expect((await store.commit(deep, "a.md", { baseVersion: null, content: "1" })).status).toBe("ok");
|
||||
});
|
||||
|
||||
it("提交身份:name=displayName,email=<userId>@filelib.paradigm-edu.net", async () => {
|
||||
await store.init(dir);
|
||||
await store.commit(dir, "a.md", {
|
||||
baseVersion: null,
|
||||
content: "1",
|
||||
author: { userId: "u_alice", displayName: "张老师" },
|
||||
});
|
||||
const ident = execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae|%cn|%ce"]).toString().trim();
|
||||
expect(ident).toBe("张老师|u_alice@filelib.paradigm-edu.net|张老师|u_alice@filelib.paradigm-edu.net");
|
||||
});
|
||||
|
||||
it("displayName 缺失时 name 回退 userId,email 仍用 userId", async () => {
|
||||
await store.init(dir);
|
||||
await store.commit(dir, "a.md", { baseVersion: null, content: "1", author: { userId: "u_bob" } });
|
||||
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae"]).toString().trim())
|
||||
.toBe("u_bob|u_bob@filelib.paradigm-edu.net");
|
||||
});
|
||||
|
||||
it("displayName 里的 <>/换行不能破坏 git ident 行", async () => {
|
||||
await store.init(dir);
|
||||
await store.commit(dir, "a.md", {
|
||||
baseVersion: null,
|
||||
content: "1",
|
||||
author: { userId: "u_x", displayName: "a <evil@e.com>\n换行" },
|
||||
});
|
||||
const out = execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae"]).toString().trim();
|
||||
const [name, email] = out.split("|");
|
||||
// 断言性质而不是具体空白(git 自己还会压缩 ident 里的空白)。
|
||||
expect(name).not.toContain("<");
|
||||
expect(name).not.toContain(">");
|
||||
expect(name).not.toContain("\n");
|
||||
expect(name).toContain("换行");
|
||||
expect(email).toBe("u_x@filelib.paradigm-edu.net");
|
||||
});
|
||||
|
||||
it("删除提交也带提交者身份", async () => {
|
||||
await store.init(dir);
|
||||
const v = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
|
||||
okVersion(await store.remove(dir, "a.md", v, { userId: "u_del", displayName: "删除者" }));
|
||||
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae"]).toString().trim())
|
||||
.toBe("删除者|u_del@filelib.paradigm-edu.net");
|
||||
});
|
||||
|
||||
it("storage root 在另一个 git 仓库内时,不得落到外层仓库上", async () => {
|
||||
// 回归:早先用 `git rev-parse --git-dir` 判定“是否已 init”,而 git 会沿目录树
|
||||
// **向上**找 `.git`。本地开发的默认 storage root 就是 `hub/.filelib-repos`,
|
||||
// 在本 repo 内 —— 于是未 init 的项目目录被误判为“已是仓库”,后续命令全部
|
||||
// 落到外层源码仓上(表现为 `git add` 报 “paths are ignored by .gitignore”)。
|
||||
const outer = path.join(root, "outer");
|
||||
const { mkdir, writeFile } = await import("node:fs/promises");
|
||||
await mkdir(outer, { recursive: true });
|
||||
execFileSync("git", ["-C", outer, "init", "--quiet"]);
|
||||
await writeFile(path.join(outer, ".gitignore"), "repos/\n");
|
||||
const inner = path.join(outer, "repos", "proj-uuid");
|
||||
await mkdir(inner, { recursive: true });
|
||||
|
||||
// 未 init 的子目录:必须报 repo_not_found,而不是静默用外层仓库。
|
||||
await expect(store.head(inner, "a.md")).rejects.toMatchObject({ code: "repo_not_found" });
|
||||
|
||||
// init 后的写入必须进自己的仓库,外层仓库保持干净。
|
||||
await store.init(inner);
|
||||
okVersion(await store.commit(inner, "a.md", { baseVersion: null, content: "1" }));
|
||||
expect(existsSync(path.join(inner, ".git"))).toBe(true);
|
||||
expect(execFileSync("git", ["-C", outer, "status", "--porcelain"]).toString().trim()).toBe("?? .gitignore");
|
||||
expect(execFileSync("git", ["-C", inner, "log", "--format=%s"]).toString().trim()).toBe("create a.md");
|
||||
});
|
||||
|
||||
it("显式 message 原样落到 commit;不传则用调用方给的默认值", async () => {
|
||||
await store.init(dir);
|
||||
okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1", message: "手填的提交信息" }));
|
||||
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%s"]).toString().trim()).toBe("手填的提交信息");
|
||||
// 以 `-` 开头的 message 不得被当成选项(execFile 数组形式 + `-m` 传入)。
|
||||
okVersion(await store.commit(dir, "b.md", { baseVersion: null, content: "1", message: "--force 看着像选项" }));
|
||||
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%s"]).toString().trim()).toBe("--force 看着像选项");
|
||||
});
|
||||
|
||||
it("diff 是真 unified diff,含增删行", async () => {
|
||||
await store.init(dir);
|
||||
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "keep\nold\n" }));
|
||||
const v2 = okVersion(await store.commit(dir, "a.md", { baseVersion: v1, content: "keep\nnew\n" }));
|
||||
const d = await store.diff(dir, "a.md", v1, v2);
|
||||
expect(d).toContain("-old");
|
||||
expect(d).toContain("+new");
|
||||
expect(d).toContain(" keep");
|
||||
});
|
||||
|
||||
it("不存在的 version → version_not_found;非法路径 → invalid_path", async () => {
|
||||
await store.init(dir);
|
||||
await store.commit(dir, "a.md", { baseVersion: null, content: "1" });
|
||||
const bogus = "0".repeat(40);
|
||||
await expect(store.read(dir, "a.md", bogus)).rejects.toMatchObject({ code: "version_not_found" });
|
||||
for (const bad of ["../escape.md", "a/../../b.md", ".git/config", "/abs.md", "a\\b.md"]) {
|
||||
await expect(store.read(dir, bad)).rejects.toMatchObject({ code: "invalid_path" });
|
||||
}
|
||||
});
|
||||
|
||||
it("仓库内的 hooks 不被执行(ADR-0030 加固)", async () => {
|
||||
await store.init(dir);
|
||||
// 装一个会失败的 pre-commit hook:若 hooks 生效,下面的 commit 就会失败。
|
||||
const { mkdir, writeFile, chmod } = await import("node:fs/promises");
|
||||
const hookDir = path.join(dir, ".git", "hooks");
|
||||
await mkdir(hookDir, { recursive: true });
|
||||
const hook = path.join(hookDir, "pre-commit");
|
||||
await writeFile(hook, "#!/bin/sh\nexit 1\n");
|
||||
await chmod(hook, 0o755);
|
||||
expect((await store.commit(dir, "a.md", { baseVersion: null, content: "1" })).status).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 纯权限 reducer 单测(契约 P6 / D11 / 2.3)。
|
||||
* 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限/toggle 冻结;
|
||||
* 纯权限 reducer 单测(契约 P6 / 2.3)。
|
||||
* 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限;
|
||||
* 外加确定性随机化不变量(单调性:任何可用 grant 都不超过 effective)。
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -10,7 +10,6 @@ const base: EffectiveRoleInput = {
|
||||
nodeId: "N",
|
||||
nodeKind: "FOLDER",
|
||||
ancestorIds: ["A", "R"], // N ⊂ A ⊂ R
|
||||
independentPermissionsEnabled: false,
|
||||
userId: "u1",
|
||||
groupIds: ["g1"],
|
||||
grants: [],
|
||||
@@ -75,33 +74,28 @@ describe("effectiveRole · 契约 P6 矩阵", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveRole · D11 独立权限开关", () => {
|
||||
describe("effectiveRole · 项目级 grant 恒生效(ADR-0030)", () => {
|
||||
const project: EffectiveRoleInput = { ...base, nodeKind: "PROJECT", nodeId: "P" };
|
||||
|
||||
it("开关关闭:项目级非创建者 grant 冻结", () => {
|
||||
it("项目级非创建者 grant 直接参与(无开关、无冻结)", () => {
|
||||
const grants = [grant({ nodeId: "P", role: "EDIT" })];
|
||||
expect(effectiveRole({ ...project, grants })).toBeNull();
|
||||
expect(effectiveRole({ ...project, grants })).toBe("EDIT");
|
||||
});
|
||||
|
||||
it("开关关闭:创建者 grant 仍生效", () => {
|
||||
it("创建者 grant 照常生效", () => {
|
||||
const grants = [grant({ nodeId: "P", role: "MANAGE", isCreatorGrant: true })];
|
||||
expect(effectiveRole({ ...project, grants })).toBe("MANAGE");
|
||||
});
|
||||
|
||||
it("开关关闭:祖先链 grant 不受影响", () => {
|
||||
it("项目级与祖先链 grant 同取 max", () => {
|
||||
const grants = [
|
||||
grant({ nodeId: "P", role: "MANAGE" }), // 冻结
|
||||
grant({ nodeId: "A", role: "VIEW" }), // 生效
|
||||
grant({ nodeId: "P", role: "VIEW" }),
|
||||
grant({ nodeId: "A", role: "EDIT" }),
|
||||
];
|
||||
expect(effectiveRole({ ...project, grants })).toBe("VIEW");
|
||||
expect(effectiveRole({ ...project, grants })).toBe("EDIT");
|
||||
});
|
||||
|
||||
it("开关开启:项目级 grant 恢复参与", () => {
|
||||
const grants = [grant({ nodeId: "P", role: "EDIT" })];
|
||||
expect(effectiveRole({ ...project, independentPermissionsEnabled: true, grants })).toBe("EDIT");
|
||||
});
|
||||
|
||||
it("文件夹忽略开关(self grant 照常参与)", () => {
|
||||
it("文件夹与项目语义一致(self grant 照常参与)", () => {
|
||||
const grants = [grant({ nodeId: "N", role: "EDIT" })];
|
||||
expect(effectiveRole({ ...base, grants })).toBe("EDIT");
|
||||
});
|
||||
|
||||
@@ -23,7 +23,11 @@ describe("InMemoryVersionStore · C1 语义", () => {
|
||||
it("新建(baseVersion null)→ ok;head/read 命中", async () => {
|
||||
const store = createInMemoryVersionStore();
|
||||
await store.init(DIR);
|
||||
const r = await store.commit(DIR, "a.md", { baseVersion: null, content: "v1 内容", author: "u1" });
|
||||
const r = await store.commit(DIR, "a.md", {
|
||||
baseVersion: null,
|
||||
content: "v1 内容",
|
||||
author: { userId: "u1" },
|
||||
});
|
||||
expect(r).toEqual({ status: "ok", version: "v1" });
|
||||
expect((await store.read(DIR, "a.md")).toString()).toBe("v1 内容");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* POST /auth/logout 不读 body,应接受任何(或没有)Content-Type。
|
||||
*
|
||||
* 回归背景:该端点原先只有 Fastify 默认的 JSON parser,`curl -d ""` 之类带上
|
||||
* form-urlencoded 的空 POST 会在解析阶段被 415 拒掉。修法是给它一个 catch-all
|
||||
* parser —— 但**必须封装在自己的作用域里**。
|
||||
*
|
||||
* 最后一个 case 是这条修复的护栏:admin plugin 没有 fastify-plugin 封装,parser
|
||||
* 若加到外层实例上会让全站每个 POST 都接受 form-urlencoded。而 form-urlencoded
|
||||
* 是跨站 HTML form 唯一能发出的媒体类型(application/json 会触发 CORS
|
||||
* preflight),"只认 JSON"是一层 CSRF 纵深防御,不能为了 logout 全局放掉。
|
||||
*/
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SESSION_COOKIE_NAME } from "../../src/admin/auth/session.js";
|
||||
|
||||
/** 复刻 authRoutes 里 logout 的注册方式(不拉起整个 admin plugin 与 Prisma)。 */
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(cookie);
|
||||
|
||||
await app.register(async (scope) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// 作用域外的改写型端点:用来证明 parser 没有漏出去。
|
||||
app.post("/api/unrelated", async () => ({ ok: true }));
|
||||
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /auth/logout content-type tolerance", () => {
|
||||
it("accepts a POST with no Content-Type", async () => {
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({ method: "POST", url: "/auth/logout" });
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(JSON.stringify(res.headers["set-cookie"])).toContain(SESSION_COOKIE_NAME);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("accepts an empty form-encoded POST (curl -d '' 的默认)", async () => {
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/logout",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
payload: "",
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("accepts a form-encoded POST with a body, ignoring it", async () => {
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/logout",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
payload: "role=OWNER&x=1",
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("accepts application/json with an empty body", async () => {
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/logout",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: "",
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// 护栏:catch-all parser 不得泄漏到作用域外的路由。
|
||||
it("does NOT make unrelated POST routes accept form-encoded bodies", async () => {
|
||||
const app = await buildApp();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/unrelated",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
payload: "role=OWNER",
|
||||
});
|
||||
expect(res.statusCode).toBe(415);
|
||||
expect(res.json().code).toBe("FST_ERR_CTP_INVALID_MEDIA_TYPE");
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user