feat(edu): presubmit UI + rebuttal assistant + tracker removal

- PresubmitPanel: rationale / conflict chips / file persistence
- RebuttalPanel: Phase 1 UI (3-column, 5-type form, stance pre-check)
- ReformatPanel / ReportPanel: hide internal fields (job id / version)
- EduSideNav: SVG icon, remove tracker menu item
- EduWorkspace: unmount tracker
- edu.js: rebuttal state + presubmitFile + SECTIONS (drop tracker)
This commit is contained in:
potaser
2026-10-12 16:38:28 +08:00
parent cf5ccb3a37
commit c4dc344a31
9 changed files with 1148 additions and 104 deletions
+69 -1
View File
@@ -6,7 +6,7 @@
// 而 http.js 只对非 2xx 抛 ApiError —— 200 信封会正常 resolve。
// unwrap() 把两条错误路径(200 信封 / 非 2xx http.js 抛出)归一成 ApiError,
// 调用方只需 catch + reviewErrorMessage(err) 一个口径。
import { get, post, ApiError } from './http.js';
import { get, post, patch, del, ApiError } from './http.js';
const BASE = '/api/review';
@@ -133,3 +133,71 @@ export function reviewErrorMessage(err) {
}
return '服务异常,请稍后重试';
}
/* —— Rebuttal Phase 1(审稿回复助手,10 端点)—— */
const RB = `${BASE}/rebuttal`;
/** POST /rebuttal/jobs {text} → {ok, job, meta:{reviewers,comments}} 文本粘贴主入口 */
export function rebuttalCreateJob(text, opts = {}) {
return unwrap(post(`${RB}/jobs`, { text }, opts));
}
/** POST /rebuttal/jobs/pdf(multipart)→ 同上;PDF 次要入口(批注版审稿 PDF) */
export function rebuttalCreateFromPdf(formData, opts = {}) {
return unwrap(post(`${RB}/jobs/pdf`, formData, opts));
}
/** GET /rebuttal/jobs/{id} → {ok, job} 全量还原 */
export function rebuttalGetJob(id, opts = {}) {
return unwrap(get(`${RB}/jobs/${id}`, {}, opts));
}
/** PATCH /rebuttal/jobs/{id}/comments/{cid}/response {parts} → {ok, job} */
export function rebuttalSaveResponse(id, cid, parts, opts = {}) {
return unwrap(patch(`${RB}/jobs/${id}/comments/${cid}/response`, { parts }, opts));
}
/** POST /rebuttal/jobs/{id}/comments/{cid}/draft → {ok, job} 或校验失败
* {ok:false, error:{code:'VALIDATION_FAILED'}, job, meta:{validation}}(校验C前置)。
* 不走 unwrap:校验失败信封里 job/meta 也是有效载荷,需原样返回给面板。 */
export function rebuttalDraft(id, cid, opts = {}) {
return post(`${RB}/jobs/${id}/comments/${cid}/draft`, {}, opts).then((payload) => {
if (payload && payload.ok === false && payload.error) {
const err = new ApiError(payload.error.message || '草稿生成失败', {
code: payload.error.code || 'INTERNAL',
status: 200,
degraded: !!payload.error.degraded,
});
err.payload = payload; // 信封全量(含 job + meta.validation)
throw err;
}
return payload;
});
}
/** PATCH /rebuttal/jobs/{id}/comments/{cid}/confirm {confirmed} → {ok, job, meta.human_ack} */
export function rebuttalConfirm(id, cid, confirmed, opts = {}) {
return unwrap(patch(`${RB}/jobs/${id}/comments/${cid}/confirm`, { confirmed }, opts));
}
/** POST /rebuttal/jobs/{id}/letter → {ok, job}(job.letter_md 已拼装) */
export function rebuttalLetter(id, opts = {}) {
return unwrap(post(`${RB}/jobs/${id}/letter`, {}, opts));
}
/** GET /rebuttal/jobs/{id}/letter.md —— 纯文本下载(human_ack 门槛,非信封)。
* 返回 Promise<string>;400 时抛 ApiError(code 取自响应文本前缀)。 */
export async function rebuttalDownloadLetterMd(id) {
const resp = await fetch(`${RB}/jobs/${id}/letter.md`);
if (!resp.ok) {
const text = await resp.text().catch(() => '');
const code = text.startsWith('HUMAN_ACK_REQUIRED') ? 'HUMAN_ACK_REQUIRED' : 'DOWNLOAD_FAILED';
throw new ApiError(text || `下载失败(HTTP ${resp.status})`, { code, status: resp.status });
}
return resp.text();
}
/** DELETE /rebuttal/jobs/{id} → {ok:true, meta:{deleted}} 手动清(无 TTL) */
export function rebuttalDeleteJob(id, opts = {}) {
return unwrap(del(`${RB}/jobs/${id}`, opts));
}
+7 -2
View File
@@ -16,7 +16,8 @@ const ITEMS = [
{ key: 'report', label: '专业审稿意见报告', icon: '📝' },
{ key: 'format', label: '论文排版与规范', icon: '📐' },
{ key: 'reformat', label: '论文自动重排版', icon: '🧩' },
{ key: 'tracker', label: '投稿进度与返修', icon: '📮' },
// SVG 内联(U+1F4AC/U+2709 在用户 Ubuntu 浏览器均无字形 → 方框;SVG 不依赖字体)
{ key: 'rebuttal', label: '审稿回复助手', icon: '<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor"><path d="M2 2h12v9H6l-4 3z"/></svg>' },
{ key: 'calendar', label: '会议与投稿日历', icon: '📅' },
];
</script>
@@ -35,7 +36,9 @@ const ITEMS = [
:title="it.label"
@click="edu.setSection(it.key)"
>
<span class="edu-icon" aria-hidden="true">{{ it.icon }}</span>
<!-- icon 为静态白名单字符串(本文件 ITEMS 常量),SVG 项用 v-html,emoji 项纯文本 -->
<span v-if="it.icon.startsWith('<svg')" class="edu-icon" aria-hidden="true" v-html="it.icon" />
<span v-else class="edu-icon" aria-hidden="true">{{ it.icon }}</span>
<span class="edu-label">{{ it.label }}</span>
</button>
</li>
@@ -106,6 +109,8 @@ const ITEMS = [
flex: none;
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
}
.edu-label {
min-width: 0;
+3 -3
View File
@@ -12,13 +12,13 @@ import PresubmitPanel from './panels/PresubmitPanel.vue';
import ReportPanel from './panels/ReportPanel.vue';
import FormatPanel from './panels/FormatPanel.vue';
import ReformatPanel from './panels/ReformatPanel.vue';
import TrackerPanel from './panels/TrackerPanel.vue';
import CalendarPanel from './panels/CalendarPanel.vue';
import RebuttalPanel from './panels/RebuttalPanel.vue';
const edu = useEduStore();
/** section → 面板组件映射(与 edu store SECTIONS 一致;
* training → calendar 换版,TrainingPanel.vue 保留在 panels/ 下未删除,仅解除映射) */
* training → calendar、tracker 已解除映射(面板文件保留在 panels/ 下未删除) */
const panelMap = {
journals: JournalsPanel,
recommend: RecommendPanel,
@@ -26,8 +26,8 @@ const panelMap = {
report: ReportPanel,
format: FormatPanel,
reformat: ReformatPanel,
tracker: TrackerPanel,
calendar: CalendarPanel,
rebuttal: RebuttalPanel,
};
const currentPanel = computed(() => panelMap[edu.section] || JournalsPanel);
@@ -8,7 +8,8 @@
recommendation 由 recommend_engine 规则引擎计算并附 rationale(可解释);
fatal → reject 红标 + "建议人工复核"(克制展示,须人工核对)。
优先级:edu.report 存在 → 结果区(不退回上传区);否则 loading > error > 上传区。
结果/targetJournal 草稿存 store 跨菜单保留;PDF 文件为面板本地 ref(切菜单需重选)。
结果/targetJournal 草稿/已选 PDF(edu.presubmitFile,仅内存)均存 store 跨菜单保留,
切菜单回来:有报告显示报告,无报告保留已选文件可直接提交。
http.js FormData+onUploadProgress → XHR 分支,上传阶段显示真实百分比。 -->
<script setup>
import { computed, ref } from 'vue';
@@ -32,7 +33,7 @@ const HARD_RULE_LABELS = {
};
const fileInput = ref(null);
const file = ref(null); // File 面板本地(不进 store)
const file = ref(edu.presubmitFile || null); // File 存 store(切菜单/面板卸载不丢);本地 ref 为渲染镜像
const fileError = ref('');
const dragging = ref(false);
const uploadPct = ref(0);
@@ -59,6 +60,16 @@ const tierText = computed(() => {
const hardHits = computed(() =>
(rationale.value?.hard_hits || []).map((t) => HARD_RULE_LABELS[t] || t)
);
const scannerConflicts = computed(() => rationale.value?.scanner_conflicts || []);
const droppedSections = computed(() => {
const d = meta.value?.dropped_sections || [];
const pretty = {
preamble: '文首', abstract: '摘要', introduction: '引言', results: '结果',
discussion: '讨论', methods: '方法', data_availability: '数据可用性',
references: '参考文献', acknowledgements: '致谢',
};
return d.filter((s) => !s.startsWith('[')).map((s) => pretty[s] || s);
});
/** 上传阶段(pct<1)显示真实百分比;上传完成后是解析 + LLM 长等待 */
const phaseText = computed(() =>
@@ -85,6 +96,7 @@ function acceptFile(f) {
return;
}
file.value = f;
edu.presubmitFile = f; // 进 store:切菜单/面板卸载不丢
}
function onPick(e) {
acceptFile(e.target.files?.[0]);
@@ -96,6 +108,7 @@ function onDrop(e) {
}
function removeFile() {
file.value = null;
edu.presubmitFile = null;
}
async function submit() {
@@ -133,7 +146,7 @@ function reset() {
📄 {{ meta.filename }}
<template v-if="meta.target_journal"> · 目标期刊 {{ meta.target_journal }}</template>
· {{ meta.text_chars }} 字符
<span v-if="meta.truncated" class="pp-truncated">· 正文超限已截断至 6 万字符</span>
<span v-if="meta.truncated" class="pp-truncated">· 正文超限已截断(保摘要/数据可用性/方法/结果/讨论)</span>
</div>
<p class="pp-ai-note">本判定由 AI 生成,仅供投稿前参考;fatal 级判定请务必人工核对后再决定。</p>
@@ -149,9 +162,21 @@ function reset() {
<div v-if="rationale" class="pp-rationale">
<div class="pp-rationale-head">判定依据</div>
<p class="pp-rationale-text">{{ rationale.explanation }}</p>
<div v-if="hardHits.length" class="pp-hard-hits">
<div v-if="hardHits.length" class="pp-hard-chips">
<span v-for="(h, i) in hardHits" :key="'hh-' + i" class="pp-hard-chip">{{ h }}</span>
</div>
<!-- 扫描器冲突(黄标:证伪了硬性缺项,请人工核对) -->
<div v-if="scannerConflicts.length" class="pp-conflicts">
<div class="pp-conflict-title">关键词扫描器冲突(已降级为非硬性,请人工核对)</div>
<div v-for="(c, i) in scannerConflicts" :key="'sc-' + i" class="pp-conflict-item">
<span class="pp-conflict-tag">{{ HARD_RULE_LABELS[c.hard_rule] || c.hard_rule }}</span>
<span class="pp-conflict-reason">{{ c.reason }}</span>
</div>
</div>
<!-- 截断提示:被丢弃的低优先级节(判定价值低,不代表论文缺失) -->
<div v-if="droppedSections.length" class="pp-dropped">
未送审部分:{{ droppedSections.join('、') }}(截断所致,非论文缺失)
</div>
</div>
<p class="pp-summary">{{ report.summary }}</p>
@@ -187,6 +212,11 @@ function reset() {
>
<div class="pp-card-title">
<span class="pp-idx light">{{ i + 1 }}</span>{{ it.title }}
<span
v-if="it.scanner_downgraded"
class="pp-down-badge"
title="扫描器在正文检出该缺项证据,已从大修降级为小修;请人工核对"
>冲突降级</span>
</div>
<p class="pp-detail light">{{ it.detail }}</p>
<div v-if="it.evidence" class="pp-evidence light">
@@ -382,7 +412,7 @@ function reset() {
line-height: 1.65;
color: var(--pa-ink);
}
.pp-hard-hits {
.pp-hard-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
@@ -395,6 +425,43 @@ function reset() {
font-size: 11.5px;
color: var(--pa-error);
}
.pp-conflicts {
margin-top: 10px;
padding: 8px 10px;
border: 1px solid var(--pa-warn, #d4a017);
border-radius: var(--pa-r-sm);
background: color-mix(in srgb, var(--pa-warn, #d4a017) 8%, transparent);
}
.pp-conflict-title {
font-size: 11.5px;
font-weight: 600;
color: var(--pa-warn, #9a6b00);
margin-bottom: 4px;
}
.pp-conflict-item {
display: flex;
gap: 6px;
align-items: baseline;
font-size: 12px;
line-height: 1.6;
color: var(--pa-ink);
}
.pp-conflict-tag {
flex: none;
padding: 0 6px;
border: 1px solid var(--pa-warn, #d4a017);
border-radius: var(--pa-r-pill);
font-size: 10.5px;
color: var(--pa-warn, #9a6b00);
}
.pp-conflict-reason {
color: var(--pa-muted, #666);
}
.pp-dropped {
margin-top: 8px;
font-size: 11.5px;
color: var(--pa-muted, #777);
}
.pp-hard-badge {
flex: none;
margin-left: 6px;
@@ -406,6 +473,17 @@ function reset() {
color: var(--pa-error);
line-height: 1.6;
}
.pp-down-badge {
flex: none;
margin-left: 6px;
padding: 0 6px;
border-radius: var(--pa-r-sm);
border: 1px solid var(--pa-warn, #d4a017);
font-size: 10.5px;
font-weight: 600;
color: var(--pa-warn, #9a6b00);
line-height: 1.6;
}
.pp-fatal-badge {
flex: none;
margin-left: 4px;
@@ -0,0 +1,635 @@
<!-- RebuttalPanel.vue —— 审稿回复助手(Rebuttal Phase 1,UI v2)。
独立工作,不依赖 tracker(tracker 已解除映射,2026-10-12)。
步骤引导:① 粘贴审稿意见 → ② 智能拆条 → ③ 逐条回复 → ④ 导出 Response Letter。
布局(大屏三栏,<1100px 两栏降级,letter 折叠):
左 220px:Reviewer 折叠分组 + comment 列表(编号/severity 徽标/状态点)
中 flex-1:原话(灰底只读)+ 问题要点(LLM 归纳)+ 5 类表单
+ draft 预览 + 校验红条 + 逐条确认
右 40% 可折叠:Letter 预览(仅已填 draft 条目)+ X/Y 已确认 + 下载
状态机:无 job→粘贴区;loading→拆条动画;error→重试;NOT_FOUND→任务过期。
样例文本与 docs/samples/rebuttal_sample_reviewer.txt 同源(前端内置副本)。 -->
<script setup>
import { computed, ref, watch } from 'vue';
import { useEduStore } from '../../../stores/edu.js';
const edu = useEduStore();
const rb = computed(() => edu.rebuttal);
const job = computed(() => rb.value.job);
const SAMPLE_TEXT = `Reviewer #1:
1. The GWAS analysis lacks multiple-testing correction. Please re-run with FDR control and report q-values for all significant loci. In addition, the sample size justification is missing — please add a power analysis or at least discuss the detectable effect size.
2. Figure 3 is blurry and the axis labels are unreadable. As noted in Comment 3, the same issue applies to Figure S2. Please replace all figures with high-resolution versions.
3. Minor: please add the standard error estimates to Supplementary Table 2.
Reviewer #2:
1. The claim of novelty overstates the advance relative to Zhang et al. 2023. The introduction should more clearly delineate what is new.
2. How were the batch effects between the two sequencing centers corrected? This is essential to support the conclusions.`;
const textInput = ref('');
const showPdf = ref(false);
const partErr = ref('');
const PART_TYPES = [
{ key: 'new_data', label: '补充新数据', hint: '新增实验/数据(图/表编号、关键数值)' },
{ key: 'reanalysis', label: '重新分析', hint: '重跑的分析、方法/参数变化与新结论' },
{ key: 'location', label: '定位/指出位置', hint: '修改或证据在稿件中的位置(Section/Fig/Methods)' },
{ key: 'clarification', label: '澄清说明', hint: '针对误解的准确解释' },
{ key: 'rebuttal', label: '礼貌反驳', hint: '礼貌但明确的不同立场与理由' },
];
const collapsed = ref({}); // reviewerId → bool
const allComments = computed(() => {
const j = job.value;
if (!j) return [];
return (j.reviewers || []).flatMap((r) =>
(r.comments || []).map((c, i) => ({ ...c, reviewer: r.label, ord: i + 1 })));
});
const activeComment = computed(() => allComments.value.find((c) => c.id === rb.value.activeCid) || null);
const unconfirmedCount = computed(() => allComments.value.filter((c) => !c.human_confirmed).length);
const confirmedCount = computed(() => allComments.value.length - unconfirmedCount.value);
const allConfirmed = computed(() => allComments.value.length > 0 && unconfirmedCount.value === 0);
const letterComments = computed(() => allComments.value.filter((c) => String(c.draft || '').trim()));
const step = computed(() => {
if (rb.value.status === 'loading') return 2;
if (!job.value) return 1;
if (job.value.letter_md && allConfirmed.value) return 4;
return 3;
});
const isExpired = computed(() => rb.value.expired === true);
const forms = ref({});
function formOf(cid) {
if (!forms.value[cid]) {
const c = allComments.value.find((x) => x.id === cid);
const parts = (c?.response?.parts || []).map((p) => ({ ...p }));
forms.value[cid] = { parts: parts.length ? parts : [{ type: 'clarification', content: '', location_hint: '' }] };
}
return forms.value[cid];
}
const canDraft = computed(() => {
const f = activeComment.value && forms.value[activeComment.value.id];
if (!f) return false;
return f.parts.length > 0 && f.parts.every((p) => String(p.content || '').trim());
});
const validationOf = computed(() => activeComment.value?.validation || null);
const validationFailed = computed(() => validationOf.value && validationOf.value.passed === false);
// 前端立场词预检(与后端 _STANCE 同词表):只提示不阻止保存/编辑
const STANCE_RE = /\b(disagree|maintain|stand\s+by|reject|respectfully|not\s+persuaded|cannot\s+agree|dispute)\b/i;
const STANCE_HINT = '礼貌反驳需要包含 maintain/disagree/respectfully 等立场词,'
+ '例如:We respectfully maintain that... 或 We disagree with the reviewer\'s point that...';
function stanceMissing(p) {
const text = String(p.content || '').trim();
return p.type === 'rebuttal' && !!text && !STANCE_RE.test(text);
}
const stanceHintParts = computed(() => {
const f = activeComment.value && forms.value[activeComment.value.id];
if (!f) return [];
return f.parts.map((p, i) => (stanceMissing(p) ? i : -1)).filter((i) => i >= 0);
});
function statusDot(c) {
if (c.human_confirmed) return 'done';
const f = forms.value[c.id] || c.response;
const parts = f?.parts || [];
const filled = c.draft || parts.some((p) => String(p.content || '').trim());
return filled ? 'filled' : 'empty';
}
function fillSample() {
textInput.value = SAMPLE_TEXT;
}
async function submitText() {
partErr.value = '';
const t = textInput.value.trim();
if (!t) { partErr.value = '请粘贴审稿意见文本'; return; }
const jobNew = await edu.rebuttalCreate(t);
if (jobNew?.reviewers?.[0]?.comments?.[0]) {
edu.rebuttalSetActive(jobNew.reviewers[0].comments[0].id);
}
}
async function onPdf(ev) {
const f = ev.target.files && ev.target.files[0];
if (!f) return;
partErr.value = '';
if (!f.name.toLowerCase().endsWith('.pdf')) {
partErr.value = '仅支持 PDF(推荐直接粘贴文本)';
ev.target.value = '';
return;
}
const fd = new FormData();
fd.append('file', f);
const jobNew = await edu.rebuttalCreatePdf(fd);
ev.target.value = '';
if (jobNew?.reviewers?.[0]?.comments?.[0]) {
edu.rebuttalSetActive(jobNew.reviewers[0].comments[0].id);
}
}
function partsPayload(cid) {
return formOf(cid).parts.map((p) => ({
type: p.type, content: String(p.content || ''), location_hint: p.location_hint || null,
}));
}
function addPart() {
formOf(activeComment.value.id).parts.push({ type: 'clarification', content: '', location_hint: '' });
}
function removePart(i) {
const f = formOf(activeComment.value.id);
if (f.parts.length > 1) f.parts.splice(i, 1);
}
const draftMsg = ref('');
async function genDraft() {
const c = activeComment.value;
if (!c || !canDraft.value) return;
draftMsg.value = '';
// 无独立"保存"按钮:生成前自动保存当前要点(保存+生成一步完成)
const res = await edu.rebuttalSaveResponse(c.id, partsPayload(c.id));
if (!res.ok) { draftMsg.value = res.error; return; }
const d = await edu.rebuttalDraft(c.id);
if (!d.ok) {
// 校验失败:按钮已由 store finally 解锁;这里补"如何改"的具体建议
const noStance = d.validation?.findings?.some((f) => f.code === 'REBUTTAL_NO_STANCE');
draftMsg.value = noStance
? `${d.error || '草稿生成失败'}。${STANCE_HINT}`
: (d.error || '草稿生成失败');
}
}
async function toggleConfirm(c, ev) {
const res = await edu.rebuttalConfirm(c.id, ev.target.checked);
if (!res.ok) draftMsg.value = res.error;
}
const letterAssembling = ref(false);
const letterError = ref('');
const letterCollapsed = ref(false);
async function assembleLetter() {
letterAssembling.value = true; letterError.value = '';
const res = await edu.rebuttalAssembleLetter();
letterAssembling.value = false;
if (!res.ok) letterError.value = res.error;
}
async function downloadLetter() {
if (!job.value?.human_ack) return;
letterError.value = '';
try {
const { rebuttalDownloadLetterMd } = await import('../../../api/review.js');
const md = await rebuttalDownloadLetterMd(job.value.id);
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `response-letter-${job.value.id}.md`;
a.click();
URL.revokeObjectURL(a.href);
} catch (err) {
letterError.value = err?.message || '下载失败';
}
}
function retryParse() {
edu.clearRebuttal();
if (!textInput.value.trim() && job.value === null) return; // 保留已输入文本
}
watch(() => rb.value.activeCid, () => {
partErr.value = ''; draftMsg.value = '';
});
</script>
<template>
<div class="rb-root">
<header class="rb-head">
<h2 class="rb-title">审稿回复助手</h2>
<p class="rb-sub">Phase 1:粘贴审稿意见 → 智能拆条 → 逐条回复 → 导出 Response Letter(.md)</p>
</header>
<!-- 步骤引导 -->
<ol class="rb-steps" data-testid="rb-steps">
<li v-for="(s, i) in ['粘贴审稿意见', '智能拆条', '逐条回复', '导出 Response Letter']"
:key="i" class="rb-step" :class="{ on: step === i + 1, past: step > i + 1 }">
<span class="rb-step-num"><span class="rb-step-digit">{{ i + 1 }}</span></span>{{ s }}
</li>
</ol>
<!-- 状态:拆条中 -->
<section v-if="rb.status === 'loading'" class="rb-parsing" data-testid="rb-parsing">
<div class="rb-spinner" aria-hidden="true" />
<p>正在拆条…(约 5-15 秒)</p>
<p class="rb-hint">正在识别 Reviewer 分节与编号条目,并归纳问题要点</p>
</section>
<!-- 状态:任务过期 -->
<section v-else-if="isExpired" class="rb-expired">
<p>任务已过期,请重新创建。</p>
<button class="rb-btn rb-btn-primary" type="button" @click="edu.clearRebuttal()">重新开始</button>
</section>
<!-- 状态:解析失败 -->
<section v-else-if="rb.status === 'error'" class="rb-expired">
<p class="rb-err">{{ rb.error || '拆条失败' }}</p>
<button class="rb-btn rb-btn-primary" type="button" @click="retryParse">重试</button>
</section>
<!-- 状态:无 job → 粘贴区 -->
<section v-else-if="!job" class="rb-input">
<div class="rb-input-head">
<label class="rb-label" for="rb-text">审稿意见文本</label>
<button class="rb-link" type="button" data-testid="rb-sample" @click="fillSample">填充示例</button>
</div>
<textarea
id="rb-text" v-model="textInput" class="rb-textarea"
placeholder="直接粘贴审稿意见(支持 Reviewer 1/2 分节或编号条目)..."
/>
<div class="rb-input-actions">
<button class="rb-btn rb-btn-primary" type="button" data-testid="rb-parse"
:disabled="!textInput.trim()" @click="submitText">
解析并拆条
</button>
</div>
<div class="rb-pdf-card">
<button class="rb-btn" type="button" data-testid="rb-pdf-toggle" @click="showPdf = !showPdf">
或上传 PDF
</button>
<p class="rb-hint">如果是审稿人批注版 PDF,通常提取不到批注,建议用文本粘贴。</p>
<div v-if="showPdf" class="rb-pdf">
<input type="file" accept="application/pdf,.pdf" @change="onPdf" />
</div>
</div>
<p v-if="partErr" class="rb-err">{{ partErr }}</p>
</section>
<!-- 主区:三栏 -->
<section v-else class="rb-main">
<div class="rb-toolbar">
<span />
<button class="rb-link rb-danger" type="button" @click="edu.rebuttalDelete()">删除任务</button>
</div>
<div class="rb-banner" :class="allConfirmed ? 'rb-banner-ok' : 'rb-banner-warn'">
<template v-if="allConfirmed">
全部已确认{{ job.human_ack ? '(human_ack 已置位)' : '' }} —— 可下载 letter.md
</template>
<template v-else>{{ unconfirmedCount }} 条未确认 —— 全部勾选确认后才能下载 letter.md</template>
</div>
<div class="rb-cols" :class="{ 'rb-cols-2': letterCollapsed }">
<!-- 左:Reviewer → Comment 树 -->
<aside class="rb-tree">
<div v-for="r in job.reviewers" :key="r.id" class="rb-rev">
<button class="rb-rev-label" type="button" @click="collapsed[r.id] = !collapsed[r.id]">
<span class="rb-rev-caret" :class="{ open: !collapsed[r.id] }">▸</span>
{{ r.label }}({{ (r.comments || []).length }})
</button>
<template v-if="!collapsed[r.id]">
<button
v-for="(c, i) in r.comments" :key="c.id"
class="rb-c-item" :class="{ active: c.id === rb.activeCid }"
type="button" @click="edu.rebuttalSetActive(c.id)"
>
<span class="rb-c-ord">{{ i + 1 }}</span>
<span class="rb-c-title">{{ c.title || c.id }}</span>
<span class="rb-c-sev" :class="c.severity">{{ c.severity }}</span>
<span class="rb-c-dot" :class="statusDot(c)" :title="statusDot(c)" />
</button>
</template>
</div>
</aside>
<!-- 中:编辑区 -->
<div v-if="activeComment" class="rb-form">
<div class="rb-form-head">
<h3 class="rb-form-title">{{ activeComment.title }}</h3>
<label class="rb-confirm">
<input
type="checkbox" :checked="activeComment.human_confirmed"
data-testid="rb-confirm" @change="toggleConfirm(activeComment, $event)"
/>
已确认本条
</label>
</div>
<div class="rb-original">
<div class="rb-original-label">审稿人原话({{ activeComment.reviewer }} · #{{ activeComment.ord }})</div>
<p class="rb-original-body">{{ activeComment.body }}</p>
</div>
<!-- 问题要点(Phase 1 砍手工合并/拆分:多子条由 LLM 拆分,纠错留 Phase 2) -->
<div class="rb-point">
<div class="rb-point-body">
<div class="rb-original-label">问题要点</div>
<p class="rb-point-text">{{ activeComment.title }}</p>
</div>
</div>
<div v-if="activeComment.cross_refs?.length" class="rb-xref">
交叉引用:<span v-for="(x, i) in activeComment.cross_refs" :key="i">「{{ x.raw }}」{{ x.target_id ? `→ ${x.target_id}` : '(未解析,保留原文)' }} </span>
</div>
<div class="rb-parts">
<div class="rb-parts-label">回复要点(user_response)</div>
<div v-for="(p, i) in formOf(activeComment.id).parts" :key="i" class="rb-part">
<select v-model="p.type" class="rb-select" :data-testid="`rb-type-${i}`">
<option v-for="t in PART_TYPES" :key="t.key" :value="t.key">{{ t.label }}</option>
</select>
<textarea
v-model="p.content" class="rb-part-content" rows="3"
:placeholder="PART_TYPES.find((t) => t.key === p.type)?.hint"
:data-testid="`rb-content-${i}`"
/>
<!-- 立场词预检:只提示不阻止继续编辑/生成 -->
<p
v-if="stanceMissing(p)" class="rb-err rb-stance"
:data-testid="`rb-stance-hint-${i}`"
>{{ STANCE_HINT }}</p>
<input
v-if="p.type === 'location'"
v-model="p.location_hint" class="rb-part-hint" data-testid="rb-location-hint"
placeholder="位置提示:如 Section 3 / Fig. S2 / Methods 第 2 段"
/>
</div>
<div class="rb-part-actions">
<button class="rb-link" type="button" @click="addPart">+ 添加要点</button>
<button
v-if="formOf(activeComment.id).parts.length > 1"
class="rb-link" type="button"
@click="removePart(formOf(activeComment.id).parts.length - 1)"
>移除最后一条</button>
</div>
</div>
<div class="rb-actions">
<button
class="rb-btn rb-btn-primary" type="button" data-testid="rb-draft"
:disabled="!canDraft || rb.draftCid === activeComment.id"
@click="genDraft"
>
{{ rb.draftCid === activeComment.id ? '生成中…' : '生成 draft' }}
</button>
<span class="rb-hint">填写要点后点击生成;内容会自动保存</span>
</div>
<p v-if="draftMsg" class="rb-err">{{ draftMsg }}</p>
<div v-if="validationFailed" class="rb-banner rb-banner-err" data-testid="rb-validation">
反幻觉校验未通过({{ validationOf.findings?.length || 0 }} 项):
<ul>
<li v-for="(f, i) in validationOf.findings" :key="i">[{{ f.code }}] {{ f.message }}</li>
</ul>
</div>
<div v-if="activeComment.draft" class="rb-draft" data-testid="rb-draft-preview">
<div class="rb-original-label">draft 预览</div>
<pre class="rb-draft-md">{{ activeComment.draft }}</pre>
</div>
</div>
<div v-else class="rb-empty">← 选择一条审稿意见开始回复</div>
<!-- 右:Letter 预览(可折叠) -->
<aside v-if="!letterCollapsed" class="rb-letter">
<div class="rb-letter-head">
<span class="rb-letter-count" data-testid="rb-letter-count">
{{ confirmedCount }}/{{ allComments.length }} 条已确认
</span>
<button class="rb-iconbtn" type="button" title="折叠" @click="letterCollapsed = true">»</button>
</div>
<div v-if="!letterComments.length" class="rb-empty rb-letter-empty">
尚无 draft —— 填写回复要点并生成后将在此预览
</div>
<div v-for="c in letterComments" :key="c.id" class="rb-letter-item">
<div class="rb-letter-title">{{ c.reviewer }} · {{ c.title }}</div>
<pre class="rb-letter-md">{{ c.draft }}</pre>
</div>
<div class="rb-letter-actions">
<button class="rb-btn" type="button" :disabled="letterAssembling" @click="assembleLetter">
{{ letterAssembling ? '拼装中…' : '拼装 letter' }}
</button>
<button
class="rb-btn rb-btn-primary" type="button" data-testid="rb-download"
:disabled="!job.human_ack || !job.letter_md" @click="downloadLetter"
>
下载 .md
</button>
</div>
<span v-if="!allConfirmed" class="rb-letter-gate" data-testid="rb-gate">
{{ unconfirmedCount }} 条未确认,下载暂不可用
</span>
<p v-if="letterError" class="rb-err">{{ letterError }}</p>
</aside>
<button v-else class="rb-letter-expand" type="button" title="展开 Letter 预览" @click="letterCollapsed = false">
« Letter
</button>
</div>
</section>
</div>
</template>
<style scoped>
/* 2026-10-12 深浅双主题适配:全部走 tokens.css 真实变量
(--pa-hairline/--pa-surface-N/--pa-raised/--pa-ink-N/--pa-g-*);
步骤徽章纯 CSS 圆形(无 emoji 数字圈,深色下对比度可控)。 */
.rb-root { display: flex; flex-direction: column; gap: 14px; padding: 18px 22px; max-width: 1280px; color: var(--pa-ink); }
.rb-title { font-size: 20px; margin: 0; color: var(--pa-ink); }
.rb-sub { color: var(--pa-ink-muted); font-size: 13px; margin: 4px 0 0; }
/* 步骤条:纯 CSS 圆形徽章 */
.rb-steps { display: flex; gap: 8px; list-style: none; margin: 0; padding: 0; flex-wrap: wrap; }
.rb-step {
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--pa-ink-subtle);
padding: 6px 14px 6px 8px; border-radius: var(--pa-r-pill);
background: var(--pa-surface-2); border: 1px solid var(--pa-hairline);
}
.rb-step-num {
width: 20px; height: 20px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; flex-shrink: 0;
border: 1px solid var(--pa-ink-subtle); color: var(--pa-ink-subtle);
background: transparent;
}
/* 当前步骤:品牌渐变填充 + 白字 */
.rb-step.on {
background: var(--pa-surface-1); color: var(--pa-ink);
border-color: transparent; font-weight: 600;
}
.rb-step.on .rb-step-num {
background: var(--pa-gradient); border-color: transparent; color: #fff;
}
/* 已完成步骤:绿勾 + 提亮文字 */
.rb-step.past { color: var(--pa-success); border-color: var(--pa-hairline); }
.rb-step.past .rb-step-num {
border-color: var(--pa-success); color: var(--pa-success); background: transparent;
}
.rb-step.past .rb-step-num::before { content: '✓'; font-size: 11px; }
.rb-step.past .rb-step-num > .rb-step-digit { display: none; }
/* 输入区:surface 底,不用纯白 */
.rb-label, .rb-parts-label, .rb-original-label { font-size: 13px; font-weight: 600; color: var(--pa-ink); }
.rb-input-head { display: flex; justify-content: space-between; align-items: baseline; }
.rb-textarea {
width: 100%; box-sizing: border-box; font: inherit; padding: 10px;
background: var(--pa-surface-2); color: var(--pa-ink);
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md);
min-height: 240px; max-height: 45vh; overflow-y: auto; resize: vertical;
}
.rb-textarea:focus { outline: none; border-color: var(--pa-g-from); }
.rb-textarea::placeholder { color: var(--pa-ink-subtle); }
.rb-input-actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; }
/* PDF 次要卡片:虚线 = hairline 50% 透明;说明文字 = muted */
.rb-pdf-card {
margin-top: 12px; padding: 12px 14px;
border: 1px dashed color-mix(in srgb, var(--pa-hairline) 50%, transparent);
border-radius: var(--pa-r-lg);
display: flex; flex-direction: column; gap: 6px; align-items: flex-start;
background: var(--pa-surface-1);
}
.rb-pdf { margin-top: 4px; }
.rb-pdf input { font: inherit; color: var(--pa-ink); accent-color: var(--pa-g-from); }
.rb-btn {
padding: 6px 14px; border: 1px solid var(--pa-hairline-strong); border-radius: var(--pa-r-md);
background: var(--pa-raised); color: var(--pa-ink); cursor: pointer; font: inherit;
}
.rb-btn:hover:not(:disabled) { border-color: var(--pa-g-from); }
.rb-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.rb-btn-primary { background: var(--pa-gradient); color: #fff; border-color: transparent; }
.rb-link { background: none; border: none; color: var(--pa-info); cursor: pointer; font: inherit; text-decoration: underline; }
.rb-danger { color: var(--pa-error); }
.rb-err { color: var(--pa-error); font-size: 13px; }
.rb-stance { margin: 4px 0 0; font-size: 12px; }
.rb-hint { color: var(--pa-ink-muted); font-size: 12px; margin: 0; }
/* 解析中 / 过期 / 失败 */
.rb-parsing, .rb-expired {
display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 20px;
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-lg); background: var(--pa-surface-1);
color: var(--pa-ink);
}
.rb-spinner {
width: 28px; height: 28px; border-radius: 50%;
border: 3px solid var(--pa-hairline-strong); border-top-color: var(--pa-g-to);
animation: rb-spin 0.8s linear infinite;
}
@keyframes rb-spin { to { transform: rotate(360deg); } }
/* 主区三栏:左/右 surface-2,中 surface-1 区分层级 */
.rb-toolbar { display: flex; justify-content: space-between; align-items: center; }
.rb-banner { padding: 8px 12px; border-radius: var(--pa-r-md); font-size: 13px; }
.rb-banner-warn { background: color-mix(in srgb, var(--pa-warning) 14%, transparent); color: var(--pa-warning); }
.rb-banner-ok { background: color-mix(in srgb, var(--pa-success) 14%, transparent); color: var(--pa-success); }
.rb-banner-err { background: color-mix(in srgb, var(--pa-error) 14%, transparent); color: var(--pa-error); margin-top: 8px; }
.rb-banner-err ul { margin: 4px 0 0 16px; padding: 0; }
.rb-cols { display: grid; grid-template-columns: 220px minmax(0, 1fr) 40%; gap: 14px; align-items: start; }
.rb-cols-2 { grid-template-columns: 220px minmax(0, 1fr) auto; }
.rb-tree {
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-lg);
padding: 6px; max-height: 560px; overflow: auto;
background: var(--pa-surface-2);
}
.rb-rev-label {
display: flex; align-items: center; gap: 4px; width: 100%; text-align: left;
border: none; background: none; font-weight: 600; font-size: 13px; cursor: pointer;
padding: 6px 4px; color: var(--pa-ink);
}
.rb-rev-caret { display: inline-block; transition: transform 0.15s; font-size: 11px; color: var(--pa-ink-muted); }
.rb-rev-caret.open { transform: rotate(90deg); }
.rb-c-item {
display: flex; gap: 6px; align-items: center; width: 100%; text-align: left;
padding: 5px 6px; border: none; background: none; cursor: pointer;
border-radius: var(--pa-r-sm); font: inherit; color: var(--pa-ink);
}
.rb-c-item.active { background: var(--pa-surface-3); box-shadow: inset 2px 0 0 var(--pa-g-from); }
.rb-c-ord {
width: 16px; height: 16px; border-radius: var(--pa-r-xs); background: var(--pa-surface-3); color: var(--pa-ink-muted);
font-size: 10px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.rb-c-title { flex: 1; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pa-ink); }
.rb-c-sev { font-size: 10px; padding: 1px 5px; border-radius: var(--pa-r-pill); flex-shrink: 0; }
.rb-c-sev.major { background: color-mix(in srgb, var(--pa-error) 18%, transparent); color: var(--pa-error); }
.rb-c-sev.minor { background: color-mix(in srgb, var(--pa-warning) 18%, transparent); color: var(--pa-warning); }
.rb-c-sev.question { background: color-mix(in srgb, var(--pa-info) 18%, transparent); color: var(--pa-info); }
.rb-c-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.rb-c-dot.empty { background: var(--pa-ink-subtle); }
.rb-c-dot.filled { background: var(--pa-info); }
.rb-c-dot.done { background: var(--pa-success); }
.rb-form {
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-lg); padding: 14px;
display: flex; flex-direction: column; gap: 10px; background: var(--pa-surface-1);
}
.rb-form-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.rb-form-title { margin: 0; font-size: 15px; color: var(--pa-ink); }
.rb-confirm { font-size: 13px; display: flex; gap: 6px; align-items: center; white-space: nowrap; color: var(--pa-ink); }
.rb-original { background: var(--pa-surface-2); border-radius: var(--pa-r-md); padding: 8px 10px; }
.rb-original-body { margin: 4px 0 0; font-size: 13px; white-space: pre-wrap; color: var(--pa-ink); }
.rb-point {
display: flex; gap: 8px; align-items: flex-start;
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md); padding: 8px 10px;
background: var(--pa-surface-2);
}
.rb-point-body { flex: 1; }
.rb-point-text { margin: 2px 0 0; font-size: 13px; font-weight: 600; color: var(--pa-ink); }
.rb-iconbtn {
border: 1px solid var(--pa-hairline); background: var(--pa-raised); border-radius: var(--pa-r-sm);
cursor: pointer; font-size: 13px; padding: 2px 8px; color: var(--pa-ink-muted);
}
.rb-iconbtn:hover { color: var(--pa-ink); border-color: var(--pa-g-from); }
.rb-xref { font-size: 12px; color: var(--pa-ink-muted); }
.rb-part { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.rb-select, .rb-part-hint {
font: inherit; padding: 6px 8px; border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md);
max-width: 320px; background: var(--pa-surface-2); color: var(--pa-ink);
}
.rb-part-content {
font: inherit; padding: 8px; border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md);
background: var(--pa-surface-2); color: var(--pa-ink);
}
.rb-part-content::placeholder, .rb-part-hint::placeholder { color: var(--pa-ink-subtle); }
.rb-part-actions { display: flex; gap: 12px; margin-top: 6px; }
.rb-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.rb-draft { border-top: 1px solid var(--pa-hairline); padding-top: 8px; }
.rb-draft-md {
white-space: pre-wrap; font-size: 13px; background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md); padding: 10px; margin: 6px 0 0;
color: var(--pa-ink);
}
.rb-empty { color: var(--pa-ink-subtle); padding: 30px; text-align: center; }
/* 右栏 letter:surface-2 与中栏区分 */
.rb-letter {
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-lg); padding: 10px;
max-height: 640px; overflow: auto; display: flex; flex-direction: column; gap: 8px;
background: var(--pa-surface-2);
}
.rb-letter-head { display: flex; justify-content: space-between; align-items: center; }
.rb-letter-count { font-size: 12px; font-weight: 600; color: var(--pa-success); }
.rb-letter-empty { padding: 16px; font-size: 12px; }
.rb-letter-item { border-top: 1px solid var(--pa-hairline); padding-top: 6px; }
.rb-letter-title { font-size: 12px; font-weight: 600; margin-bottom: 4px; color: var(--pa-ink); }
.rb-letter-md {
white-space: pre-wrap; font-size: 12px; background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md); padding: 8px; margin: 0;
color: var(--pa-ink);
}
.rb-letter-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.rb-letter-gate { font-size: 12px; color: var(--pa-warning); }
.rb-letter-expand {
writing-mode: vertical-rl; border: 1px solid var(--pa-hairline); background: var(--pa-surface-2);
border-radius: var(--pa-r-lg); cursor: pointer; font: inherit; font-size: 12px; padding: 12px 4px;
color: var(--pa-ink-muted);
}
.rb-letter-expand:hover { color: var(--pa-ink); border-color: var(--pa-g-from); }
@media (max-width: 1100px) {
.rb-cols, .rb-cols-2 { grid-template-columns: 200px minmax(0, 1fr); }
.rb-letter { grid-column: 1 / -1; max-height: none; }
.rb-letter-expand { display: none; }
}
</style>
+29 -4
View File
@@ -117,6 +117,17 @@ const structWarnings = computed(() => result.value?.warnings || []);
const modifiedCount = computed(() => changes.value.filter((c) => c.action === 'modified').length);
const suggestCount = computed(() => changes.value.filter((c) => c.action === 'warning').length);
const sourceLabel = computed(() => SOURCE_LABEL[meta.value?.rules_source] || meta.value?.rules_source || '—');
// ISO 时间戳 → "10-12 15:25"(内部字段不裸露;helper 规则见 UI 注释)
const generatedShort = computed(() => {
const s = meta.value?.generated_at;
if (!s) return '';
const str = String(s);
return str.length >= 16 ? str.slice(5, 16).replace('T', ' ') : '';
});
function copyJobId() {
const id = result.value?.job_id;
if (id && navigator.clipboard) navigator.clipboard.writeText(String(id)).catch(() => {});
}
onMounted(() => {
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 精选期刊下拉数据源
@@ -195,9 +206,15 @@ function download() {
<!-- ========== ① 有结果 → 结果区 ========== -->
<template v-if="hasResult">
<div class="rf-meta">
📄 {{ meta?.filename || '—' }} · 目标:{{ meta?.journal_name || '—' }}({{ sourceLabel }})·
{{ meta?.engine }} · {{ meta?.generated_at || '' }}
<span class="rf-meta-sub"> · job {{ result.job_id }}</span>
📄 {{ meta?.filename || '—' }} · 目标:{{ meta?.journal_name || '—' }}({{ sourceLabel }})
<template v-if="generatedShort"> · {{ generatedShort }}</template>
<!-- 内部 job id 默认不展示;报错/反馈时可点复制 -->
<button
v-if="result.job_id" class="rf-copy-id" type="button"
title="复制任务 ID(报错反馈时使用)" @click="copyJobId"
>
复制任务 ID
</button>
</div>
<p v-if="meta?.rules_note" class="rf-note" role="note">{{ meta.rules_note }}</p>
@@ -399,9 +416,17 @@ function download() {
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.rf-meta-sub {
.rf-copy-id {
margin-left: 8px;
padding: 1px 8px;
font-size: 11px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
background: transparent;
color: var(--pa-ink-subtle);
cursor: pointer;
}
.rf-copy-id:hover { color: var(--pa-ink); border-color: var(--pa-hairline-strong); }
.rf-note {
margin: 0;
padding: 8px 12px;
+14 -1
View File
@@ -28,7 +28,8 @@ const meta = computed(() => edu.report?.meta || null);
const rec = computed(() => REC[report.value?.recommendation] || REC.major_revision);
const createdAtText = computed(() => {
const s = edu.reportCreatedAt;
return s ? String(s).slice(0, 16).replace('T', ' ') : '—';
// ISO → "10-12 15:25" 短格式(不裸露完整时间戳)
return s ? String(s).slice(5, 16).replace('T', ' ') : '—';
});
/* 五节结构:1 总体评价(summary 单列)+ 2..5 列表节(questions 映射为 {title} 条目) */
@@ -405,6 +406,18 @@ onUnmounted(() => clearTimeout(toastTimer));
.rp-toolbar {
display: none !important;
}
/* 2026-10-12 修"导出只导一页":祖先链 height:100vh + overflow:auto 会把打印
内容裁在视口第一页——打印态整链展开为正常文档流,让浏览器自然分页。 */
:global(.edu-workspace) {
height: auto !important;
display: block !important;
}
:global(.edu-panel) {
height: auto !important;
overflow: visible !important;
display: block !important;
padding: 0 !important;
}
.rp-sheet {
max-width: none;
padding: 0;
+169 -83
View File
@@ -1,11 +1,14 @@
<!-- TrackerPanel.vue —— ⑥ 投稿进度与返修(纯前端 localStorage 台账,无后端)。
严格复用 edu store 现有 API:submissionsSorted(updatedAt 倒序)/ addSubmission /
updateSubmission / removeSubmission / SUBMISSION_STATUS;不新增字段、不改 store。
交互:新增(内联抽屉)/ 编辑(同一表单预填)/ 删除(行内二次确认)/ 空态引导。
日期约定:表单为 'YYYY-MM-DD';存量 submittedAt 可能是 ISO → 读取统一 slice(0,10) 兼容;
addSubmission 固定 submittedAt=now,故仅当用户改了日期时再 updateSubmission 补丁。 -->
<!-- TrackerPanel.vue —— ⑦ 投稿进度台账(纯前端 localStorage,无后端)。
v3 方案 A 极简台账(2026-10-12):
- 定位:只做"我投了哪些稿、现在什么状态",不与 Rebuttal 假联动
- 顶部统计条:总投稿 | 进行中 | 待返修 | 已接收 | 已拒绝
- 卡片:期刊/状态/日期/备注 + 返修轮次与截止倒计时(台账信息)
- "去写返修"仅在 status ∈ {under_review, revision} 显示,跳转 RebuttalPanel
自行操作;不回写 jobId、不显示"已开启/未开始"联动徽章
- 表单保留返修截止日/返修轮次字段(台账数据)
方案 B 真联动(回写 jobId + 返修时间线)留待 Phase 2。 -->
<script setup>
import { reactive, ref } from 'vue';
import { computed, reactive, ref } from 'vue';
import { useEduStore, SUBMISSION_STATUS } from '../../../stores/edu.js';
const edu = useEduStore();
@@ -24,12 +27,37 @@ const mode = ref('create'); // 'create' | 'edit'
const editingId = ref(null);
const formError = ref('');
const pendingDeleteId = ref(null); // 删除行内二次确认
const form = reactive({ journal: '', status: 'draft', date: '', notes: '' });
const form = reactive({
journal: '', status: 'draft', date: '', notes: '',
revisionDue: '', revisionRound: '',
});
const todayStr = () => new Date().toISOString().slice(0, 10);
const dateOf = (s) => String(s || '').slice(0, 10);
const dtOf = (s) => String(s || '').slice(0, 16).replace('T', ' ');
// 返修截止倒计时:"overdue 2d" / "due today" / "due in 3d"
function dueText(due) {
if (!due) return '';
const dueDay = new Date(`${String(due).slice(0, 10)}T23:59:59`);
const diff = Math.ceil((dueDay - Date.now()) / 86400000);
if (Number.isNaN(diff)) return `due ${dateOf(due)}`;
if (diff < 0) return `已逾期 ${-diff} 天`;
if (diff === 0) return '今天截止';
return `还剩 ${diff} 天`;
}
const stats = computed(() => {
const rows = edu.submissionsSorted;
return {
total: rows.length,
active: rows.filter((r) => r.status === 'submitted' || r.status === 'under_review').length,
revision: rows.filter((r) => r.status === 'revision').length,
accepted: rows.filter((r) => r.status === 'accepted').length,
rejected: rows.filter((r) => r.status === 'rejected').length,
};
});
function openCreate() {
mode.value = 'create';
editingId.value = null;
@@ -37,6 +65,8 @@ function openCreate() {
form.status = 'draft';
form.date = todayStr();
form.notes = '';
form.revisionDue = '';
form.revisionRound = '';
formError.value = '';
pendingDeleteId.value = null;
formOpen.value = true;
@@ -48,6 +78,8 @@ function openEdit(row) {
form.status = row.status;
form.date = dateOf(row.submittedAt);
form.notes = row.notes || '';
form.revisionDue = row.revisionDue || '';
form.revisionRound = row.revisionRound != null ? String(row.revisionRound) : '';
formError.value = '';
pendingDeleteId.value = null;
formOpen.value = true;
@@ -62,18 +94,21 @@ function save() {
formError.value = '请输入期刊名';
return;
}
const round = form.revisionRound === '' ? null : Math.max(1, parseInt(form.revisionRound, 10) || 1);
const extra = {
notes: form.notes,
revisionDue: form.revisionDue || '',
revisionRound: round,
};
if (mode.value === 'create') {
const rec = edu.addSubmission({ journal, status: form.status, notes: form.notes });
// addSubmission 固定 submittedAt=now(ISO);仅当用户改了投稿日期才补丁(复用 updateSubmission,不改 store)
const rec = edu.addSubmission({ journal, status: form.status, ...extra });
// addSubmission 默认 submittedAt=now(ISO);仅当用户改了投稿日期才补丁
if (form.date && form.date !== todayStr()) {
edu.updateSubmission(rec.id, { submittedAt: form.date });
}
} else {
edu.updateSubmission(editingId.value, {
journal,
status: form.status,
notes: form.notes,
submittedAt: form.date,
journal, status: form.status, submittedAt: form.date, ...extra,
});
}
closeForm();
@@ -82,16 +117,28 @@ function confirmDelete() {
if (pendingDeleteId.value) edu.removeSubmission(pendingDeleteId.value);
pendingDeleteId.value = null;
}
function startRebuttal(row) {
edu.rebuttalStartFromTracker(row);
}
</script>
<template>
<section class="tk">
<header class="tk-head">
<h1 class="tk-title">投稿进度与返修</h1>
<h1 class="tk-title">投稿进度台账</h1>
<span class="tk-sub">本地台账 · 仅存于当前浏览器</span>
<button class="tk-add" type="button" @click="openCreate">+ 新增投稿</button>
</header>
<!-- ========== 统计条 ========== -->
<div class="tk-stats" data-testid="tk-stats">
<div class="tk-stat"><b>{{ stats.total }}</b><span>总投稿</span></div>
<div class="tk-stat"><b>{{ stats.active }}</b><span>进行中</span></div>
<div class="tk-stat tk-stat-rev"><b>{{ stats.revision }}</b><span>待返修</span></div>
<div class="tk-stat tk-stat-ok"><b>{{ stats.accepted }}</b><span>已接收</span></div>
<div class="tk-stat tk-stat-no"><b>{{ stats.rejected }}</b><span>已拒绝</span></div>
</div>
<!-- ========== 新增/编辑共用抽屉 ========== -->
<div v-if="formOpen" class="tk-form">
<div class="tk-form-grid">
@@ -117,6 +164,14 @@ function confirmDelete() {
<span class="tk-label">投稿日期</span>
<input v-model="form.date" class="tk-input" type="date" />
</label>
<label class="tk-field">
<span class="tk-label">返修截止日(可选)</span>
<input v-model="form.revisionDue" class="tk-input" type="date" data-testid="tk-revision-due" />
</label>
<label class="tk-field">
<span class="tk-label">返修轮次(可选,默认 1)</span>
<input v-model="form.revisionRound" class="tk-input" type="number" min="1" placeholder="1" data-testid="tk-revision-round" />
</label>
<label class="tk-field tk-field-wide">
<span class="tk-label">备注(可空)</span>
<textarea
@@ -138,7 +193,7 @@ function confirmDelete() {
<!-- ========== 空态 ========== -->
<p v-if="!edu.submissionsSorted.length" class="tk-empty">
暂无投稿记录,点击「新增投稿」开始追踪
暂无投稿记录。点击「新增投稿」记录你投出的稿件。
</p>
<!-- ========== 列表(store 已按 updatedAt 倒序) ========== -->
@@ -147,12 +202,18 @@ function confirmDelete() {
:key="row.id"
class="tk-row"
:class="{ confirming: pendingDeleteId === row.id }"
data-testid="tk-row"
>
<div class="tk-main">
<span class="tk-journal" :title="row.journal">{{ row.journal }}</span>
<span class="tk-badge" :class="row.status">{{ STATUS_LABEL[row.status] || row.status }}</span>
<span class="tk-date">投稿 {{ dateOf(row.submittedAt) || '—' }}</span>
<span class="tk-updated">更新 {{ dtOf(row.updatedAt) || '—' }}</span>
<!-- 返修轮次 + 截止倒计时(台账信息,无联动暗示) -->
<span v-if="row.revisionRound || row.revisionDue" class="tk-rev" data-testid="tk-rev-info">
第 {{ row.revisionRound || 1 }} 轮返修
<template v-if="row.revisionDue"> · due {{ dateOf(row.revisionDue) }}({{ dueText(row.revisionDue) }})</template>
</span>
</div>
<p class="tk-notes" :class="{ empty: !row.notes }">{{ row.notes || '—' }}</p>
<div class="tk-actions">
@@ -166,6 +227,14 @@ function confirmDelete() {
<button class="tk-act tk-danger" type="button" @click="pendingDeleteId = row.id">
删除
</button>
<!-- 方案 A:仅审稿中/返修中可跳去写返修(纯跳转,无联动) -->
<button
v-if="row.status === 'under_review' || row.status === 'revision'"
class="tk-act tk-rebuttal-btn" type="button" data-testid="tk-start-rebuttal"
@click="startRebuttal(row)"
>
去写返修
</button>
</template>
</div>
</div>
@@ -208,6 +277,31 @@ function confirmDelete() {
filter: brightness(1.08);
}
/* ---------- 统计条 ---------- */
.tk-stats {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.tk-stat {
display: flex;
align-items: baseline;
gap: 6px;
padding: 8px 14px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
font-size: 12px;
color: var(--pa-ink-muted);
}
.tk-stat b {
font-size: 18px;
color: var(--pa-ink);
}
.tk-stat-rev b { color: #b8860b; }
.tk-stat-ok b { color: #2b8a3e; }
.tk-stat-no b { color: #c92a2a; }
/* ---------- 抽屉表单 ---------- */
.tk-form {
display: flex;
@@ -248,84 +342,62 @@ function confirmDelete() {
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 13px;
font-family: inherit;
}
.tk-input:focus {
outline: none;
border-color: var(--pa-g-from);
}
.tk-textarea {
resize: vertical;
min-height: 60px;
min-height: 56px;
}
.tk-form-error {
margin: 0;
font-size: 12.5px;
color: var(--pa-error);
font-size: 12px;
}
.tk-form-btns {
display: flex;
gap: 10px;
gap: 8px;
}
.tk-save {
padding: 6px 20px;
padding: 7px 18px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
background: var(--pa-accent);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tk-cancel,
.tk-act {
padding: 6px 14px;
.tk-cancel {
padding: 7px 14px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12.5px;
font-size: 13px;
cursor: pointer;
}
.tk-cancel:hover,
.tk-act:hover {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
.tk-danger {
color: var(--pa-error);
border-color: currentColor;
}
.tk-danger:hover {
color: var(--pa-error);
background: transparent;
filter: brightness(1.15);
}
/* ---------- 空态 ---------- */
.tk-empty {
margin: 0;
padding: 40px 16px;
padding: 28px 18px;
text-align: center;
font-size: 13px;
color: var(--pa-ink-subtle);
border: 1px dashed var(--pa-hairline);
font-size: 13px;
background: var(--pa-surface-1);
border: 1px dashed var(--pa-hairline-strong);
border-radius: var(--pa-r-lg);
line-height: 1.7;
}
/* ---------- 列表行 ---------- */
.tk-row {
display: flex;
flex-direction: column;
gap: 7px;
padding: 12px 16px;
gap: 6px;
padding: 12px 14px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
transition: border-color .15s var(--pa-ease);
}
.tk-row:hover {
border-color: var(--pa-hairline-strong);
border-radius: var(--pa-r-lg);
}
.tk-row.confirming {
border-color: var(--pa-error);
@@ -333,62 +405,76 @@ function confirmDelete() {
.tk-main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
flex-wrap: wrap;
}
.tk-journal {
font-size: 14px;
font-weight: 600;
font-size: 14px;
color: var(--pa-ink);
max-width: 44%;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 状态徽章 6 态配色:灰/蓝/青/橙/绿/红。
青(under_review)无专用 --pa-cyan 变量 → 用现成渐变终点色 --pa-g-to(#00D4FF),不硬编码 */
.tk-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border: 1px solid currentColor;
border-radius: var(--pa-r-pill);
font-size: 11.5px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1.6;
}
.tk-badge.draft { color: var(--pa-ink-subtle); }
.tk-badge.submitted { color: var(--pa-info); }
.tk-badge.under_review { color: var(--pa-g-to); }
.tk-badge.revision { color: var(--pa-warning); }
.tk-badge.accepted { color: var(--pa-success); }
.tk-badge.rejected { color: var(--pa-error); }
.tk-date,
.tk-updated {
font-family: var(--pa-font-mono);
font-size: 11.5px;
.tk-badge.draft { background: #f1f3f5; color: #495057; }
.tk-badge.submitted { background: #e7f5ff; color: #1971c2; }
.tk-badge.under_review { background: #fff9db; color: #997404; }
.tk-badge.revision { background: #fff3bf; color: #b8860b; }
.tk-badge.accepted { background: #d3f9d8; color: #2b8a3e; }
.tk-badge.rejected { background: #ffe3e3; color: #c92a2a; }
.tk-date, .tk-updated {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.tk-rev {
font-size: 12px;
font-weight: 600;
color: #b8860b;
}
.tk-notes {
margin: 0;
font-size: 12.5px;
line-height: 1.55;
font-size: 12px;
color: var(--pa-ink-muted);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
}
.tk-notes.empty {
color: var(--pa-ink-subtle);
}
.tk-actions {
display: flex;
align-items: center;
gap: 8px;
align-items: center;
}
.tk-confirm-q {
font-size: 12.5px;
font-size: 12px;
color: var(--pa-error);
}
.tk-act {
padding: 4px 12px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
background: transparent;
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
}
.tk-act:hover {
background: var(--pa-surface-2);
}
.tk-danger {
color: var(--pa-error);
border-color: var(--pa-error);
}
.tk-rebuttal-btn {
color: var(--pa-accent);
border-color: var(--pa-accent);
font-weight: 600;
}
</style>
+139 -5
View File
@@ -4,10 +4,11 @@
// - 进行中的请求由 store action 持有 → 面板卸载不中断;
// - 表单草稿存 drafts → 切菜单不丢。
import { defineStore } from 'pinia';
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reformatTex, reviewErrorMessage } from '../api/review.js';
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reformatTex, reviewErrorMessage, rebuttalConfirm, rebuttalCreateFromPdf, rebuttalCreateJob, rebuttalDeleteJob, rebuttalDraft, rebuttalLetter, rebuttalSaveResponse } from '../api/review.js';
const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'reformat', 'tracker', 'calendar'];
const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化(tracker 面板已解除映射,state 保留)
// tracker 已砍(2026-10-12,仅解除 UI 映射;TrackerPanel.vue 保留在 panels/ 下可复用)
const SECTIONS = ['journals', 'recommend', 'presubmit', 'report', 'format', 'reformat', 'calendar', 'rebuttal'];
// ⑥ 台账状态枚举(labels 由 step-7 UI 层给;store 只存字符串)
export const SUBMISSION_STATUS = ['draft', 'submitted', 'under_review', 'revision', 'accepted', 'rejected'];
@@ -68,6 +69,9 @@ export const useEduStore = defineStore('edu', {
reportCreatedAt: null, // ④报告头生成时间(ISO;payload 无时间字段,提交成功时盖章,clearReport 重置)
reportStatus: 'idle',
reportError: null,
// ③ presubmit 已选 PDF(File 对象,仅内存:面板卸载/切菜单不丢,error 态可直接重试;
// 不可序列化 → 不进 localStorage;clearReport 一并清空)
presubmitFile: null,
recommend: null, // ② 推荐响应 {ok, tiers, meta}
recommendStatus: 'idle',
recommendError: null,
@@ -104,6 +108,19 @@ export const useEduStore = defineStore('edu', {
// 结果区档位筛选(纯视图状态,不参与 payload;随 store 保留跨菜单)
tierFilter: 'all',
},
/* —— Rebuttal Phase 1(审稿回复助手;纯内存态,切换面板不丢,刷新丢失)—— */
rebuttal: {
job: null, // 后端全量 job(reviewers/letter_md/human_ack...)
status: 'idle', // idle | loading | ready | error
error: null,
expired: false, // NOT_FOUND(job 已删除/过期)→ 面板显示"任务已过期"
activeCid: null, // 当前编辑的 comment id
letterLoading: false,
draftCid: null, // 正在生成 draft 的 comment(按钮 loading)
// Tracker「去写返修」带来的期刊名预填(方案 A:仅跳转,无 jobId 回写)
prefillJournal: '',
},
}),
getters: {
@@ -121,9 +138,16 @@ export const useEduStore = defineStore('edu', {
loadFromStorage() {
this.submissions = readStorage();
},
addSubmission({ journal, status = 'draft', notes = '' }) {
addSubmission({ journal, status = 'draft', notes = '', submittedAt = null, revisionDue = '', revisionRound = null, rebuttalJobId = null }) {
const now = new Date().toISOString();
const rec = { id: safeId(), journal, status, submittedAt: now, updatedAt: now, notes };
const rec = {
id: safeId(), journal, status,
submittedAt: submittedAt || now,
updatedAt: now, notes,
revisionDue: revisionDue || '', // 返修截止日 'YYYY-MM-DD'(可空)
revisionRound: revisionRound || null, // 返修轮次(可空)
rebuttalJobId: rebuttalJobId || null, // 关联的 rebuttal job(开始返修回写)
};
this.submissions.unshift(rec);
this._persist();
return rec;
@@ -236,6 +260,116 @@ export const useEduStore = defineStore('edu', {
this.reportCreatedAt = null;
this.reportStatus = 'idle';
this.reportError = null;
this.presubmitFile = null;
},
/* —— Rebuttal Phase 1 actions(job 为后端权威,本地只镜像)—— */
_rebuttalBase() {
return {
job: null, status: 'idle', error: null, expired: false, activeCid: null,
letterLoading: false, draftCid: null, prefillJournal: '',
};
},
_rebuttalAfterCreate(res) {
this.rebuttal.job = res.job;
this.rebuttal.status = 'ready';
return res.job;
},
async rebuttalCreate(text) {
this.rebuttal = { ...this._rebuttalBase(), status: 'loading', prefillJournal: this.rebuttal.prefillJournal };
try {
const res = await rebuttalCreateJob(text);
return this._rebuttalAfterCreate(res);
} catch (err) {
this.rebuttal.status = 'error';
this.rebuttal.error = reviewErrorMessage(err);
return null;
}
},
async rebuttalCreatePdf(formData) {
this.rebuttal = { ...this._rebuttalBase(), status: 'loading', prefillJournal: this.rebuttal.prefillJournal };
try {
const res = await rebuttalCreateFromPdf(formData);
return this._rebuttalAfterCreate(res);
} catch (err) {
this.rebuttal.status = 'error';
this.rebuttal.error = reviewErrorMessage(err);
return null;
}
},
/** Tracker「去写返修」(方案 A):仅跳转 + 期刊名预填,不做 jobId 回写联动 */
rebuttalStartFromTracker(row) {
this.clearRebuttal();
this.setSection('rebuttal');
this.rebuttal.prefillJournal = row?.journal || '';
},
rebuttalSetActive(cid) {
this.rebuttal.activeCid = cid;
},
_applyRebuttalJob(job) {
if (job) this.rebuttal.job = job;
},
async rebuttalSaveResponse(cid, parts) {
try {
const res = await rebuttalSaveResponse(this.rebuttal.job.id, cid, parts);
this._applyRebuttalJob(res.job);
return { ok: true };
} catch (err) {
return { ok: false, error: reviewErrorMessage(err), code: err?.code };
}
},
async rebuttalDraft(cid) {
this.rebuttal.draftCid = cid;
try {
const res = await rebuttalDraft(this.rebuttal.job.id, cid);
this._applyRebuttalJob(res.job);
return { ok: true, validation: res.meta?.validation };
} catch (err) {
// VALIDATION_FAILED:信封里的 job 已带 validation,一并镜像
if (err?.payload?.job) this._applyRebuttalJob(err.payload.job);
return {
ok: false,
error: reviewErrorMessage(err),
code: err?.code,
validation: err?.payload?.meta?.validation || null,
};
} finally {
// 成功/失败/异常都要解锁按钮——否则校验报红后按钮永卡"生成中"
this.rebuttal.draftCid = null;
}
},
// Phase 1 砍手工合并/拆分(2026-10-12):PATCH /structure 后端端点保留,前端不调
async rebuttalConfirm(cid, confirmed) {
try {
const res = await rebuttalConfirm(this.rebuttal.job.id, cid, confirmed);
this._applyRebuttalJob(res.job);
return { ok: true, humanAck: res.meta?.human_ack ?? res.job?.human_ack };
} catch (err) {
return { ok: false, error: reviewErrorMessage(err), code: err?.code };
}
},
async rebuttalAssembleLetter() {
try {
const res = await rebuttalLetter(this.rebuttal.job.id);
this._applyRebuttalJob(res.job);
return { ok: true };
} catch (err) {
return { ok: false, error: reviewErrorMessage(err), code: err?.code };
}
},
async rebuttalDelete() {
const id = this.rebuttal.job?.id;
this.clearRebuttal();
if (!id) return { ok: true };
try {
await rebuttalDeleteJob(id);
return { ok: true };
} catch (err) {
return { ok: false, error: reviewErrorMessage(err) };
}
},
clearRebuttal() {
this.rebuttal = this._rebuttalBase();
},
async submitRecommend(payload) {
this.recommendStatus = 'loading';