feat(filelib): 导出改为下载 cph 编译的真实 PDF

This commit is contained in:
2026-07-27 20:25:29 +08:00
parent ce5fbfb9a6
commit 3b544d99f4
6 changed files with 216 additions and 23 deletions
+1
View File
@@ -19,6 +19,7 @@
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6",
restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5",
download: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3",
} as const;
export type IconName = keyof typeof ICONS;
+52 -19
View File
@@ -4,12 +4,14 @@
import { currentNode } from "./browser.js";
import type { ExportJob, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
let showEditDesc = $state(false);
let descDraft = $state("");
let exportJob = $state<ExportJob | null>(null);
let exporting = $state(false);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const canManage = $derived(node.role === "MANAGE");
@@ -34,6 +36,7 @@
$effect(() => {
void node.id;
exportJob = null;
exporting = false;
});
function openEditDesc(): void {
@@ -57,31 +60,63 @@
}
}
async function submitExport(): Promise<void> {
/**
* 导出 PDF:提交 job → 轮询 → 完成即自动触发浏览器下载。
*
* 只有一个导出目标,所以不给目标选择器 —— target 由后端 adapter 固定。
* 下载走 <a download> 而非 fetch+blob:接口是 same-origin cookie 认证,
* 浏览器直接带上会话,不需要在 JS 里搬一遍字节。
*/
async function exportPdf(): Promise<void> {
if (exporting) return;
exporting = true;
exportJob = null;
try {
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
method: "POST",
body: { target: "manifest" },
body: { target: "pdf" },
});
toastOk("导出已提交");
void pollExport(r.jobId);
await pollExport(r.jobId);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
exporting = false;
}
}
async function pollExport(jobId: string): Promise<void> {
for (;;) {
await new Promise((r) => setTimeout(r, 800));
let job: ExportJob;
try {
const job = await api<ExportJob>(`/database/api/exports/${jobId}`);
exportJob = job;
if (job.status === "DONE" || job.status === "FAILED") break;
} catch {
break;
job = await api<ExportJob>(`/database/api/exports/${jobId}`);
} catch (e) {
exporting = false;
toastErr(e instanceof Error ? e.message : String(e));
return;
}
exportJob = job;
if (job.status === "DONE") {
exporting = false;
toastOk("导出完成,开始下载");
triggerDownload(`/database/api/exports/${job.id}/download`);
return;
}
if (job.status === "FAILED") {
exporting = false;
toastErr(`导出失败:${job.error ?? "未知原因"}`);
return;
}
}
}
function triggerDownload(url: string): void {
const a = document.createElement("a");
a.href = url;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
}
</script>
<div class="panel">
@@ -124,18 +159,16 @@
<div class="section-title mb-2">导出</div>
<div class="flex items-center gap-2">
<select class="select !w-auto"><option value="manifest">manifest(stub)</option></select>
<button class="btn" onclick={submitExport}>开始导出</button>
{#if exportJob}
<button class="btn" onclick={exportPdf} disabled={exporting}>
<Icon name="download" size={13} />
{exporting ? "导出中…" : "导出 PDF"}
</button>
{#if exportJob?.status === "DONE"}
<span class="file-meta">
{#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}
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download" download>重新下载</a>
</span>
{:else if exportJob?.status === "FAILED"}
<span class="file-meta text-danger">失败:{exportJob.error ?? "未知原因"}</span>
{/if}
</div>
{/if}