From 1dab83f9db325c312d0c9738862bf20db795f944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD?= <3401797899@qq.com> Date: Mon, 27 Jul 2026 21:09:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(filelib):=20=E9=A1=B9=E7=9B=AE=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=BF=AE=E6=94=B9=E5=8E=86=E5=8F=B2=20tab=20=E5=B9=B6?= =?UTF-8?q?=E9=9A=90=E8=97=8F=20version=20hash=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/filelib-web/src/lib/FileEditor.svelte | 17 ++- hub/filelib-web/src/lib/HistoryPanel.svelte | 113 ++++++++++++++++++ .../src/lib/NodeDetailPanel.svelte | 8 +- hub/filelib-web/src/lib/types.ts | 9 ++ hub/src/database/filelib/fileService.ts | 13 +- hub/src/database/filelib/gitVersionStore.ts | 35 ++++++ hub/src/database/filelib/versionStore.ts | 31 +++++ hub/src/database/routes/fileRoutes.ts | 17 +++ 8 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 hub/filelib-web/src/lib/HistoryPanel.svelte diff --git a/hub/filelib-web/src/lib/FileEditor.svelte b/hub/filelib-web/src/lib/FileEditor.svelte index 66df122..23dacb2 100644 --- a/hub/filelib-web/src/lib/FileEditor.svelte +++ b/hub/filelib-web/src/lib/FileEditor.svelte @@ -128,9 +128,6 @@
{filename} - {#if file} - @ {file.version} - {/if}
{#if file} @@ -183,9 +180,17 @@ (showHistory = false)}>
{#each history as v (v.version)} -
- {v.version} {v.message} -
{new Date(v.committedAt).toLocaleString("zh-CN")}{v.author ? " · " + v.author : ""}
+
+ +
+

{v.message}

+

+ {#if v.author}{v.author} · {/if}{new Date(v.committedAt).toLocaleString("zh-CN")} +

+
{/each}
diff --git a/hub/filelib-web/src/lib/HistoryPanel.svelte b/hub/filelib-web/src/lib/HistoryPanel.svelte new file mode 100644 index 0000000..3ca6216 --- /dev/null +++ b/hub/filelib-web/src/lib/HistoryPanel.svelte @@ -0,0 +1,113 @@ + + +
+
修改历史
+ + {#if history === null && loadError === null} +
加载中…
+ {:else if loadError} +
{loadError}
+ {:else if history && history.length === 0} +
暂无提交记录
+ {:else if history} +
+ {#each history as commit (commit.version)} + {@const isOpen = expanded.has(commit.version)} +
+ + + {#if isOpen} +
+
+ {#each commit.files as filePath (filePath)} + + {filePath} + + {/each} + {#if commit.files.length === 0} + 无文件变更信息 + {/if} +
+
+ {/if} +
+ {/each} +
+ + {#if history.length >= limit} +
+ +
+ {/if} + {/if} +
diff --git a/hub/filelib-web/src/lib/NodeDetailPanel.svelte b/hub/filelib-web/src/lib/NodeDetailPanel.svelte index fe437c2..97b65b2 100644 --- a/hub/filelib-web/src/lib/NodeDetailPanel.svelte +++ b/hub/filelib-web/src/lib/NodeDetailPanel.svelte @@ -5,10 +5,11 @@ 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"; + type Tab = "detail" | "files" | "history" | "grants"; let tab = $state("detail"); let showCreateChild = $state(false); let newName = $state(""); @@ -20,11 +21,12 @@ 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 => { const out: Array = [["detail", "概览"]]; if (node?.kind === "PROJECT") out.push(["files", "文件"]); + if (node?.kind === "PROJECT") out.push(["history", "修改历史"]); if (canManage) out.push(["grants", "授权"]); return out; }); @@ -129,6 +131,8 @@ {:else if tab === "files" && node.kind === "PROJECT"} + {:else if tab === "history" && node.kind === "PROJECT"} + {:else} {#if node.kind === "FOLDER"} diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index d39b00b..c8ff830 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -63,6 +63,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; diff --git a/hub/src/database/filelib/fileService.ts b/hub/src/database/filelib/fileService.ts index 999192e..a94dafa 100644 --- a/hub/src/database/filelib/fileService.ts +++ b/hub/src/database/filelib/fileService.ts @@ -10,7 +10,7 @@ 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"; @@ -228,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 { + const project = await requireProject(deps, actor, projectId, "VIEW"); + return deps.versionStore.projectHistory(project.storageDir, limit); +} + export async function diffFile( deps: FileDeps, actor: FileLibActor, diff --git a/hub/src/database/filelib/gitVersionStore.ts b/hub/src/database/filelib/gitVersionStore.ts index 443394e..dc34dcd 100644 --- a/hub/src/database/filelib/gitVersionStore.ts +++ b/hub/src/database/filelib/gitVersionStore.ts @@ -20,6 +20,7 @@ import type { CommitRequest, CommitResult, FileEntry, + ProjectCommitInfo, VersionId, VersionInfo, VersionStore, @@ -404,5 +405,39 @@ export function createGitVersionStore(): VersionStore { } 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; + }, }; } diff --git a/hub/src/database/filelib/versionStore.ts b/hub/src/database/filelib/versionStore.ts index 9f5a1ac..4431649 100644 --- a/hub/src/database/filelib/versionStore.ts +++ b/hub/src/database/filelib/versionStore.ts @@ -45,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; @@ -65,6 +75,8 @@ export interface VersionStore { ): Promise; diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise; history(projectDir: string, filePath: string, limit?: number): Promise; + /** 项目级提交历史(所有文件),新→旧排列。 */ + projectHistory(projectDir: string, limit?: number): Promise; } interface StoredVersion { @@ -300,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; + }, }; } diff --git a/hub/src/database/routes/fileRoutes.ts b/hub/src/database/routes/fileRoutes.ts index 334e1be..7fd2b58 100644 --- a/hub/src/database/routes/fileRoutes.ts +++ b/hub/src/database/routes/fileRoutes.ts @@ -10,6 +10,7 @@ import { diffFile, fileHistory, listFiles, + projectHistory, readFile, readFileRaw, type FileContentEncoding, @@ -183,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) => {