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:
+69
-1
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user