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- 前缀,零新增依赖。 -->