feat(review): P3b enable compile PDF in reformat panel

- ReformatPanel: compile tab state machine (idle/loading/success/error)
- Blob URL for PDF preview; backend path hidden
- First-compile 1-3min hint banner (dismissible)
- QUEUE_FULL 429 with fixed message
- Store: compileStatus/compileResult/compileError + actions
- prewarm_tectonic.sh: minimal .tex compilation to pre-cache TeX bundles
- Vitest: 6 new cases (guide / button / loading / success / error / queue-full)
This commit is contained in:
potaser
2026-10-13 10:24:55 +08:00
parent ac07cb2008
commit 3a72d3a861
6 changed files with 500 additions and 34 deletions
+4 -1
View File
@@ -30,4 +30,7 @@ set +a
echo "[restart] 启动审稿服务 :$PORT($PYTHON)"
nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \
> "$SCRIPT_DIR/review.log" 2>&1 &
echo "[restart] 完成;日志 review_service/review.log"
# P3b:后台预热 tectonic TeX bundle(非阻塞;仅首次需 1-3 分钟,失败不影响服务)
nohup bash "$SCRIPT_DIR/scripts/prewarm_tectonic.sh" \
> "$SCRIPT_DIR/prewarm.log" 2>&1 &
echo "[restart] 完成;日志 review_service/review.log(预热 prewarm.log)"
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# prewarm_tectonic.sh —— P3b:预下载 tectonic TeX bundle(宏包缓存)
# 目的:服务启动时预热 data/tectonic-cache/,用户首次编译不必等 1-3 分钟下载。
# 用法: bash scripts/prewarm_tectonic.sh (后台跑,非阻塞;restart.sh 自动调起)
# 注:tectonic v0.17.0 无 `download` 子命令——预热 = 用最小 hello.tex 实编译一次,
# 让 bundle/宏包落进 TECTONIC_CACHE_DIR(同 tools/README.md 预热法)。
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$SCRIPT_DIR"
TECTONIC="$SCRIPT_DIR/tools/tectonic"
[[ -x "$TECTONIC" ]] || { echo "[prewarm] tectonic 不存在,跳过:$TECTONIC"; exit 0; }
export TECTONIC_CACHE_DIR="${REVIEW_TECTONIC_CACHE_DIR:-$SCRIPT_DIR/data/tectonic-cache}"
mkdir -p "$TECTONIC_CACHE_DIR"
TMPDIR_PREWARM="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_PREWARM"' EXIT
cat > "$TMPDIR_PREWARM/hello.tex" <<'EOF'
\documentclass{article}
\begin{document}
prewarm
\end{document}
EOF
echo "[prewarm] 预下载 TeX bundle 至 $TECTONIC_CACHE_DIR(首次 1-3 分钟,仅一次)"
if "$TECTONIC" -X compile "$TMPDIR_PREWARM/hello.tex" --untrusted \
--outdir "$TMPDIR_PREWARM" >/dev/null 2>&1; then
echo "[prewarm] OK:宏包缓存就绪,用户首编可秒级命中"
else
echo "[prewarm] 预热失败(网络/磁盘):不影响服务启动,首编时仍会现场拉取" >&2
exit 0
fi
@@ -0,0 +1,178 @@
// tests/frontend/unit/edu/reformat.compile.test.js —— ReformatPanel 编译 PDF Tab(P3b)
// 1) 无 job_id → "请先完成重排版再编译" 引导
// 2) 有 job_id → "编译 PDF" 按钮可点
// 3) 点击后按钮变 "编译中…"(loading 期间禁用;store 状态驱动,切面板不中断)
// 4) 成功状态:iframe 预览 + 下载按钮 + 耗时
// 5) 失败状态:错误摘要 + log 尾折叠 <pre> + 查看完整 log
// 6) QUEUE_FULL → 固定文案"编译队列已满(最多 5 个任务)"
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
const compileTexMock = vi.fn();
vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
const actual = await importOriginal(); // 保留真实 reviewErrorMessage(QUEUE_FULL 文案走 CODE_HINTS)
return {
...actual,
getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
getDirectory: vi.fn(),
reformatTex: vi.fn(),
compileTex: (...args) => compileTexMock(...args),
};
});
import { useEduStore } from '../../../../ui/src/stores/edu.js';
import { ApiError } from '../../../../ui/src/api/http.js';
function seedResult(edu, job_id = 'abc123def456') {
edu.reformatResult = {
ok: true,
job_id,
modified_tex: '\\documentclass{article}\\begin{document}Hi\\end{document}',
changes: [],
warnings: [],
meta: { filename: 'paper.tex', journal_name: 'Plant Cell', rules_source: 'official' },
};
edu.reformatStatus = 'ready';
}
async function mountPdfTab(edu) {
const pinia = createPinia();
setActivePinia(pinia);
edu = useEduStore();
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
// 切到"编译 PDF" Tab
const tabs = wrapper.findAll('.rf-tab');
const pdfTab = tabs.find((t) => t.text() === '编译 PDF');
await pdfTab.trigger('click');
return { wrapper, edu };
}
describe('ReformatPanel 编译 PDF Tab(P3b)', () => {
beforeEach(() => {
compileTexMock.mockReset();
// jsdom 无 URL.createObjectURL:store blob 加载走 catch 回退,不致挂
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
});
it('1) 无 job_id → "请先完成重排版再编译" 引导', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
edu.reformatResult = { ok: true, job_id: null, changes: [], meta: {} };
edu.reformatStatus = 'ready';
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
const guide = wrapper.find('[data-testid="rf-compile-guide"]');
expect(guide.exists()).toBe(true);
expect(guide.text()).toContain('请先完成重排版再编译');
expect(wrapper.find('[data-testid="rf-compile-btn"]').exists()).toBe(false);
});
it('2) 有 job_id → "编译 PDF" 按钮可点', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
seedResult(edu);
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
const btn = wrapper.find('[data-testid="rf-compile-btn"]');
expect(btn.exists()).toBe(true);
expect(btn.text()).toBe('编译 PDF');
expect(btn.attributes('disabled')).toBeUndefined();
});
it('3) 点击后按钮变"编译中…"并禁用;首次编译黄条出现', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
seedResult(edu);
compileTexMock.mockReturnValue(new Promise(() => {})); // 挂起,模拟编译中
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
await wrapper.vm.$nextTick();
const btn = wrapper.find('[data-testid="rf-compile-btn"]');
expect(btn.text()).toContain('编译中');
expect(btn.attributes('disabled')).toBeDefined();
expect(wrapper.find('[data-testid="rf-compile-loading"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-first-hint"]').exists()).toBe(true);
expect(compileTexMock).toHaveBeenCalledTimes(1);
// loading 期间再点不重复提交
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
expect(compileTexMock).toHaveBeenCalledTimes(1);
});
it('4) 成功状态:iframe 预览 + 下载按钮 + 耗时', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
seedResult(edu);
edu.compileResult = {
ok: true, compile_id: 'abc123def456',
pdf_url: '/api/review/compile/abc123def456/pdf',
log_url: '/api/review/compile/abc123def456/log',
meta: { seconds: 12.34, pdf_bytes: 1048576, engine: 'tectonic' },
};
edu.compileStatus = 'ready';
edu.compilePdfUrl = ''; // blob 失败回退直链
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
await wrapper.vm.$nextTick();
const frame = wrapper.find('[data-testid="rf-pdf-frame"]');
expect(frame.exists()).toBe(true);
expect(frame.attributes('src')).toContain('/pdf');
expect(wrapper.find('[data-testid="rf-download-pdf"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-pdf-size"]').text()).toContain('MB');
expect(wrapper.find('[data-testid="rf-compile-secs"]').text()).toContain('12.3 秒');
expect(wrapper.find('[data-testid="rf-recompile"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-compile-btn"]').exists()).toBe(false); // 操作行换成下载/重编
});
it('5) 失败状态:错误摘要 + log 尾折叠区 + 完整 log 按钮', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
seedResult(edu);
edu.compileStatus = 'error';
edu.compileError = 'LaTeX 编译失败:缺少宏包';
edu.compileLogTail = 'This is tectonic\n! LaTeX Error: File `foo.sty\' not found.\n68 lines omitted...';
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
await wrapper.vm.$nextTick();
const err = wrapper.find('[data-testid="rf-compile-error"]');
expect(err.exists()).toBe(true);
expect(err.text()).toContain('缺少宏包');
const details = wrapper.find('[data-testid="rf-log-details"]');
expect(details.exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-log-body"]').text()).toContain('foo.sty');
expect(wrapper.find('[data-testid="rf-download-log"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-recompile"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="rf-pdf-frame"]').exists()).toBe(false);
});
it('6) QUEUE_FULL → 固定文案"编译队列已满(最多 5 个任务)"', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const edu = useEduStore();
seedResult(edu);
compileTexMock.mockRejectedValue(new ApiError('编译队列已满(7 个任务排队),请稍后重试', {
code: 'QUEUE_FULL', status: 429,
}));
const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
const err = wrapper.find('[data-testid="rf-compile-error"]');
expect(err.exists()).toBe(true);
expect(err.text()).toContain('编译队列已满(最多 5 个任务),请稍后重试');
// 报错后按钮立即恢复可点(重新编译)
const retry = wrapper.find('[data-testid="rf-recompile"]');
expect(retry.exists()).toBe(true);
expect(retry.attributes('disabled')).toBeUndefined();
});
});
+3 -1
View File
@@ -119,9 +119,11 @@ const CODE_HINTS = {
VALIDATION: null,
PARSE_FAILED: '论文解析失败:可能是扫描件/图片型 PDF,请换文本型 PDF。',
UPSTREAM_DOWN: '审稿模型暂时不可用,请稍后重试。',
SEED_UNAVAILABLE: '期刊库未就绪(种子数据缺失),请联系管理员。',
SEED_UNAVAILABLE: '期刊库就绪中(种子数据缺失),请稍后再试。',
TIMEOUT: '请求超时,请稍后重试。',
NETWORK: '网络异常,请检查连接后重试。',
// P3b 编译:固定口径(后端 message 带动态 pending 数,此处按规范统一为"最多 5 个")
QUEUE_FULL: '编译队列已满(最多 5 个任务),请稍后重试',
};
/** ApiError(或任意错误)→ 用户可读文案。degraded 由调用方读 err.degraded 走降级横幅样式。 */
+258 -29
View File
@@ -9,10 +9,11 @@
选定后 target = 目录 id,后端经 journal_directory 回链)。
结果三 Tab:修改后源码(Blob 下载)/ Diff 报告(按 rule_key 着色、
按 action 计数:modified=已修改、warning=建议·橙色·不计入)/
编译 PDF(P3 决策暂缓:仅「开发中」占位,不触发编译;后端 tectonic
API(P3a)保留备用,前端不暴露编译入口,引导下载 .tex 去 Overleaf/
本地编译)。
结果跨菜单保留(store reformatResult);样式 rf- 前缀,零新增依赖。 -->
编译 PDF(P3b):POST /compile {job_id}(tectonic 单槽队列,约 10-30 秒,
首次拉 bundle 1-3 分钟——store.compileFirstHint 黄条提示可切面板后台跑);
成功→blob URL 内嵌 iframe 预览 + 下载 + 耗时/重编;失败→错误摘要 +
log 尾 200 行折叠 + 完整 log 下载;QUEUE_FULL→固定文案。
结果跨菜单保留(store reformatResult/compile*);样式 rf- 前缀,零新增依赖。 -->
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { getDirectory, reviewErrorMessage } from '../../../api/review.js';
@@ -129,6 +130,53 @@ function copyJobId() {
if (id && navigator.clipboard) navigator.clipboard.writeText(String(id)).catch(() => {});
}
/* —— P3b 编译 PDF(状态全在 store → 切菜单/切 Tab 编译继续跑)—— */
const compiling = computed(() => edu.compileStatus === 'loading');
const compileReady = computed(() => edu.compileStatus === 'ready' && !!edu.compileResult);
const compileErrored = computed(() => edu.compileStatus === 'error');
const compileMeta = computed(() => edu.compileResult?.meta || null);
const pdfSrc = computed(() => edu.compilePdfUrl || edu.compileResult?.pdf_url || '');
const pdfSizeText = computed(() => {
const b = compileMeta.value?.pdf_bytes;
if (b == null) return '';
return b >= 1048576 ? `${(b / 1048576).toFixed(1)} MB` : `${Math.ceil(b / 1024)} KB`;
});
const compileLog = computed(() => edu.compileLogTail || '');
const logCollapsed = ref(true); // log 折叠区(默认收起)
const firstHintDismissed = ref(false);
const showFirstHint = computed(() =>
edu.compileFirstHint && compiling.value && !firstHintDismissed.value,
);
function startCompile() {
const jid = result.value?.job_id;
if (!jid || compiling.value) return;
logCollapsed.value = true;
edu.submitCompile(jid); // 不 await:按钮/进度由 store 状态驱动,切面板不中断
}
function downloadPdf() {
const href = pdfSrc.value;
if (!href) return;
const a = document.createElement('a');
a.href = href;
a.download = `${result.value?.job_id || 'paper'}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
}
function downloadFullLog() {
// 成功:信封带 log_url;失败信封不带——job_id 即 anchor_id,log 同目录可拼路径
const jid = result.value?.job_id;
const href = edu.compileResult?.log_url || (jid ? `/api/review/compile/${jid}/log` : '');
if (!href) return;
const a = document.createElement('a');
a.href = href;
a.download = `${jid || 'compile'}.log`;
document.body.appendChild(a);
a.click();
a.remove();
}
onMounted(() => {
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 精选期刊下拉数据源
// 恢复的 target 是目录刊但无选中记录(历史草稿)→ 回落通用规则
@@ -269,21 +317,95 @@ function download() {
</article>
</div>
<!-- 编译 PDF(P3 暂缓):「开发中」占位,不触发编译;后端 P3a API 保留备用 -->
<!-- ========== 编译 PDF(P3b) ========== -->
<div v-else class="rf-compile">
<div class="rf-wip" role="note">
<p class="rf-wip-main">
PDF 编译功能开发中,暂时请下载修改后的 .tex 文件,在 Overleaf 或本地编译。
后续将支持多文件工程和图片上传。
</p>
<p class="rf-wip-sub">已提供逐行修改对照(Diff 报告)与修改后源码下载。</p>
<a
class="rf-wip-feedback"
href="http://192.168.0.175:3000/admin/PlantAI-rag-new/issues"
target="_blank"
rel="noopener noreferrer"
>反馈需求 →</a>
<!-- 无 job_id(未完成重排版)→ 引导 -->
<div v-if="!result?.job_id" class="rf-wip" role="note" data-testid="rf-compile-guide">
<p class="rf-wip-main">请先完成重排版再编译。</p>
<p class="rf-wip-sub">编译基于重排版后的 .tex 源码,重排版成功后此页会出现「编译 PDF」按钮。</p>
</div>
<template v-else>
<!-- 首次编译慢提示(loading 期间显示,可手动关闭) -->
<div v-if="showFirstHint" class="rf-first-hint" role="note" data-testid="rf-first-hint">
<span>
首次编译需要 1-3 分钟(下载 TeX 环境),后续编译秒级。
可以切到其他面板,编译会在后台继续。
</span>
<button class="rf-hint-x" type="button" aria-label="关闭提示"
@click="firstHintDismissed = true">×</button>
</div>
<!-- 操作行:编译按钮 / 下载 / 重新编译 -->
<div class="rf-compile-bar">
<button
v-if="!compileReady && !compileErrored"
class="rf-compile-btn" type="button"
data-testid="rf-compile-btn"
:disabled="compiling"
@click="startCompile"
>
{{ compiling ? '编译中…(首次约 1-3 分钟)' : '编译 PDF' }}
</button>
<template v-if="compileReady">
<button class="rf-compile-btn" type="button" data-testid="rf-download-pdf"
@click="downloadPdf">下载 PDF</button>
<button class="rf-compile-btn rf-compile-ghost" type="button"
data-testid="rf-recompile"
:disabled="compiling" @click="startCompile">
{{ compiling ? '编译中…' : '重新编译' }}
</button>
</template>
<template v-if="compileErrored">
<button class="rf-compile-btn" type="button" data-testid="rf-recompile"
:disabled="compiling" @click="startCompile">
{{ compiling ? '编译中…' : '重新编译' }}
</button>
<button class="rf-compile-btn rf-compile-ghost" type="button"
data-testid="rf-download-log" @click="downloadFullLog">查看完整 log</button>
</template>
<span v-if="compileReady && pdfSizeText" class="rf-compile-size"
data-testid="rf-pdf-size">{{ pdfSizeText }}</span>
</div>
<!-- loading:动画进度(后端无 stage,不确定时长) -->
<div v-if="compiling" class="rf-compile-loading" role="status"
data-testid="rf-compile-loading">
<span class="rf-compile-spin" aria-hidden="true" />
<p class="rf-state-msg">
编译中…(tectonic 单槽队列,常规 10-30 秒;首次 1-3 分钟)
可切换到其他面板,编译在后台继续。
</p>
</div>
<!-- 成功:内嵌 PDF 预览(blob URL) -->
<div v-else-if="compileReady" class="rf-compile-ready">
<iframe
class="rf-compile-frame" :src="pdfSrc" title="PDF 预览"
data-testid="rf-pdf-frame"
/>
<div class="rf-compile-foot">
<span class="rf-compile-secs" data-testid="rf-compile-secs">
编译耗时 {{ compileMeta?.seconds != null ? `${Number(compileMeta.seconds).toFixed(1)} 秒` : '—' }}
</span>
</div>
</div>
<!-- 失败:错误摘要 + log 尾折叠 + 完整 log 下载 -->
<div v-else-if="compileErrored" class="rf-compile-error" role="alert"
data-testid="rf-compile-error">
<p class="rf-compile-msg">{{ edu.compileError }}</p>
<details
v-if="compileLog" class="rf-log" :open="!logCollapsed"
data-testid="rf-log-details"
>
<summary class="rf-log-sum" @click="logCollapsed = !logCollapsed">
编译日志(尾 200 行)
</summary>
<pre class="rf-log-body" data-testid="rf-log-body">{{ compileLog }}</pre>
</details>
</div>
</template>
</div>
</template>
@@ -625,12 +747,6 @@ function download() {
color: var(--pa-warning);
}
/* 编译 PDF(P3 暂缓:开发中占位) */
.rf-compile {
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-wip {
display: flex;
flex-direction: column;
@@ -652,13 +768,126 @@ function download() {
font-size: 12.5px;
color: var(--pa-ink-subtle);
}
.rf-wip-feedback {
font-size: 12.5px;
font-weight: 600;
color: var(--pa-g-from);
text-decoration: none;
/* 编译 PDF(P3b) */
.rf-compile {
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-first-hint {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--pa-warning) 45%, transparent);
border-radius: var(--pa-r-md);
background: color-mix(in srgb, var(--pa-warning) 12%, transparent);
color: var(--pa-ink);
font-size: 12.5px;
line-height: 1.6;
}
.rf-hint-x {
border: none;
background: transparent;
color: var(--pa-ink-muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.rf-hint-x:hover { color: var(--pa-ink); }
.rf-compile-bar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.rf-compile-btn {
padding: 7px 18px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.rf-compile-btn:disabled { opacity: .6; cursor: not-allowed; }
.rf-compile-btn:not(:disabled):hover { filter: brightness(1.08); }
.rf-compile-ghost {
background: transparent;
border: 1px solid var(--pa-hairline-strong);
color: var(--pa-ink);
}
.rf-compile-size {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.rf-compile-loading {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 16px;
background: var(--pa-surface-2);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.rf-compile-spin {
flex: none;
width: 18px;
height: 18px;
border: 2px solid var(--pa-hairline-strong);
border-top-color: var(--pa-g-from);
border-radius: 50%;
animation: rf-spin .8s linear infinite;
}
@keyframes rf-spin { to { transform: rotate(360deg); } }
.rf-compile-ready { display: flex; flex-direction: column; gap: 8px; }
.rf-compile-frame {
width: 100%;
height: 560px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
background: var(--pa-surface-2);
}
.rf-compile-foot { display: flex; gap: 12px; align-items: center; }
.rf-compile-secs { font-size: 12px; color: var(--pa-ink-subtle); }
.rf-compile-error {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
background: color-mix(in srgb, var(--pa-error) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--pa-error) 40%, transparent);
border-radius: var(--pa-r-lg);
}
.rf-compile-msg {
margin: 0;
font-size: 13px;
color: var(--pa-error);
}
.rf-log { border-top: 1px dashed var(--pa-hairline-strong); padding-top: 8px; }
.rf-log-sum {
font-size: 12.5px;
color: var(--pa-ink-muted);
cursor: pointer;
user-select: none;
}
.rf-log-body {
margin: 8px 0 0;
max-height: 300px;
overflow: auto;
padding: 10px 12px;
background: var(--pa-code-bg);
border-radius: var(--pa-r-md);
font-size: 12px;
line-height: 1.55;
color: var(--pa-ink-muted);
white-space: pre-wrap;
word-break: break-word;
}
.rf-wip-feedback:hover { text-decoration: underline; }
/* 状态区 */
.rf-state {
+23 -3
View File
@@ -83,11 +83,13 @@ export const useEduStore = defineStore('edu', {
reformatResult: null,
reformatStatus: 'idle',
reformatError: null,
/* —— P3a 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta}
/* —— P3a/P3b 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta:{seconds,pdf_bytes,...}}
compileStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error'
compileError: null,
compileLogTail: '', // COMPILE_FAILED/TIMEOUT 的 log 尾 200 行(err.detail)
compilePdfUrl: null, // PDF blob URL(预览/下载,不暴露 API 路径;失败回退 pdf_url)
compileFirstHint: false, // 首次编译黄色提示条(编译开始到 clearCompile 前保留)
/* —— 表单草稿(切菜单不丢;四字段并存:recommend 用 abstract+prefs、
presubmit 用 targetJournal、format 用 formatJournalId(期刊 id 下拉))—— */
@@ -426,18 +428,20 @@ export const useEduStore = defineStore('edu', {
this.reformatError = null;
this.clearCompile();
},
/* —— P3a 编译 PDF(tectonic 单槽队列;约 10-30 秒)—— */
/* —— P3b 编译 PDF(tectonic 单槽队列;约 10-30 秒,首次拉 bundle 1-3 分钟)—— */
async submitCompile(jobId) {
if (this.compileStatus === 'loading') return null;
this.compileStatus = 'loading';
this.compileError = null;
this.compileLogTail = '';
if (!this.compileResult) this.compileFirstHint = true; // 首次编译提示条
const fd = new FormData();
fd.append('job_id', jobId);
try {
const payload = await compileTex(fd);
this.compileResult = payload;
this.compileStatus = 'ready';
this._loadCompileBlob(payload.pdf_url); // 异步换 blob URL,失败回退直链
return payload;
} catch (err) {
this.compileStatus = 'error';
@@ -446,11 +450,27 @@ export const useEduStore = defineStore('edu', {
return null;
}
},
/** PDF → blob URL(预览/下载不暴露后端路径);失败静默回退 pdf_url 直链 */
async _loadCompileBlob(url) {
if (!url) return;
try {
const resp = await fetch(url);
if (!resp.ok) return;
const blob = await resp.blob();
if (this.compilePdfUrl) URL.revokeObjectURL(this.compilePdfUrl);
this.compilePdfUrl = URL.createObjectURL(blob);
} catch { /* 保持 pdf_url 直链 */ }
},
clearCompile() {
if (this.compilePdfUrl) {
try { URL.revokeObjectURL(this.compilePdfUrl); } catch { /* noop */ }
}
this.compileResult = null;
this.compileStatus = 'idle';
this.compileError = null;
this.compileLogTail = '';
this.compilePdfUrl = null;
this.compileFirstHint = false;
},
},
});