forked from EduCraft/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
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";
|
||||
|
||||
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("");
|
||||
let loadError = $state<string | null>(null);
|
||||
let conflict = $state<{ currentVersion: string; diff: string } | null>(null);
|
||||
let showHistory = $state(false);
|
||||
let history = $state<VersionInfo[]>([]);
|
||||
|
||||
const canEdit = $derived(role !== "VIEW");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
file = await api<FileContent>(`/database/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
|
||||
draft = file.encoding === "utf8" ? file.content : "";
|
||||
loadError = null;
|
||||
conflict = null;
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
void path;
|
||||
void load();
|
||||
});
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (file === null) return;
|
||||
try {
|
||||
const r = await api<{ version: string }>(`/database/api/projects/${projectId}/file/commits`, {
|
||||
method: "POST",
|
||||
body: { path: file.path, baseVersion: file.version, content: draft },
|
||||
});
|
||||
toastOk("已提交 " + r.version);
|
||||
await load();
|
||||
onchanged();
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409 && typeof e.details?.["currentVersion"] === "string") {
|
||||
await showConflict(e.details["currentVersion"]);
|
||||
} else {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function showConflict(currentVersion: string): Promise<void> {
|
||||
if (file === null) return;
|
||||
try {
|
||||
const r = await api<{ diff: string }>(
|
||||
`/database/api/projects/${projectId}/file/diff?path=${encodeURIComponent(file.path)}&from=${encodeURIComponent(file.version)}&to=${encodeURIComponent(currentVersion)}`,
|
||||
);
|
||||
conflict = { currentVersion, diff: r.diff };
|
||||
file = { ...file, version: currentVersion };
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptLatest(): Promise<void> {
|
||||
conflict = null;
|
||||
await load();
|
||||
toast("已载入最新内容,请在此基础上合并", "info");
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
if (file === null || !confirm("删除文件 " + file.path + "?")) return;
|
||||
try {
|
||||
await api(`/database/api/projects/${projectId}/file?path=${encodeURIComponent(file.path)}`, {
|
||||
method: "DELETE",
|
||||
body: { baseVersion: file.version },
|
||||
});
|
||||
toastOk("已删除");
|
||||
file = null;
|
||||
onchanged();
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function openHistory(): Promise<void> {
|
||||
try {
|
||||
const r = await api<{ history: VersionInfo[] }>(`/database/api/projects/${projectId}/file/history?path=${encodeURIComponent(path)}`);
|
||||
history = r.history;
|
||||
showHistory = true;
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiff(diff: string): string {
|
||||
return diff
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/^\+(.*)$/gm, '<span class="add">+$1</span>')
|
||||
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loadError}
|
||||
<div class="rounded-xl border border-line-soft bg-panel p-5 text-xs text-danger">{loadError}</div>
|
||||
{:else if file}
|
||||
<div class="rounded-xl border border-line-soft bg-panel p-5">
|
||||
<div class="mb-2.5 flex items-center justify-between">
|
||||
<span class="font-mono text-[11px] text-ink-3">{file.path} @ {file.version}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<a class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>下载</a>
|
||||
<button class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" onclick={openHistory}>历史</button>
|
||||
{#if canEdit}
|
||||
<button class="rounded-lg border border-transparent px-3 py-1 text-[12px] font-medium text-danger transition hover:bg-[#A13A3312]" onclick={remove}>删除文件</button>
|
||||
{/if}
|
||||
{#if onclose}
|
||||
<button class="rounded-lg border border-line bg-panel px-2.5 py-1 text-[12px] font-medium text-ink-3 transition hover:bg-hover hover:text-ink" onclick={onclose} title="关闭预览">✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if file.encoding === "base64"}
|
||||
<div class="text-xs text-ink-3">二进制文件({file.size} B),不支持在线编辑</div>
|
||||
{:else}
|
||||
<textarea rows="14" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs leading-7 outline-none focus:border-accent" bind:value={draft} readonly={!canEdit}></textarea>
|
||||
{/if}
|
||||
|
||||
{#if canEdit && file.encoding !== "base64"}
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" 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="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" onclick={acceptLatest}>载入最新内容</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-line-soft bg-panel p-5 text-xs text-ink-3">加载中…</div>
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" onclick={() => (showHistory = false)}>关闭</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user