forked from bai/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>文件库</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1621
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "filelib-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { api, UnauthenticatedError } from "./lib/api.js";
|
||||
import { me, authChecked } from "./lib/stores.js";
|
||||
import type { MeResponse } from "./lib/types.js";
|
||||
import LoginView from "./lib/LoginView.svelte";
|
||||
import BrowserShell from "./lib/BrowserShell.svelte";
|
||||
import Toasts from "./lib/Toasts.svelte";
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
me.set(await api<MeResponse>("/database/api/me"));
|
||||
} catch (e) {
|
||||
if (!(e instanceof UnauthenticatedError)) console.error(e);
|
||||
me.set(null);
|
||||
} finally {
|
||||
authChecked.set(true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !$authChecked}
|
||||
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
|
||||
{:else if $me}
|
||||
<BrowserShell />
|
||||
{:else}
|
||||
<LoginView />
|
||||
{/if}
|
||||
|
||||
<Toasts />
|
||||
@@ -0,0 +1,66 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 全局 UI 主题令牌(与 hub 端 uiTheme.ts 同源) */
|
||||
@theme {
|
||||
--color-bg: #fcfcfb;
|
||||
--color-panel: #ffffff;
|
||||
--color-sidebar: #f7f7f5;
|
||||
--color-ink: #1a1a18;
|
||||
--color-ink-2: #6b6a66;
|
||||
--color-ink-3: #9c9b96;
|
||||
--color-line: #ecece8;
|
||||
--color-line-soft: #f1f1ee;
|
||||
--color-hover: #f4f4f1;
|
||||
--color-selected: #ebebe7;
|
||||
--color-accent: #1a1a18;
|
||||
--color-accent-hover: #333330;
|
||||
--color-danger: #a13a33;
|
||||
--color-guide: #e9e9e5;
|
||||
--color-diff-add-bg: #f3f6f2;
|
||||
--color-diff-add-text: #4a6741;
|
||||
--color-diff-del-bg: #f8f2f1;
|
||||
--color-diff-del-text: #a13a33;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* diff 渲染 */
|
||||
pre.diff {
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
pre.diff .add {
|
||||
display: block;
|
||||
color: var(--color-diff-add-text);
|
||||
background: var(--color-diff-add-bg);
|
||||
}
|
||||
pre.diff .del {
|
||||
display: block;
|
||||
color: var(--color-diff-del-text);
|
||||
background: var(--color-diff-del-bg);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { api } from "./api.js";
|
||||
import { me, toastErr, toastOk } from "./stores.js";
|
||||
import { treeVersion, bumpTree, currentNode, selectedFilePath, clearSelectedFile, bumpFiles } from "./browser.js";
|
||||
import type { NodeChild } from "./types.js";
|
||||
import TreeNode from "./TreeNode.svelte";
|
||||
import NodeDetailPanel from "./NodeDetailPanel.svelte";
|
||||
import FileEditor from "./FileEditor.svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
let roots = $state<NodeChild[] | null>(null);
|
||||
let treeError = $state<string | null>(null);
|
||||
let showCreateRoot = $state(false);
|
||||
let newName = $state("");
|
||||
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
|
||||
let newDesc = $state("");
|
||||
|
||||
async function loadRoots(): Promise<void> {
|
||||
try {
|
||||
const r = await api<{ nodes: NodeChild[] }>("/database/api/nodes");
|
||||
roots = r.nodes;
|
||||
treeError = null;
|
||||
} catch (e) {
|
||||
treeError = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadRoots);
|
||||
$effect(() => {
|
||||
void $treeVersion;
|
||||
void loadRoots();
|
||||
});
|
||||
|
||||
async function createRoot(): Promise<void> {
|
||||
const name = newName.trim();
|
||||
if (name === "") return;
|
||||
try {
|
||||
await api("/database/api/nodes", {
|
||||
method: "POST",
|
||||
body: {
|
||||
parentId: null,
|
||||
kind: newKind,
|
||||
name,
|
||||
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
|
||||
},
|
||||
});
|
||||
toastOk("已创建");
|
||||
showCreateRoot = false;
|
||||
newName = ""; newKind = "FOLDER"; newDesc = "";
|
||||
bumpTree();
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch { /* ignore */ }
|
||||
me.set(null);
|
||||
}
|
||||
|
||||
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase());
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
<!-- 侧栏 -->
|
||||
<aside class="flex w-[300px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
|
||||
<span class="text-[15px] font-semibold text-ink">文件库</span>
|
||||
{#if $me?.isWebsiteAdmin}
|
||||
<button class="rounded-lg border border-line bg-panel px-2.5 py-1 text-[11.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateRoot = true)}>
|
||||
+ 根目录
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-2 py-2 text-[13px]">
|
||||
{#if roots === null}
|
||||
<div class="px-3 py-6 text-center text-xs text-ink-3">加载中…</div>
|
||||
{:else if treeError}
|
||||
<div class="px-3 py-6 text-center text-xs text-danger">{treeError}</div>
|
||||
{:else if roots.length === 0}
|
||||
<div class="px-3 py-6 text-center text-xs text-ink-3">
|
||||
{$me?.isWebsiteAdmin ? "空文件库 · 点上方「+ 根目录」开始" : "文件库为空,请联系管理员创建根目录"}
|
||||
</div>
|
||||
{:else}
|
||||
{#each roots as node (node.id)}
|
||||
<TreeNode {node} depth={0} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
|
||||
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
|
||||
<span class="flex-1 truncate text-ink">{$me?.userId ?? ""}</span>
|
||||
<button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主区 -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<NodeDetailPanel />
|
||||
</main>
|
||||
|
||||
<!-- 右侧:文件预览/编辑栏(选中文件时出现) -->
|
||||
{#if $selectedFilePath && $currentNode?.kind === "PROJECT"}
|
||||
<section class="flex w-[46%] min-w-[420px] shrink-0 flex-col overflow-y-auto border-l border-line-soft bg-bg p-4">
|
||||
<FileEditor
|
||||
projectId={$currentNode.id}
|
||||
path={$selectedFilePath}
|
||||
role={$currentNode.role}
|
||||
onchanged={bumpFiles}
|
||||
onclose={clearSelectedFile}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showCreateRoot}
|
||||
<Modal title="新建根目录" onclose={() => (showCreateRoot = false)}>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-[11.5px] text-ink-3" for="root-name">名称</label>
|
||||
<input id="root-name" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newName} placeholder="例如:物理教研" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-[11.5px] text-ink-3" for="root-kind">类型</label>
|
||||
<select id="root-kind" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newKind}>
|
||||
<option value="FOLDER">文件夹</option>
|
||||
<option value="PROJECT">项目(课程资源库)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-[11.5px] text-ink-3" for="root-desc">简介(可选)</label>
|
||||
<textarea id="root-desc" rows="3" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs outline-none focus:border-accent" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateRoot = false)}>取消</button>
|
||||
<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={createRoot}>创建</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -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}
|
||||
@@ -0,0 +1,137 @@
|
||||
<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";
|
||||
|
||||
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("");
|
||||
let uploadInput: HTMLInputElement;
|
||||
|
||||
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="rounded-xl border border-line-soft bg-panel p-5">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-[13px] font-semibold">项目文件({files?.length ?? 0})</div>
|
||||
{#if canEdit}
|
||||
<div class="flex gap-2">
|
||||
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showNewFile = true)}>+ 新建文件</button>
|
||||
<button class="rounded-lg bg-accent px-3 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={() => uploadInput.click()}>上传文件</button>
|
||||
<input bind:this={uploadInput} type="file" class="hidden" onchange={doUpload} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if files === null && loadError === null}
|
||||
<div class="py-5 text-center text-xs text-ink-3">加载中…</div>
|
||||
{:else if loadError}
|
||||
<div class="py-5 text-center text-xs text-danger">{loadError}</div>
|
||||
{:else if files && files.length === 0}
|
||||
<div class="py-5 text-center text-xs text-ink-3">空仓库 · 可新建或上传文件</div>
|
||||
{:else if files}
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<tbody>
|
||||
{#each files as f (f.path)}
|
||||
<tr
|
||||
class="cursor-pointer border-t border-line-soft first:border-t-0 {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
|
||||
onclick={() => selectedFilePath.set(f.path)}
|
||||
>
|
||||
<td class="py-2 font-mono text-[12.5px] text-ink">{f.path}</td>
|
||||
<td class="py-2 text-right font-mono text-[11px] text-ink-3">{f.size} B</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showNewFile}
|
||||
<Modal title="新建文件" onclose={() => (showNewFile = false)}>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-[11.5px] text-ink-3" for="nf-path">路径</label>
|
||||
<input id="nf-path" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-[13px] outline-none focus:border-accent" bind:value={newPath} placeholder="docs/intro.md" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-[11.5px] text-ink-3" for="nf-content">内容</label>
|
||||
<textarea id="nf-content" rows="8" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs outline-none focus:border-accent" bind:value={newContent} placeholder="内容…"></textarea>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showNewFile = false)}>取消</button>
|
||||
<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={submitNewFile}>创建</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { api } from "./api.js";
|
||||
|
||||
interface LoginInfo {
|
||||
orgSlug: string;
|
||||
devLoginEnabled: boolean;
|
||||
}
|
||||
|
||||
let info = $state<LoginInfo | null>(null);
|
||||
let loadFailed = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
info = await api<LoginInfo>("/database/api/login-info");
|
||||
} catch {
|
||||
loadFailed = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-full items-center justify-center p-6">
|
||||
<div class="w-full max-w-[380px] rounded-2xl border border-line-soft bg-panel p-9 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
|
||||
<div class="text-center text-[26px] font-semibold tracking-wide text-ink">文件库</div>
|
||||
<p class="mt-2.5 mb-8 text-center text-[13px] text-ink-3">课程资源与教研文件,一处安放,随处可查</p>
|
||||
|
||||
{#if info}
|
||||
<a
|
||||
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
|
||||
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
|
||||
>使用飞书登录</a>
|
||||
|
||||
{#if info.devLoginEnabled}
|
||||
<div class="my-5 flex items-center gap-2.5 text-[11px] text-ink-3">
|
||||
<span class="flex-1 border-t border-line-soft"></span>开发模式
|
||||
<span class="flex-1 border-t border-line-soft"></span>
|
||||
</div>
|
||||
<a href="/app/dev-login-teacher" class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover">⚡ 一键登录(老师)</a>
|
||||
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
|
||||
{/if}
|
||||
{:else if loadFailed}
|
||||
<p class="text-center text-[12.5px] text-danger">无法加载登录配置,请稍后重试</p>
|
||||
{:else}
|
||||
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<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="w-full max-w-[440px] rounded-2xl border border-line-soft bg-panel p-6 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
|
||||
<div class="mb-4 text-[15px] font-semibold">{title}</div>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { api } from "./api.js";
|
||||
import { currentNode, breadcrumb, bumpTree, clearSelectedFile } from "./browser.js";
|
||||
import { toastOk, toastErr } from "./stores.js";
|
||||
import OverviewPanel from "./OverviewPanel.svelte";
|
||||
import FilesPanel from "./FilesPanel.svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
let tab = $state<"detail" | "files">("detail");
|
||||
let showCreateChild = $state(false);
|
||||
let newName = $state("");
|
||||
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
|
||||
let newDesc = $state("");
|
||||
|
||||
const node = $derived($currentNode);
|
||||
const crumbs = $derived($breadcrumb);
|
||||
const canManage = $derived(node?.role === "MANAGE");
|
||||
const canEdit = $derived(canManage || node?.role === "EDIT");
|
||||
|
||||
$effect(() => {
|
||||
void node?.id;
|
||||
tab = "detail";
|
||||
clearSelectedFile();
|
||||
});
|
||||
|
||||
async function createChild(): Promise<void> {
|
||||
const name = newName.trim();
|
||||
if (name === "" || node === null) return;
|
||||
try {
|
||||
await api("/database/api/nodes", {
|
||||
method: "POST",
|
||||
body: {
|
||||
parentId: node.id,
|
||||
kind: newKind,
|
||||
name,
|
||||
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
|
||||
},
|
||||
});
|
||||
toastOk("已创建");
|
||||
showCreateChild = false;
|
||||
newName = ""; newKind = "FOLDER"; newDesc = "";
|
||||
bumpTree();
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function renameNode(): Promise<void> {
|
||||
if (node === null) return;
|
||||
const name = prompt("新名称", node.name);
|
||||
if (name === null) return;
|
||||
try {
|
||||
await api(`/database/api/nodes/${node.id}`, { method: "PATCH", body: { name } });
|
||||
toastOk("已重命名");
|
||||
bumpTree();
|
||||
currentNode.update((n) => (n ? { ...n, name } : n));
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNode(): Promise<void> {
|
||||
if (node === null || !confirm(`确认删除「${node.name}」?软删除后不可见。`)) return;
|
||||
try {
|
||||
await api(`/database/api/nodes/${node.id}`, { method: "DELETE" });
|
||||
toastOk("已删除");
|
||||
currentNode.set(null);
|
||||
bumpTree();
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if node === null}
|
||||
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">从左侧选择一个文件夹或项目</div>
|
||||
{:else}
|
||||
<div class="mx-auto max-w-[880px] px-9 py-9">
|
||||
<div class="mb-2 text-[12.5px] text-ink-3">
|
||||
{#each crumbs as c, i (i)}
|
||||
{#if i > 0}<span class="mx-1 text-line">/</span>{/if}
|
||||
<span>{c.name ?? "…"}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5 text-[19px] font-semibold text-ink">
|
||||
{node.name}
|
||||
<span class="rounded-full border border-line-soft bg-panel px-2 py-0.5 font-mono text-[10.5px] text-ink-3">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if canEdit && node.kind === "FOLDER"}
|
||||
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateChild = true)}>+ 新建</button>
|
||||
{/if}
|
||||
{#if canManage}
|
||||
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={renameNode}>重命名</button>
|
||||
<button class="rounded-lg border border-transparent px-3 py-1.5 text-[12.5px] font-medium text-danger transition hover:bg-[#A13A3312]" onclick={deleteNode}>删除</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if node.kind === "FOLDER"}
|
||||
<div class="py-7 text-[13px] text-ink-3">点击左侧树展开以浏览子内容</div>
|
||||
{:else}
|
||||
<div class="mb-5 flex gap-1 border-b border-line-soft">
|
||||
{#each [["detail", "概览"], ["files", "文件"]] as [id, label] (id)}
|
||||
<button
|
||||
class="border-b-2 px-3.5 py-2 text-[13px] transition {tab === id ? 'border-accent font-semibold text-ink' : 'border-transparent text-ink-3 hover:text-ink'}"
|
||||
onclick={() => (tab = id as "detail" | "files")}
|
||||
>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if tab === "detail"}
|
||||
<OverviewPanel {node} />
|
||||
{:else}
|
||||
<FilesPanel {node} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showCreateChild && node}
|
||||
<Modal title="新建子节点" onclose={() => (showCreateChild = false)}>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-name">名称</label>
|
||||
<input id="child-name" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newName} placeholder="例如:物理必修一" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-kind">类型</label>
|
||||
<select id="child-kind" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newKind}>
|
||||
<option value="FOLDER">文件夹</option>
|
||||
<option value="PROJECT">项目(课程资源库)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-desc">简介(可选)</label>
|
||||
<textarea id="child-desc" rows="3" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs outline-none focus:border-accent" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateChild = false)}>取消</button>
|
||||
<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={createChild}>创建</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { api } from "./api.js";
|
||||
import { toastOk, toastErr } from "./stores.js";
|
||||
import { currentNode } from "./browser.js";
|
||||
import type { ExportJob, NodeDetail } from "./types.js";
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
let { node }: { node: NodeDetail } = $props();
|
||||
|
||||
let showEditDesc = $state(false);
|
||||
let descDraft = $state("");
|
||||
let exportJob = $state<ExportJob | null>(null);
|
||||
|
||||
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
|
||||
const roleLabel = $derived(node.role === "MANAGE" ? "可管理" : node.role === "EDIT" ? "可编辑" : "只读");
|
||||
|
||||
$effect(() => {
|
||||
void node.id;
|
||||
exportJob = null;
|
||||
});
|
||||
|
||||
function openEditDesc(): void {
|
||||
descDraft = node.description ?? "";
|
||||
showEditDesc = true;
|
||||
}
|
||||
|
||||
async function saveDesc(): Promise<void> {
|
||||
const description = descDraft.trim();
|
||||
try {
|
||||
await api(`/database/api/nodes/${node.id}`, {
|
||||
method: "PATCH",
|
||||
body: { description: description === "" ? null : description },
|
||||
});
|
||||
toastOk("简介已保存");
|
||||
showEditDesc = false;
|
||||
const next = description === "" ? null : description;
|
||||
currentNode.update((n) => (n && n.id === node.id ? { ...n, description: next } : n));
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitExport(): Promise<void> {
|
||||
try {
|
||||
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
|
||||
method: "POST",
|
||||
body: { target: "manifest" },
|
||||
});
|
||||
toastOk("导出已提交");
|
||||
void pollExport(r.jobId);
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function pollExport(jobId: string): Promise<void> {
|
||||
for (;;) {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
try {
|
||||
const job = await api<ExportJob>(`/database/api/exports/${jobId}`);
|
||||
exportJob = job;
|
||||
if (job.status === "DONE" || job.status === "FAILED") break;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-line-soft bg-panel p-5">
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-[11.5px] text-ink-3">简介</div>
|
||||
<div class="text-[13.5px] leading-7 text-ink">
|
||||
{#if node.description}
|
||||
{node.description}
|
||||
{:else}
|
||||
<span class="italic text-ink-3">暂无简介</span>
|
||||
{/if}
|
||||
{#if canEdit}
|
||||
<button class="ml-2.5 rounded-lg border border-line bg-panel px-2.5 py-0.5 align-middle text-[11.5px] text-ink transition hover:bg-hover" onclick={openEditDesc}>编辑</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-4 border-t border-line-soft"></div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 text-[13px] text-ink-2">
|
||||
<div>我的角色 <b class="font-semibold text-ink">{roleLabel}</b></div>
|
||||
<div>创建时间 <b class="font-semibold text-ink">{new Date(node.createdAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}</b></div>
|
||||
<div>最近修改 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}</b></div>
|
||||
</div>
|
||||
|
||||
<div class="my-4 border-t border-line-soft"></div>
|
||||
|
||||
<div class="mb-2 text-[12.5px] font-semibold">导出</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={submitExport}>开始导出</button>
|
||||
{#if exportJob}
|
||||
<span class="font-mono text-[11px] text-ink-3">
|
||||
{#if exportJob.status === "DONE"}
|
||||
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
|
||||
{:else if exportJob.status === "FAILED"}
|
||||
失败:{exportJob.error ?? ""}
|
||||
{:else}
|
||||
{exportJob.status}…
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showEditDesc}
|
||||
<Modal title="编辑简介" onclose={() => (showEditDesc = false)}>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-[11.5px] text-ink-3" for="desc-draft">简要说明这个项目的内容</label>
|
||||
<textarea id="desc-draft" rows="5" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] leading-7 outline-none focus:border-accent" bind:value={descDraft} placeholder="例如:高中物理必修一第三章,表面张力相关内容……"></textarea>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showEditDesc = false)}>取消</button>
|
||||
<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={saveDesc}>保存</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { toasts } from "./stores.js";
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{#each $toasts as t (t.id)}
|
||||
<div class="max-w-[340px] rounded-lg px-4 py-2 text-sm text-white {t.kind === 'err' ? 'bg-[#7E2C26]' : 'bg-[#333230]'}">
|
||||
{t.message}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import TreeNode from "./TreeNode.svelte";
|
||||
import { api } from "./api.js";
|
||||
import { expanded, currentNode, breadcrumb, toggleExpanded, treeVersion } from "./browser.js";
|
||||
import { toastErr } from "./stores.js";
|
||||
import type { BreadcrumbEntry, NodeChild, NodeDetail } from "./types.js";
|
||||
|
||||
let { node, depth }: { node: NodeChild; depth: number } = $props();
|
||||
|
||||
let children = $state<NodeChild[] | null>(null);
|
||||
const isOpen = $derived($expanded.has(node.id));
|
||||
const isSelected = $derived($currentNode?.id === node.id);
|
||||
|
||||
// 树刷新信号(增/删/移/重命名)→ 失效子节点缓存,展开状态下随之重载
|
||||
$effect(() => {
|
||||
void $treeVersion;
|
||||
children = null;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && node.kind === "FOLDER" && children === null) {
|
||||
api<{ nodes: NodeChild[] }>(`/database/api/nodes?parentId=${encodeURIComponent(node.id)}`)
|
||||
.then((r) => (children = r.nodes))
|
||||
.catch((e) => toastErr(e instanceof Error ? e.message : String(e)));
|
||||
}
|
||||
});
|
||||
|
||||
async function select(): Promise<void> {
|
||||
if (node.kind === "FOLDER") toggleExpanded(node.id);
|
||||
try {
|
||||
const [detail, crumb] = await Promise.all([
|
||||
api<{ node: NodeDetail }>(`/database/api/nodes/${node.id}`),
|
||||
api<{ breadcrumb: BreadcrumbEntry[] }>(`/database/api/nodes/${node.id}/breadcrumb`),
|
||||
]);
|
||||
currentNode.set(detail.node);
|
||||
breadcrumb.set(crumb.breadcrumb);
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div
|
||||
class="tree-item flex cursor-pointer items-center gap-1 rounded-lg px-1.5 py-1.5 select-none {isSelected ? 'bg-selected' : 'hover:bg-hover'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={select}
|
||||
onkeydown={(e) => e.key === "Enter" && select()}
|
||||
>
|
||||
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-ink-3">
|
||||
{#if node.kind === "FOLDER"}
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
|
||||
{#if isOpen}<path d="M6 9l6 6 6-6z" />{:else}<path d="M9 6l6 6-6 6z" />{/if}
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="flex h-4 w-4 shrink-0 items-center justify-center {node.kind === 'PROJECT' ? 'text-ink' : 'text-ink-3'}">
|
||||
{#if node.kind === "PROJECT"}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||
{:else}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="truncate">{node.name}</span>
|
||||
{#if node.role !== "MANAGE"}
|
||||
<span class="ml-auto pr-1 font-mono text-[10px] text-ink-3">{node.role}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if node.kind === "FOLDER" && isOpen}
|
||||
<div class="ml-[15px] border-l border-guide pl-1">
|
||||
{#if children === null}
|
||||
<div class="px-3 py-1.5 text-xs text-ink-3">…</div>
|
||||
{:else}
|
||||
{#each children as child (child.id)}
|
||||
<TreeNode node={child} depth={depth + 1} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
/** 与 /database/api/* 的约定一致;401 时清空会话(回到登录视图)。 */
|
||||
|
||||
import { me } from "./stores.js";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthenticatedError extends Error {
|
||||
constructor() {
|
||||
super("unauthenticated");
|
||||
this.name = "UnauthenticatedError";
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOpts {
|
||||
readonly method?: string;
|
||||
readonly body?: unknown;
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
method: opts.method ?? "GET",
|
||||
...(opts.body !== undefined
|
||||
? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts.body) }
|
||||
: {}),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
me.set(null);
|
||||
throw new UnauthenticatedError();
|
||||
}
|
||||
if (res.status === 204) return null as T;
|
||||
const text = await res.text();
|
||||
const data = text === "" ? null : (JSON.parse(text) as unknown);
|
||||
if (!res.ok) {
|
||||
const err = (data as { error?: { code?: string; message?: string } } | null)?.error ?? {};
|
||||
throw new ApiError(res.status, err.code ?? "unknown", err.message ?? res.statusText, err as Record<string, unknown>);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { BreadcrumbEntry, NodeDetail } from "./types.js";
|
||||
|
||||
/** 树展开集合 / 当前选中节点 / 面包屑 / 树刷新计数。 */
|
||||
export const expanded = writable<Set<string>>(new Set());
|
||||
export const currentNode = writable<NodeDetail | null>(null);
|
||||
export const breadcrumb = writable<BreadcrumbEntry[]>([]);
|
||||
export const treeVersion = writable(0);
|
||||
|
||||
/** 右侧预览栏:当前选中文件路径(项目内);切换节点时清空。 */
|
||||
export const selectedFilePath = writable<string | null>(null);
|
||||
/** 文件列表刷新计数(编辑器保存/删除后 bump,列表随之重载)。 */
|
||||
export const filesVersion = writable(0);
|
||||
|
||||
export function bumpTree(): void {
|
||||
treeVersion.update((v) => v + 1);
|
||||
}
|
||||
|
||||
export function bumpFiles(): void {
|
||||
filesVersion.update((v) => v + 1);
|
||||
}
|
||||
|
||||
export function clearSelectedFile(): void {
|
||||
selectedFilePath.set(null);
|
||||
}
|
||||
|
||||
export function toggleExpanded(id: string): void {
|
||||
expanded.update((set) => {
|
||||
const next = new Set(set);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { MeResponse } from "./types.js";
|
||||
|
||||
/** 当前登录身份;null = 未登录(显示登录视图)。 */
|
||||
export const me = writable<MeResponse | null>(null);
|
||||
export const authChecked = writable(false);
|
||||
|
||||
export interface ToastItem {
|
||||
readonly id: number;
|
||||
readonly message: string;
|
||||
readonly kind: "info" | "err";
|
||||
}
|
||||
|
||||
let nextToastId = 1;
|
||||
export const toasts = writable<ToastItem[]>([]);
|
||||
|
||||
export function toast(message: string, kind: ToastItem["kind"] = "info"): void {
|
||||
const id = nextToastId++;
|
||||
toasts.update((list) => [...list, { id, message, kind }]);
|
||||
setTimeout(() => {
|
||||
toasts.update((list) => list.filter((t) => t.id !== id));
|
||||
}, 3600);
|
||||
}
|
||||
|
||||
export const toastOk = (m: string): void => toast(m, "info");
|
||||
export const toastErr = (m: string): void => toast(m, "err");
|
||||
@@ -0,0 +1,85 @@
|
||||
/** 与后端 /database/api/* 响应形状对齐。 */
|
||||
|
||||
export type NodeKind = "FOLDER" | "PROJECT";
|
||||
export type Role = "VIEW" | "EDIT" | "MANAGE";
|
||||
|
||||
export interface NodeChild {
|
||||
readonly id: string;
|
||||
readonly parentId: string | null;
|
||||
readonly kind: NodeKind;
|
||||
readonly name: string;
|
||||
readonly role: Role;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BreadcrumbEntry {
|
||||
readonly depth: number;
|
||||
readonly id: string | null;
|
||||
readonly name: string | null;
|
||||
readonly kind: NodeKind;
|
||||
}
|
||||
|
||||
export interface NodeDetail {
|
||||
readonly id: string;
|
||||
readonly parentId: string | null;
|
||||
readonly kind: NodeKind;
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly role: Role;
|
||||
readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED";
|
||||
readonly independentPermission: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
readonly userId: string;
|
||||
readonly isWebsiteAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export type FileContentEncoding = "utf8" | "base64";
|
||||
|
||||
export interface FileContent {
|
||||
readonly path: string;
|
||||
readonly version: string;
|
||||
readonly encoding: FileContentEncoding;
|
||||
readonly content: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
readonly version: string;
|
||||
readonly message: string;
|
||||
readonly author?: string;
|
||||
readonly committedAt: string;
|
||||
}
|
||||
|
||||
export interface ExportJob {
|
||||
readonly id: string;
|
||||
readonly nodeId: string;
|
||||
readonly target: string;
|
||||
readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
|
||||
readonly error: string | null;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface Grant {
|
||||
readonly id: string;
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
readonly role: Role;
|
||||
readonly isCreatorGrant: boolean;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface GroupSearchResult {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly breadcrumb: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
import "./app.css";
|
||||
|
||||
export default mount(App, { target: document.getElementById("app")! });
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// 老师端独立 SPA:构建产物由 hub 后端静态托管于 /app;
|
||||
// 开发时 vite dev(:5173)把 API/认证请求代理到后端(:8788)。
|
||||
const backend = "http://127.0.0.1:8788";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), tailwindcss()],
|
||||
base: "/app/",
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/database/api": backend,
|
||||
"/auth": backend,
|
||||
"/app/dev-login": backend,
|
||||
"/app/dev-login-teacher": backend,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user