feat(filelib): 项目新增修改历史 tab 并隐藏 version hash id

This commit is contained in:
2026-07-27 21:09:10 +08:00
parent 8c26c42e0c
commit 1dab83f9db
8 changed files with 234 additions and 9 deletions
+11 -6
View File
@@ -128,9 +128,6 @@
<div class="flex shrink-0 items-center justify-between border-b border-line-soft px-6 py-4"> <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"> <div class="flex min-w-0 items-center gap-3">
<span class="text-[15px] font-semibold text-ink truncate">{filename}</span> <span class="text-[15px] font-semibold text-ink truncate">{filename}</span>
{#if file}
<span class="file-meta">@ {file.version}</span>
{/if}
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
{#if file} {#if file}
@@ -183,9 +180,17 @@
<Modal title="版本历史" onclose={() => (showHistory = false)}> <Modal title="版本历史" onclose={() => (showHistory = false)}>
<div class="max-h-80 overflow-y-auto"> <div class="max-h-80 overflow-y-auto">
{#each history as v (v.version)} {#each history as v (v.version)}
<div class="border-t border-line-soft py-2 text-xs first:border-t-0"> <div class="flex items-center gap-2.5 border-t border-line-soft py-2.5 first:border-t-0">
<span class="font-mono text-accent">{v.version}</span> {v.message} <span
<div class="text-ink-3">{new Date(v.committedAt).toLocaleString("zh-CN")}{v.author ? " · " + v.author : ""}</div> 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> </div>
{/each} {/each}
</div> </div>
+113
View File
@@ -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>
@@ -5,10 +5,11 @@
import OverviewPanel from "./OverviewPanel.svelte"; import OverviewPanel from "./OverviewPanel.svelte";
import FilesPanel from "./FilesPanel.svelte"; import FilesPanel from "./FilesPanel.svelte";
import GrantsPanel from "./GrantsPanel.svelte"; import GrantsPanel from "./GrantsPanel.svelte";
import HistoryPanel from "./HistoryPanel.svelte";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
type Tab = "detail" | "files" | "grants"; type Tab = "detail" | "files" | "history" | "grants";
let tab = $state<Tab>("detail"); let tab = $state<Tab>("detail");
let showCreateChild = $state(false); let showCreateChild = $state(false);
let newName = $state(""); let newName = $state("");
@@ -20,11 +21,12 @@
const canManage = $derived(node?.role === "MANAGE"); const canManage = $derived(node?.role === "MANAGE");
const canEdit = $derived(canManage || node?.role === "EDIT"); const canEdit = $derived(canManage || node?.role === "EDIT");
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;授权仅 MANAGE // 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;修改历史仅 PROJECT;授权仅 MANAGE
// (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。 // (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。
const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => { const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => {
const out: Array<readonly [Tab, string]> = [["detail", "概览"]]; const out: Array<readonly [Tab, string]> = [["detail", "概览"]];
if (node?.kind === "PROJECT") out.push(["files", "文件"]); if (node?.kind === "PROJECT") out.push(["files", "文件"]);
if (node?.kind === "PROJECT") out.push(["history", "修改历史"]);
if (canManage) out.push(["grants", "授权"]); if (canManage) out.push(["grants", "授权"]);
return out; return out;
}); });
@@ -129,6 +131,8 @@
<GrantsPanel {node} /> <GrantsPanel {node} />
{:else if tab === "files" && node.kind === "PROJECT"} {:else if tab === "files" && node.kind === "PROJECT"}
<FilesPanel {node} /> <FilesPanel {node} />
{:else if tab === "history" && node.kind === "PROJECT"}
<HistoryPanel {node} />
{:else} {:else}
<OverviewPanel {node} /> <OverviewPanel {node} />
{#if node.kind === "FOLDER"} {#if node.kind === "FOLDER"}
+9
View File
@@ -63,6 +63,15 @@ export interface VersionInfo {
readonly committedAt: string; readonly committedAt: string;
} }
/** 项目级提交历史条目(包含受影响文件路径)。 */
export interface ProjectCommitInfo {
readonly version: string;
readonly message: string;
readonly author?: string;
readonly committedAt: string;
readonly files: readonly string[];
}
export interface ExportJob { export interface ExportJob {
readonly id: string; readonly id: string;
readonly nodeId: string; readonly nodeId: string;
+12 -1
View File
@@ -10,7 +10,7 @@
import { FileLibError } from "./model.js"; import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.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 type { AccessDeps, FileLibActor } from "./treeService.js";
import { requireAccessInTx } from "./treeService.js"; import { requireAccessInTx } from "./treeService.js";
import type { FileLibNode, PrismaClient } from "@prisma/client"; import type { FileLibNode, PrismaClient } from "@prisma/client";
@@ -228,6 +228,17 @@ export async function fileHistory(
return deps.versionStore.history(project.storageDir, filePath, limit); 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( export async function diffFile(
deps: FileDeps, deps: FileDeps,
actor: FileLibActor, actor: FileLibActor,
@@ -20,6 +20,7 @@ import type {
CommitRequest, CommitRequest,
CommitResult, CommitResult,
FileEntry, FileEntry,
ProjectCommitInfo,
VersionId, VersionId,
VersionInfo, VersionInfo,
VersionStore, VersionStore,
@@ -404,5 +405,39 @@ export function createGitVersionStore(): VersionStore {
} }
return out; // git log 已是新→旧 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;
},
}; };
} }
+31
View File
@@ -45,6 +45,16 @@ export interface VersionInfo {
readonly committedAt: string; 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 { export interface FileEntry {
readonly path: string; readonly path: string;
readonly size: number; readonly size: number;
@@ -65,6 +75,8 @@ export interface VersionStore {
): Promise<CommitResult>; ): Promise<CommitResult>;
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>; diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>;
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>; history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
/** 项目级提交历史(所有文件),新→旧排列。 */
projectHistory(projectDir: string, limit?: number): Promise<ProjectCommitInfo[]>;
} }
interface StoredVersion { interface StoredVersion {
@@ -300,5 +312,24 @@ export function createInMemoryVersionStore(persistPath?: string): VersionStore {
const ordered = infos.reverse(); const ordered = infos.reverse();
return limit !== undefined ? ordered.slice(0, limit) : ordered; 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;
},
}; };
} }
+17
View File
@@ -10,6 +10,7 @@ import {
diffFile, diffFile,
fileHistory, fileHistory,
listFiles, listFiles,
projectHistory,
readFile, readFile,
readFileRaw, readFileRaw,
type FileContentEncoding, 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 异步) */ /* ------------------------------------------------------------ 导出(D10 异步) */
app.post("/database/api/projects/:id/exports", async (request, reply) => { app.post("/database/api/projects/:id/exports", async (request, reply) => {