forked from EduCraft/curriculum-project-hub
feat(filelib)!: VersionStore 改为真 git,一项目一仓库
内存 store 换成 gitVersionStore:init 建目录并 git init,VersionId 是
commit hash,某文件的版本取 `git log -1 -- <path>`(D16 文件级版本不因
别的文件提交而失效)。删除也是一个 commit,旧版本仍可读。决策见 ADR-0030。
git 用 execFile 调系统二进制,不引依赖。每次调用钉死 --git-dir/--work-tree
并禁 hooks、隔离全局 gitconfig:项目仓库是老师上传的数据,而 storage root
默认就在本 repo 内,不钉死会让命令落到外层仓库上。
同时:
- 单文件上限改为 HUB_FILELIB_MAX_FILE_BYTES(缺省 10MiB),前端从
/database/config 读,不再两处硬编码
- commit 身份 name=displayName、email=<userId>@filelib.paradigm-edu.net;
message 缺省为「【用户名】修改了【路径】」,调用方显式传则优先
- 上传改走弹窗,路径与 commit 信息可手填(原先 prompt 只能填路径)
BREAKING CHANGE: VersionId 由计数器(v1/v2)变为 commit hash;
CommitRequest.author 由字符串变为 { userId, displayName? }。
旧 .version-store.json 不迁移,此前建的项目报 repo_not_found。
This commit is contained in:
@@ -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,8 +14,20 @@
|
||||
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");
|
||||
|
||||
@@ -34,17 +47,25 @@
|
||||
void loadFiles();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void loadConfig()
|
||||
.then((c) => (maxFileBytes = c.maxFileBytes))
|
||||
.catch(() => (maxFileBytes = null));
|
||||
});
|
||||
|
||||
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 +81,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 = `材料/${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>
|
||||
@@ -93,7 +137,7 @@
|
||||
<div class="flex gap-1.5">
|
||||
<button class="btn" onclick={() => (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>
|
||||
@@ -131,9 +175,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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user