forked from EduCraft/curriculum-project-hub
207 lines
7.7 KiB
Svelte
207 lines
7.7 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* 文件编辑器(模态框形式)。打开后加载文件内容并使用 CodeMirror 编辑,
|
|
* 支持语法高亮、版本冲突处理、历史查看与删除。
|
|
*/
|
|
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";
|
|
import Icon from "./Icon.svelte";
|
|
import CodeEditor from "./CodeEditor.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");
|
|
const filename = $derived(path.split("/").pop() ?? "");
|
|
|
|
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;
|
|
// 内容未变化时不提交,避免产生空 commit。
|
|
if (draft === file.content) {
|
|
toastOk("内容无变化,未提交");
|
|
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();
|
|
onclose();
|
|
} 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>
|
|
|
|
<!-- 主编辑器模态框:宽屏 overlay -->
|
|
<div
|
|
class="fixed inset-0 z-40 flex items-center justify-center bg-black/30 p-4"
|
|
role="presentation"
|
|
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
|
>
|
|
<div class="flex h-[85vh] w-full max-w-[900px] flex-col rounded-2xl border border-line-soft bg-panel shadow-[0_4px_20px_rgba(26,26,24,.07)]">
|
|
<!-- 顶栏 -->
|
|
<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>
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
{#if file}
|
|
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>
|
|
<Icon name="download" size={13} /> 下载
|
|
</a>
|
|
<button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button>
|
|
{#if canEdit}
|
|
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除</button>
|
|
{/if}
|
|
{/if}
|
|
<button class="btn !px-2.5" onclick={onclose} title="关闭" aria-label="关闭编辑器">✕</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 编辑器主体 -->
|
|
<div class="flex-1 overflow-y-auto px-6 py-4">
|
|
{#if loadError}
|
|
<div class="text-xs text-danger">{loadError}</div>
|
|
{:else if file === null}
|
|
<div class="quiet">加载中…</div>
|
|
{:else if file.encoding === "base64"}
|
|
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
|
|
{:else}
|
|
<CodeEditor value={draft} filename={path} readonly={!canEdit} onchange={(v) => (draft = v)} />
|
|
{/if}
|
|
|
|
{#if conflict}
|
|
<div class="mt-4 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="btn" onclick={acceptLatest}>载入最新内容</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- 底栏 -->
|
|
{#if canEdit && file && file.encoding !== "base64"}
|
|
<div class="flex shrink-0 justify-end border-t border-line-soft px-6 py-3">
|
|
<button class="btn btn-primary" onclick={save}>提交修改</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if showHistory}
|
|
<Modal title="版本历史" onclose={() => (showHistory = false)}>
|
|
<div class="max-h-80 overflow-y-auto">
|
|
{#each history as v (v.version)}
|
|
<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>
|
|
<div class="mt-3 flex justify-end">
|
|
<button class="btn" onclick={() => (showHistory = false)}>关闭</button>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|