diff --git a/review_service/restart.sh b/review_service/restart.sh index 80b522d..1e6be33 100755 --- a/review_service/restart.sh +++ b/review_service/restart.sh @@ -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)" diff --git a/review_service/scripts/prewarm_tectonic.sh b/review_service/scripts/prewarm_tectonic.sh new file mode 100644 index 0000000..54d5aae --- /dev/null +++ b/review_service/scripts/prewarm_tectonic.sh @@ -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 diff --git a/tests/frontend/unit/edu/reformat.compile.test.js b/tests/frontend/unit/edu/reformat.compile.test.js new file mode 100644 index 0000000..fbb97c7 --- /dev/null +++ b/tests/frontend/unit/edu/reformat.compile.test.js @@ -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 尾折叠
+ 查看完整 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();
+ });
+});
diff --git a/ui/src/api/review.js b/ui/src/api/review.js
index ca4bddb..0a6c67b 100644
--- a/ui/src/api/review.js
+++ b/ui/src/api/review.js
@@ -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 走降级横幅样式。 */
diff --git a/ui/src/components/edu/panels/ReformatPanel.vue b/ui/src/components/edu/panels/ReformatPanel.vue
index 8627302..50a4f02 100644
--- a/ui/src/components/edu/panels/ReformatPanel.vue
+++ b/ui/src/components/edu/panels/ReformatPanel.vue
@@ -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- 前缀,零新增依赖。 -->