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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 走降级横幅样式。 */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user