forked from EduCraft/curriculum-project-hub
feat(filelib): 项目新增修改历史 tab 并隐藏 version hash id
This commit is contained in:
@@ -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 min-w-0 items-center gap-3">
|
||||
<span class="text-[15px] font-semibold text-ink truncate">{filename}</span>
|
||||
{#if file}
|
||||
<span class="file-meta">@ {file.version}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if file}
|
||||
@@ -183,9 +180,17 @@
|
||||
<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>
|
||||
|
||||
@@ -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 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<Tab>("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<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;
|
||||
});
|
||||
@@ -129,6 +131,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"}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user