forked from bai/curriculum-project-hub
325b4fc137
第一版迁移只把 uiTheme.ts 的 @theme 颜色令牌搬了过来,155 行里约 90 行的组件类(.btn/.panel/.input/.select/table.list/.tag/.switch/ .link-danger/.quiet 等)被丢掉,于是每个组件各自内联重述按钮、输入框、 面板的样式 —— 正是旧代码的重复问题被原样复刻,后台观感明显退化。 现在 app.css 是设计系统的唯一去处:@theme 管令牌,@layer components 管组件类。组件只带布局工具类,不重述组件样式。 图标集同样是丢的:旧面板有 13 个内联 SVG,新版一个不剩,只有纯文字的 「+」「删除」—— 这是"简陋"最直接的来源。提成 Icon.svelte 共享。 Group 节点沿用两人剪影而非文件夹图标:MemberGroup 与文件库的 FOLDER/PROJECT 是两套无关层级,图标不应混淆(ADR-0028/0021)。 顺手修 FilesPanel 的 uploadInput:bind:this 的目标要用 $state, 否则 Svelte 5 下不保证更新。
140 lines
4.8 KiB
Svelte
140 lines
4.8 KiB
Svelte
<script lang="ts">
|
|
import { api } from "./api.js";
|
|
import { toastOk, toastErr } from "./stores.js";
|
|
import { selectedFilePath, filesVersion } from "./browser.js";
|
|
import type { FileEntry, NodeDetail } from "./types.js";
|
|
import Modal from "./Modal.svelte";
|
|
import Icon from "./Icon.svelte";
|
|
|
|
let { node }: { node: NodeDetail } = $props();
|
|
|
|
let files = $state<FileEntry[] | null>(null);
|
|
let loadError = $state<string | null>(null);
|
|
let showNewFile = $state(false);
|
|
let newPath = $state("");
|
|
let newContent = $state("");
|
|
// bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。
|
|
let uploadInput = $state<HTMLInputElement | null>(null);
|
|
|
|
const canEdit = $derived(node.role !== "VIEW");
|
|
|
|
async function loadFiles(): Promise<void> {
|
|
try {
|
|
const r = await api<{ files: FileEntry[] }>(`/database/api/projects/${node.id}/files`);
|
|
files = r.files;
|
|
loadError = null;
|
|
} catch (e) {
|
|
loadError = e instanceof Error ? e.message : String(e);
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
void node.id;
|
|
void $filesVersion;
|
|
void loadFiles();
|
|
});
|
|
|
|
async function submitNewFile(): Promise<void> {
|
|
const path = newPath.trim();
|
|
if (path === "") return;
|
|
try {
|
|
await api(`/database/api/projects/${node.id}/file`, {
|
|
method: "PUT",
|
|
body: { path, content: newContent },
|
|
});
|
|
toastOk("已创建");
|
|
showNewFile = false;
|
|
newPath = ""; newContent = "";
|
|
await loadFiles();
|
|
} catch (e) {
|
|
toastErr(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}
|
|
|
|
function u8ToBase64(bytes: Uint8Array): string {
|
|
let bin = "";
|
|
const CHUNK = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK)) as unknown as number[]);
|
|
}
|
|
return btoa(bin);
|
|
}
|
|
|
|
async function doUpload(e: Event): Promise<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 上限");
|
|
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" };
|
|
try {
|
|
await api(`/database/api/projects/${node.id}/file`, { method: "PUT", body });
|
|
toastOk("已上传 " + file.name);
|
|
await loadFiles();
|
|
} catch (err) {
|
|
toastErr(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="panel">
|
|
<div class="mb-1.5 flex items-center justify-between">
|
|
<div class="section-title">项目文件({files?.length ?? 0})</div>
|
|
{#if canEdit}
|
|
<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} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if files === 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 files && files.length === 0}
|
|
<div class="quiet py-5 text-center">空仓库 · 可新建或上传文件</div>
|
|
{:else if files}
|
|
<table class="list">
|
|
<tbody>
|
|
{#each files as f (f.path)}
|
|
<tr
|
|
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
|
|
onclick={() => selectedFilePath.set(f.path)}
|
|
>
|
|
<td class="font-mono text-[12.5px] text-ink">{f.path}</td>
|
|
<td class="file-meta text-right">{f.size} B</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if showNewFile}
|
|
<Modal title="新建文件" onclose={() => (showNewFile = false)}>
|
|
<div class="form-row">
|
|
<label class="form-label" for="nf-path">路径</label>
|
|
<input id="nf-path" class="input font-mono" bind:value={newPath} placeholder="docs/intro.md" />
|
|
</div>
|
|
<div class="form-row">
|
|
<label class="form-label" for="nf-content">内容</label>
|
|
<textarea id="nf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea>
|
|
</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}
|