7 Commits

11 changed files with 2101 additions and 26 deletions

View File

@@ -391,7 +391,8 @@ str = str.replace(regex, function (match, content, offset, fullString) {
const allTables = [];
if (!tables || tables.length === 0) {
console.warn("未找到表格内容,请检查 XML 结构");
return [];
callback([]);
return;
}
for (const table of tables) {
const rows = table.getElementsByTagNameNS(namespace, "tr");

View File

@@ -128,6 +128,7 @@ export default {
},
created() {
this.initORCID();
bus.$on('editorSessionLocalRestored', this.syncLsUserToHeader);
if (this.user_id == 24) {
this.daojishi = '2021.9.3 - 2021.9.30';
this.curStartTime = '2021-10-01 00:00:00';
@@ -162,6 +163,11 @@ export default {
}
},
methods: {
syncLsUserToHeader() {
this.user_id = localStorage.getItem('U_id');
this.user_cap = localStorage.getItem('U_role');
this.$forceUpdate();
},
goHome() {
this.$router.push('/');
},
@@ -295,6 +301,9 @@ export default {
},
immediate: true
}
},
beforeDestroy() {
bus.$off('editorSessionLocalRestored', this.syncLsUserToHeader);
}
};
</script>

View File

@@ -120,6 +120,7 @@ export default {
},
created() {
this.initORCID();
bus.$on('editorSessionLocalRestored', this.onEditorSessionLocalRestored);
if (this.user_id == 24) {
this.daojishi = '2021.9.3 - 2021.9.30';
this.curStartTime = '2021-10-01 00:00:00';
@@ -155,6 +156,10 @@ export default {
}
},
methods: {
onEditorSessionLocalRestored() {
this.updateUsername();
this.$forceUpdate();
},
updateUsername() {
this.localUsername = localStorage.getItem('U_name');
@@ -290,6 +295,7 @@ export default {
}
},
beforeDestroy() {
bus.$off('editorSessionLocalRestored', this.onEditorSessionLocalRestored);
// 步骤 C2: 销毁前移除监听器
if (this.$bus) {
this.$bus.$off('user-name-updated', this.updateUsername);

View File

@@ -2,14 +2,14 @@
//记得切换
//正式
const mediaUrl = '/public/';
const baseUrl = '/';
// const mediaUrl = '/public/';
// const baseUrl = '/';
//正式环境
// const mediaUrl = 'https://submission.tmrjournals.com/public/';
// // const mediaUrl = 'http://zmzm.tougao.dev.com/public/';
// const baseUrl = '/api'
const mediaUrl = 'https://submission.tmrjournals.com/public/';
// const mediaUrl = 'http://zmzm.tougao.dev.com/public/';
const baseUrl = '/api'
//测试环境

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="article-detail-editor-page">
<div class="crumbs">
<div class="art_state_message_id" style="padding-left: 18px">
<font
@@ -915,9 +915,38 @@
}}</b>
<!-- <el-button type="text" @click="testedit" icon="el-icon-edit">Change</el-button> -->
</div>
<div>
<span>Repetition : </span>
<b>{{ form.repetition }}%</b>
<div class="detail-plagiarism-row">
<span class="detail-plagiarism-main">
<span class="detail-plagiarism-lbl">Repetition :</span>
<template v-if="detailPlagiarismUiShowProcessing()">
<span class="detail-plagiarism-loading">
<i class="el-icon-loading"></i>
{{ $t('articleListEditor.plagiarismChecking') }}
</span>
</template>
<template v-else-if="detailPlagiarismUiShowResult()">
<span
class="detail-plagiarism-pct-text"
:style="detailPlagiarismSimilarityStyle()"
@click.stop="detailOpenPlagiarismReport"
>
{{ detailPlagiarismSimilarityNumber() }}%
</span>
</template>
<template v-else>
<span class="detail-plagiarism-not-checked-text" @click.stop="detailTriggerCrossrefPlagiarismCheck">
{{ $t('articleListEditor.plagiarismNotChecked') }}
</span>
</template>
<el-button
v-if="detailPlagiarismShowRecheck()"
type="text"
class="detail-plagiarism-recheck-btn detail-plagiarism-recheck-inline"
@click.stop="detailTriggerCrossrefPlagiarismCheck"
>
{{ $t('articleListEditor.plagiarismRecheck') }}
</el-button>
</span>
</div>
<!--<br clear="both">
<el-button type="primary" @click="showResubmit" style="margin: 15px 0 0 0;">Resubmit the manuscript
@@ -937,9 +966,38 @@
}}</b>
<el-button style="padding: 0" type="text" @click="testedit" icon="el-icon-edit">Change</el-button>
</div>
<div>
<span>Repetition : </span>
<b>{{ form.repetition }}%</b>
<div class="detail-plagiarism-row detail-plagiarism-row--with-actions">
<span class="detail-plagiarism-main">
<span class="detail-plagiarism-lbl">Repetition :</span>
<template v-if="detailPlagiarismUiShowProcessing()">
<span class="detail-plagiarism-loading">
<i class="el-icon-loading"></i>
{{ $t('articleListEditor.plagiarismChecking') }}
</span>
</template>
<template v-else-if="detailPlagiarismUiShowResult()">
<span
class="detail-plagiarism-pct-text"
:style="detailPlagiarismSimilarityStyle()"
@click.stop="detailOpenPlagiarismReport"
>
{{ detailPlagiarismSimilarityNumber() }}%
</span>
</template>
<template v-else>
<span class="detail-plagiarism-not-checked-text" @click.stop="detailTriggerCrossrefPlagiarismCheck">
{{ $t('articleListEditor.plagiarismNotChecked') }}
</span>
</template>
<el-button
v-if="detailPlagiarismShowRecheck()"
type="text"
class="detail-plagiarism-recheck-btn detail-plagiarism-recheck-inline"
@click.stop="detailTriggerCrossrefPlagiarismCheck"
>
{{ $t('articleListEditor.plagiarismRecheck') }}
</el-button>
</span>
<a :href="mediaUrl + form.repeurl" v-if="form.repeurl" class="zip_load" target="_blank">
<img src="../../assets/img/icon_0.png" />
<span>Duplicate check file</span>
@@ -1522,6 +1580,8 @@
import timetalk from './time_talk';
import reviewerDetail from '../../components/page/components/articleDetail/reviewerdetail.vue';
import FigureCopyright from '../../components/page/components/articleDetail/FigureCopyright.vue';
import axios from 'axios';
import { getSimilarityStyle } from '../../utils/ithenticateSimilarityStyle';
export default {
components: {
timetalk,
@@ -1645,6 +1705,9 @@ export default {
approval_content: '',
is_figure_copyright: '',
repetition: '',
plagiarism_similarity: '',
plagiarism_report_url: '',
plagiarism_job_state: '',
manuscirpt: '',
remarks: '',
state: '',
@@ -1840,6 +1903,8 @@ export default {
finalDecision: ['1'],
is_figure_copyright: '',
figurecopyright_file: '',
plagiarismDetailPollTimer: null,
plagiarismDetailPending: false,
plagiarismList: [],
plagiarismListLoading: false,
plagiarismListReady: false,
@@ -1868,6 +1933,7 @@ export default {
},
beforeDestroy() {
this.stopPlagiarismPolling();
this.detailStopPlagiarismPolling();
},
computed: {
// coverLetterUrl: function() {
@@ -2657,6 +2723,26 @@ export default {
this.form.approval_file = res.article.approval_file;
this.form.approval_content = res.article.approval_content;
this.form.repetition = res.article.repetition;
this.$set(
this.form,
'plagiarism_similarity',
res.article.plagiarism_similarity != null && res.article.plagiarism_similarity !== ''
? res.article.plagiarism_similarity
: res.article.crossref_similarity != null && res.article.crossref_similarity !== ''
? res.article.crossref_similarity
: ''
);
this.$set(
this.form,
'plagiarism_report_url',
res.article.plagiarism_report_url || res.article.crossref_report_url || ''
);
this.$set(
this.form,
'plagiarism_job_state',
res.article.plagiarism_job_state || res.article.crossref_status || ''
);
this.detailInitPlagiarismAfterLoad();
this.form.remarks = res.article.remarks;
this.form.repeurl = res.article.repeurl;
this.repeform.repefen = res.article.repetition;
@@ -2973,6 +3059,181 @@ export default {
closeResubmit() {
(this.resubmitVisible = false), this.$refs['resubmitJournal'].resetFields();
},
/* ---------- Crossref plagiarism详情页 Repetition 行) ---------- */
detailPlagiarismArticleKey() {
return String(this.form.articleId || this.editform.articleId || this.$route.query.id || '');
},
detailNormalizePlagiarismStatusPayload(body) {
const root = body && typeof body === 'object' ? body : {};
if (root.code != null && Number(root.code) !== 0) {
return { status: 'api_error', similarity: null, reportUrl: '' };
}
let d = root.data != null ? root.data : root;
if (typeof d === 'string') {
try {
d = JSON.parse(d);
} catch (e) {
d = {};
}
}
if (!d || typeof d !== 'object') d = {};
const statusRaw = d.status || d.state || d.job_status || d.plagiarism_status || '';
const status = String(statusRaw).toLowerCase();
let sim =
d.similarity != null
? d.similarity
: d.percent != null
? d.percent
: d.similarity_percent != null
? d.similarity_percent
: d.crossref_similarity != null
? d.crossref_similarity
: null;
if (sim === '' || sim === undefined) sim = null;
const reportUrl = d.report_url || d.reportUrl || d.url || d.report_link || '';
return { status, similarity: sim, reportUrl: String(reportUrl || '') };
},
detailPlagiarismSimilarityRaw() {
const f = this.form;
if (!f) return null;
if (f.plagiarism_similarity != null && f.plagiarism_similarity !== '') return f.plagiarism_similarity;
if (f.crossref_similarity != null && f.crossref_similarity !== '') return f.crossref_similarity;
const st = String(f.plagiarism_job_state || f.crossref_status || '').toLowerCase();
if (['completed', 'done', 'success', 'complete'].includes(st) && (f.plagiarism_similarity === 0 || f.plagiarism_similarity === '0')) {
return 0;
}
const rep = f.repetition;
if (rep != null && rep !== '' && Number(rep) > 0) return rep;
return null;
},
detailPlagiarismSimilarityNumber() {
const n = this.detailPlagiarismSimilarityRaw();
if (n == null || n === '') return 0;
const x = Number(n);
return isNaN(x) ? 0 : Math.round(x * 10) / 10;
},
detailPlagiarismUiShowProcessing() {
if (this.form._plagiarismLocalLoading) return true;
if (this.plagiarismDetailPending) return true;
const st = String(this.form.plagiarism_job_state || this.form.crossref_status || '').toLowerCase();
return ['pending', 'processing', 'queued', 'running', 'submitted'].includes(st);
},
detailPlagiarismUiShowResult() {
if (this.detailPlagiarismUiShowProcessing()) return false;
const st = String(this.form.plagiarism_job_state || this.form.crossref_status || '').toLowerCase();
const done = ['completed', 'done', 'success', 'complete'].includes(st);
const raw = this.detailPlagiarismSimilarityRaw();
if (raw != null && raw !== '' && !isNaN(Number(raw))) {
if (Number(raw) === 0 && !done && !(this.form.plagiarism_report_url || this.form.crossref_report_url)) {
return false;
}
return true;
}
if (done && (this.form.plagiarism_similarity === 0 || this.form.plagiarism_similarity === '0')) return true;
return !!(this.form.plagiarism_report_url || this.form.crossref_report_url);
},
detailPlagiarismSimilarityStyle() {
const n = Number(this.detailPlagiarismSimilarityNumber());
return { color: getSimilarityStyle(n).color };
},
detailPlagiarismShowRecheck() {
if (this.detailPlagiarismUiShowProcessing()) return false;
return Number(this.form.state) === 6;
},
detailInitPlagiarismAfterLoad() {
const st = String(this.form.plagiarism_job_state || '').toLowerCase();
if (['pending', 'processing', 'queued', 'running', 'submitted'].includes(st)) {
this.plagiarismDetailPending = true;
this.detailEnsurePlagiarismPoll();
}
},
detailEnsurePlagiarismPoll() {
if (!this.plagiarismDetailPending) {
this.detailStopPlagiarismPolling();
return;
}
if (!this.plagiarismDetailPollTimer) {
this.detailPollPlagiarismOnce();
this.plagiarismDetailPollTimer = setInterval(() => this.detailPollPlagiarismOnce(), 60000);
}
},
detailStopPlagiarismPolling() {
if (this.plagiarismDetailPollTimer) {
clearInterval(this.plagiarismDetailPollTimer);
this.plagiarismDetailPollTimer = null;
}
},
async detailPollPlagiarismOnce() {
const key = this.detailPlagiarismArticleKey();
if (!key || !this.plagiarismDetailPending) return;
try {
const res = await axios.get('/api/plagiarism/status', { params: { article_id: key } });
const body = res && res.data;
const norm = this.detailNormalizePlagiarismStatusPayload(body);
if (norm.status === 'api_error') {
this.plagiarismDetailPending = false;
this.detailStopPlagiarismPolling();
return;
}
if (norm.similarity != null && norm.similarity !== '') {
this.$set(this.form, 'plagiarism_similarity', norm.similarity);
}
if (norm.reportUrl) this.$set(this.form, 'plagiarism_report_url', norm.reportUrl);
if (norm.status) this.$set(this.form, 'plagiarism_job_state', norm.status);
const active = ['pending', 'processing', 'queued', 'running', 'submitted'];
const terminal = ['completed', 'done', 'success', 'complete', 'failed', 'error', 'fail', 'cancelled'];
const isActive = active.includes(norm.status);
let clearPending =
terminal.includes(norm.status) ||
norm.status === 'error' ||
(!isActive && norm.reportUrl && norm.similarity != null && norm.similarity !== '');
if (clearPending) {
this.plagiarismDetailPending = false;
}
this.detailEnsurePlagiarismPoll();
} catch (e) {
console.error(e);
this.$message.error(this.$t('articleListEditor.plagiarismStatusFailed'));
}
},
async detailTriggerCrossrefPlagiarismCheck() {
const key = this.detailPlagiarismArticleKey();
if (!key) return;
if (this.detailPlagiarismUiShowProcessing()) return;
this.$set(this.form, '_plagiarismLocalLoading', true);
try {
const res = await this.$api.post('api/plagiarism/check', { article_id: key });
if (res && Number(res.code) === 0) {
this.plagiarismDetailPending = true;
this.$set(this.form, 'plagiarism_job_state', 'pending');
this.detailEnsurePlagiarismPoll();
await this.detailPollPlagiarismOnce();
} else {
this.$message.error((res && res.msg) || this.$t('articleListEditor.plagiarismCheckFailed'));
}
} catch (e) {
console.error(e);
this.$message.error(this.$t('articleListEditor.plagiarismCheckFailed'));
} finally {
this.$set(this.form, '_plagiarismLocalLoading', false);
}
},
detailOpenPlagiarismReport() {
const raw = (this.form.plagiarism_report_url || this.form.crossref_report_url || '').trim();
if (!raw) {
this.$message.warning(this.$t('articleListEditor.plagiarismNoReportUrl'));
return;
}
let full = raw;
if (!/^https?:\/\//i.test(raw)) {
const base = (this.mediaUrl || '').replace(/\/+$/, '');
const path = raw.replace(/^\/+/, '');
full = base ? `${base}/${path}` : `/${path}`;
}
window.open(full, '_blank');
},
/* ---------- 自动查重列表 ---------- */
startPlagiarismPolling(resetList) {
this.stopPlagiarismPolling();
if (resetList !== false) {
@@ -3807,4 +4068,80 @@ td {
.copyright-declaration-wrapper :deep(.el-radio.is-checked .el-radio__label) {
font-weight: 500;
}
/* 详情页:去掉全局 main.css 中 .art_caozuo_ 的浅蓝底;查重行无额外底色 */
.article-detail-editor-page .art_caozuo_ {
background-color: #fff !important;
border: 1px solid #ebeef5 !important;
}
.detail-plagiarism-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
background: transparent !important;
padding: 4px 0 8px 0;
margin: 0 0 10px 0;
}
/* 合并「Repetition :」与数值,避免命中 .art_caozuo_ > div > span 的 min-width:65px 造成巨大空隙 */
.article-detail-editor-page .art_caozuo_ > div > span.detail-plagiarism-main {
min-width: 0 !important;
width: auto !important;
max-width: 100%;
margin-right: 8px !important;
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 6px;
vertical-align: middle;
}
.article-detail-editor-page .art_caozuo_ .detail-plagiarism-lbl {
color: #777;
margin: 0 2px 0 0 !important;
min-width: 0 !important;
width: auto !important;
white-space: nowrap;
}
.detail-plagiarism-loading {
color: #409eff;
font-size: 13px;
font-weight: 600;
}
.detail-plagiarism-loading .el-icon-loading {
margin-right: 6px;
}
.detail-plagiarism-pct-text {
cursor: pointer;
font-size: 14px;
font-weight: 700;
background: none !important;
border: none !important;
padding: 0;
line-height: 1.2;
}
.detail-plagiarism-pct-text:hover {
opacity: 0.92;
}
.detail-plagiarism-not-checked-text {
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: #409eff;
text-decoration: underline;
text-underline-offset: 2px;
}
.detail-plagiarism-not-checked-text:hover {
color: #66b1ff;
}
.detail-plagiarism-recheck-btn {
margin-left: 4px !important;
font-weight: 600 !important;
}
/* 紧跟在百分比 / 未检测 文案后,避免继承整行大间距 */
.detail-plagiarism-main .detail-plagiarism-recheck-inline.el-button--text {
margin-left: 0 !important;
padding: 0 2px !important;
vertical-align: baseline;
line-height: inherit;
}
</style>

View File

@@ -116,9 +116,36 @@
<i class="el-icon-data-line"></i> Manuscript Tracking
</b>
<span style="float: right">
<span style="float: right" class="plagiarism-header-bar">
<span class="labelTitle" style="font-weight: 500; font-size: 13px">Plagiarism Check :</span>
<font style="margin-right: 16px; font-size: 13px; font-weight: 700"> {{ item.repetition }} % </font>
<template v-if="plagiarismUiShowProcessing(item)">
<span class="plagiarism-inline-loading plagiarism-on-blue">
<i class="el-icon-loading"></i>
<span>{{ $t('articleListEditor.plagiarismChecking') }}</span>
</span>
</template>
<template v-else-if="plagiarismUiShowResult(item)">
<span
class="plagiarism-pct-text plagiarism-on-blue"
@click.stop="openPlagiarismReport(item)"
>
{{ plagiarismSimilarityNumber(item) }}%
</span>
</template>
<template v-else>
<span class="plagiarism-not-checked-text plagiarism-on-blue" @click.stop="triggerCrossrefPlagiarismCheck(item)">
{{ $t('articleListEditor.plagiarismNotChecked') }}
</span>
</template>
<el-button
v-if="plagiarismShowRecheck(item)"
type="text"
size="mini"
class="plagiarism-recheck-btn"
@click.native.stop="triggerCrossrefPlagiarismCheck(item)"
>
{{ $t('articleListEditor.plagiarismRecheck') }}
</el-button>
</span>
<span style="margin: 0 10px; float: right">| </span>
@@ -1065,6 +1092,7 @@
</template>
<script>
import axios from 'axios';
import { Loading } from 'element-ui';
import timetalk from './time_talk';
import commonRemarkList from './articleListEditor_A_list.vue';
@@ -1432,12 +1460,18 @@ export default {
editVisible1: false,
bankVisible: false,
majorData: {},
googleSearchInfo: ''
googleSearchInfo: '',
/** Crossref / plagiarism async polling */
plagiarismPollTimer: null,
plagiarismPendingIds: {}
};
},
created() {
this.getdate();
},
beforeDestroy() {
this.stopPlagiarismPolling();
},
computed: {
upload_zip: function () {
return this.baseUrl + 'api/Article/up_file/type/repezip';
@@ -2072,6 +2106,7 @@ export default {
this.tableData[i].reportList = this.tableData[i].reportList.slice(0, 3);
this.$forceUpdate();
}
this.initPlagiarismFromList();
loading.close();
})
@@ -2658,6 +2693,234 @@ export default {
return str;
},
/* ---------- Crossref plagiarism (list header) ---------- */
_plagiarismArticleKey(item) {
return item && item.article_id != null ? String(item.article_id) : '';
},
_plagiarismPending(articleKey) {
return !!(articleKey && this.plagiarismPendingIds[articleKey]);
},
plagiarismSimilarityNumber(item) {
const n = this.plagiarismSimilarityRaw(item);
if (n == null || n === '') return 0;
const x = Number(n);
return isNaN(x) ? 0 : Math.round(x * 10) / 10;
},
plagiarismSimilarityRaw(item) {
if (!item) return null;
if (item.plagiarism_similarity != null && item.plagiarism_similarity !== '') return item.plagiarism_similarity;
if (item.crossref_similarity != null && item.crossref_similarity !== '') return item.crossref_similarity;
const st = String(item.plagiarism_job_state || item.crossref_status || '').toLowerCase();
if (['completed', 'done', 'success', 'complete'].includes(st) && (item.plagiarism_similarity === 0 || item.plagiarism_similarity === '0')) {
return 0;
}
const rep = item.repetition;
if (rep != null && rep !== '' && Number(rep) > 0) return rep;
return null;
},
plagiarismUiShowProcessing(item) {
const key = this._plagiarismArticleKey(item);
if (!key) return false;
if (item._plagiarismLocalLoading) return true;
if (this._plagiarismPending(key)) return true;
const st = String(item.plagiarism_job_state || item.crossref_status || '').toLowerCase();
return ['pending', 'processing', 'queued', 'running', 'submitted'].includes(st);
},
plagiarismUiShowResult(item) {
if (this.plagiarismUiShowProcessing(item)) return false;
const st = String(item.plagiarism_job_state || item.crossref_status || '').toLowerCase();
const done = ['completed', 'done', 'success', 'complete'].includes(st);
const raw = this.plagiarismSimilarityRaw(item);
if (raw != null && raw !== '' && !isNaN(Number(raw))) {
if (Number(raw) === 0 && !done && !(item.plagiarism_report_url || item.crossref_report_url)) {
return false;
}
return true;
}
if (done && (item.plagiarism_similarity === 0 || item.plagiarism_similarity === '0')) return true;
return !!(item.plagiarism_report_url || item.crossref_report_url);
},
plagiarismShowRecheck(item) {
if (this.plagiarismUiShowProcessing(item)) return false;
return Number(item.state) === 6;
},
initPlagiarismFromList() {
if (!this.tableData || !this.tableData.length) {
this.reconcilePlagiarismPollTimer();
return;
}
this.tableData.forEach((row) => {
if (row.crossref_report_url && !row.plagiarism_report_url) {
this.$set(row, 'plagiarism_report_url', row.crossref_report_url);
}
if (row.crossref_similarity != null && row.plagiarism_similarity == null) {
this.$set(row, 'plagiarism_similarity', row.crossref_similarity);
}
if (row.crossref_status && !row.plagiarism_job_state) {
this.$set(row, 'plagiarism_job_state', row.crossref_status);
}
const key = this._plagiarismArticleKey(row);
const st = String(row.plagiarism_job_state || '').toLowerCase();
if (key && ['pending', 'processing', 'queued', 'running', 'submitted'].includes(st)) {
this.$set(this.plagiarismPendingIds, key, true);
}
});
this.reconcilePlagiarismPollTimer();
},
reconcilePlagiarismPollTimer() {
const hasPending = Object.keys(this.plagiarismPendingIds).some((k) => this.plagiarismPendingIds[k]);
if (hasPending) {
if (!this.plagiarismPollTimer) {
this.pollPlagiarismPendingOnce();
this.plagiarismPollTimer = setInterval(() => this.pollPlagiarismPendingOnce(), 60000);
}
} else if (this.plagiarismPollTimer) {
clearInterval(this.plagiarismPollTimer);
this.plagiarismPollTimer = null;
}
},
stopPlagiarismPolling() {
if (this.plagiarismPollTimer) {
clearInterval(this.plagiarismPollTimer);
this.plagiarismPollTimer = null;
}
this.plagiarismPendingIds = {};
},
pollPlagiarismPendingOnce() {
Object.keys(this.plagiarismPendingIds).forEach((key) => {
if (this.plagiarismPendingIds[key]) this.fetchPlagiarismStatusByArticleId(key);
});
},
_normalizePlagiarismStatusPayload(body) {
const root = body && typeof body === 'object' ? body : {};
if (root.code != null && Number(root.code) !== 0) {
return { status: 'api_error', similarity: null, reportUrl: '' };
}
let d = root.data != null ? root.data : root;
if (typeof d === 'string') {
try {
d = JSON.parse(d);
} catch (e) {
d = {};
}
}
if (!d || typeof d !== 'object') d = {};
const statusRaw = d.status || d.state || d.job_status || d.plagiarism_status || '';
const status = String(statusRaw).toLowerCase();
let sim =
d.similarity != null
? d.similarity
: d.percent != null
? d.percent
: d.similarity_percent != null
? d.similarity_percent
: d.crossref_similarity != null
? d.crossref_similarity
: null;
if (sim === '' || sim === undefined) sim = null;
const reportUrl = d.report_url || d.reportUrl || d.url || d.report_link || '';
return { status, similarity: sim, reportUrl: String(reportUrl || '') };
},
findTableRowByArticleId(articleId) {
const id = String(articleId);
return (this.tableData || []).find((r) => String(r.article_id) === id) || null;
},
async fetchPlagiarismStatusByArticleId(articleId) {
const key = String(articleId);
try {
const res = await axios.get('/api/plagiarism/status', { params: { article_id: key } });
const body = res && res.data;
const norm = this._normalizePlagiarismStatusPayload(body);
if (norm.status === 'api_error') {
this.$set(this.plagiarismPendingIds, key, false);
this.reconcilePlagiarismPollTimer();
return;
}
const row = this.findTableRowByArticleId(key);
if (row) {
if (norm.similarity != null && norm.similarity !== '') {
this.$set(row, 'plagiarism_similarity', norm.similarity);
}
if (norm.reportUrl) this.$set(row, 'plagiarism_report_url', norm.reportUrl);
if (norm.status) this.$set(row, 'plagiarism_job_state', norm.status);
}
const active = ['pending', 'processing', 'queued', 'running', 'submitted'];
const terminal = ['completed', 'done', 'success', 'complete', 'failed', 'error', 'fail', 'cancelled'];
const isActive = active.includes(norm.status);
let clearPending =
terminal.includes(norm.status) ||
norm.status === 'error' ||
(!isActive && norm.reportUrl && norm.similarity != null && norm.similarity !== '');
if (clearPending) {
this.$set(this.plagiarismPendingIds, key, false);
}
this.reconcilePlagiarismPollTimer();
} catch (e) {
console.error(e);
this.$message.error(this.$t('articleListEditor.plagiarismStatusFailed'));
}
},
async triggerCrossrefPlagiarismCheck(item) {
const key = this._plagiarismArticleKey(item);
if (!key) return;
if (this.plagiarismUiShowProcessing(item)) return;
this.$set(item, '_plagiarismLocalLoading', true);
try {
const res = await this.$api.post('api/plagiarism/check', { article_id: key });
if (res && Number(res.code) === 0) {
this.$set(this.plagiarismPendingIds, key, true);
this.$set(item, 'plagiarism_job_state', 'pending');
this.reconcilePlagiarismPollTimer();
await this.fetchPlagiarismStatusByArticleId(key);
} else {
this.$message.error((res && res.msg) || this.$t('articleListEditor.plagiarismCheckFailed'));
}
} catch (e) {
console.error(e);
this.$message.error(this.$t('articleListEditor.plagiarismCheckFailed'));
} finally {
this.$set(item, '_plagiarismLocalLoading', false);
}
},
async openPlagiarismReport(item) {
const articleId = item && item.article_id;
if (!articleId) {
this.$message.warning(this.$t('articleListEditor.plagiarismNoReportUrl'));
return;
}
let raw = '';
try {
const res = await this.$api.post('api/Article/getArticleDetail', {
articleId,
human: 'editor'
});
console.log("🚀 ~ openPlagiarismReport ~ res:", res);
const a = res && res.article;
if (a) {
raw = String(
a.repeurl || ''
).trim();
}
} catch (e) {
console.error(e);
this.$message.error(this.$t('articleListEditor.plagiarismReportDetailFailed'));
return;
}
const url = String(raw).trim();
if (!url) {
this.$message.warning(this.$t('articleListEditor.plagiarismNoReportUrl'));
return;
}
let full = url;
if (!/^https?:\/\//i.test(url)) {
const base = (this.mediaUrl || '').replace(/\/+$/, '');
const path = url.replace(/^\/+/, '');
full = base ? `${base}/${path}` : `/${path}`;
}
window.open(full, '_blank');
},
//文章类型
@@ -3426,4 +3689,65 @@ td {
border-bottom: 1px solid #f0f0f0;
/* box-shadow: 0 2px 8px rgba(0,0,0,0.1); */
}
/* Crossref plagiarism strip (blue header bar) */
.plagiarism-header-bar {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
margin-right: 4px;
max-width: 48%;
justify-content: flex-end;
}
.plagiarism-on-blue {
vertical-align: middle;
}
.articleTopBaseInfo .plagiarism-inline-loading {
color: #e6f4ff;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 8px;
}
.articleTopBaseInfo .plagiarism-inline-loading .el-icon-loading {
font-size: 14px;
}
.articleTopBaseInfo .plagiarism-pct-text {
cursor: pointer;
margin-right: 4px;
font-size: 13px;
font-weight: 800;
color: #fff;
background: none !important;
border: none !important;
padding: 0;
line-height: 1.2;
}
.articleTopBaseInfo .plagiarism-pct-text:hover {
filter: brightness(1.08);
}
.articleTopBaseInfo .plagiarism-not-checked-text {
cursor: pointer;
margin-right: 4px;
font-size: 13px;
font-weight: 600;
color: #e6f4ff;
text-decoration: underline;
text-underline-offset: 2px;
}
.articleTopBaseInfo .plagiarism-not-checked-text:hover {
color: #fff;
}
.articleTopBaseInfo .plagiarism-recheck-btn {
color: #e6f4ff !important;
padding: 0 4px !important;
font-weight: 600;
}
.articleTopBaseInfo .plagiarism-recheck-btn:hover {
color: #fff !important;
text-decoration: underline;
}
</style>

View File

@@ -5,6 +5,29 @@
<h1 class="mail-subject-top">{{ $t('mailboxCollect.subject') }}{{ mailData.subject }}</h1>
</div>
<div class="toolbar-right">
<el-button
v-if="hasWordAttachment"
type="primary"
size="small"
plain
icon="el-icon-upload2"
:loading="registerAuthorLoading"
class="register-author-btn"
@click="registerAuthorFromMail"
>
{{ $t('mailboxCollect.registerAuthorBtn') }}
</el-button>
<!-- <el-button
v-if="resolvedJournalId"
type="success"
size="small"
plain
icon="el-icon-upload2"
class="register-author-btn"
@click="openAutoSubmitDialog"
>
{{ $t('mailboxCollect.autoSubmitBtn') }}
</el-button> -->
<!-- <i class="el-icon-star-off action-icon"></i> -->
<i class="el-icon-close action-icon" @click="$emit('close')"></i>
</div>
@@ -118,15 +141,98 @@
ref="previewDialog"
@download="downloadAttachment"
/>
<el-dialog
:title="
autoSubmitSuccessMode
? $t('mailboxCollect.autoSubmitSuccessTitle')
: $t('mailboxCollect.autoSubmitDialogTitle')
"
:visible.sync="autoSubmitDialogVisible"
width="580px"
append-to-body
@close="onAutoSubmitDialogClose"
>
<div v-show="!autoSubmitSuccessMode">
<el-alert
v-if="autoSubmitPrefillFromRegisterConflict"
type="warning"
:closable="false"
show-icon
class="auto-submit-existing-tip"
:title="$t('mailboxCollect.autoSubmitExistingAccountTip')"
/>
<el-form ref="autoSubmitFormRef" :model="autoSubmitForm" label-width="120px" class="auto-submit-form">
<el-form-item :label="$t('mailboxCollect.autoSubmitSenderEmailLabel')">
<el-input
class="auto-submit-email-readonly"
:value="autoSubmitReadonlyEmailDisplay || $t('mailboxCollect.autoSubmitSenderEmailPlaceholder')"
readonly
tabindex="-1"
/>
</el-form-item>
<el-form-item :label="$t('mailboxCollect.autoSubmitPassword')">
<el-input
v-model="autoSubmitForm.password"
type="password"
show-password
autocomplete="current-password"
/>
</el-form-item>
<el-form-item :label="$t('mailboxCollect.autoSubmitManuscriptSource')">
<el-upload
:key="'mail-auto-docx-' + autoSubmitUploadKey"
ref="autoSubmitLocalUpload"
class="auto-submit-local-upload"
action=""
:auto-upload="false"
:limit="1"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
:on-change="onAutoSubmitLocalDocxChange"
:on-remove="onAutoSubmitLocalDocxRemove"
:on-exceed="onAutoSubmitLocalDocxExceed"
>
<el-button size="small" type="default" icon="el-icon-folder-open">{{
$t('mailboxCollect.autoSubmitPickLocalDocx')
}}</el-button>
</el-upload>
<p class="auto-submit-source-hint">{{ $t('mailboxCollect.autoSubmitSourceHint') }}</p>
<p v-if="localDocxDisplayName" class="auto-submit-local-picked">
{{ $t('mailboxCollect.autoSubmitLocalPicked', { name: localDocxDisplayName }) }}
</p>
</el-form-item>
</el-form>
</div>
<div v-show="autoSubmitSuccessMode" class="auto-submit-success-body" v-html="autoSubmitSuccessBodyHtml"></div>
<span slot="footer" class="dialog-footer">
<template v-if="autoSubmitSuccessMode">
<el-button type="primary" @click="autoSubmitDialogVisible = false">{{
$t('mailboxCollect.autoSubmitDialogClose')
}}</el-button>
</template>
<template v-else>
<el-button @click="autoSubmitDialogVisible = false">{{ $t('mailboxCollect.autoSubmitCancel') }}</el-button>
<el-button type="primary" :loading="autoSubmitLoading" @click="submitAutoSubmitManuscript">{{
$t('mailboxCollect.autoSubmitConfirm')
}}</el-button>
</template>
</span>
</el-dialog>
</div>
</template>
<script>
import axios from 'axios';
import Common from '@/components/common/common';
import bus from '@/components/common/bus';
import { normalizeEmailHtmlForInlineDisplay } from '@/utils/emailHtmlView';
import { runMailManuscriptPipeline, clearAuthorShadowSession } from '@/utils/mailManuscriptAutoSubmit';
import JSZip from 'jszip';
import { saveAs } from 'file-saver';
import FilePreviewDialog from './FilePreviewDialog.vue';
/** 投稿页对外链接域名(邮件与成功通知统一用线上地址,避免本地 origin */
const SUBMISSION_SITE_ORIGIN = 'https://submission.tmrjournals.com';
export default {
components: {
FilePreviewDialog
@@ -145,6 +251,20 @@ export default {
content: '',
attachments: []
})
},
/** 当前邮件列表所选邮箱账号的期刊 ID与 mailboxCollect 一致) */
journalId: {
type: [Number, String],
default: null
},
journalTitle: {
type: String,
default: ''
},
/** 当前邮箱账号 j_email_id与 mailboxSend 发信参数一致 */
jEmailId: {
type: [Number, String],
default: null
}
},
data() {
@@ -152,15 +272,100 @@ export default {
mediaUrl: Common.mediaUrl,
isDetailExpanded: false,
downloadingIndex: -1,
packingAll: false
packingAll: false,
registerAuthorLoading: false,
autoSubmitDialogVisible: false,
autoSubmitLoading: false,
/** 建稿成功后在同一弹窗内展示摘要,仅保留关闭 */
autoSubmitSuccessMode: false,
autoSubmitSuccessBodyHtml: '',
autoSubmitForm: {
username: '',
password: ''
},
/** 弹窗内本机选择的 .docx优先于邮件附件下载 */
autoSubmitLocalDocxFile: null,
/** 因「邮箱/账号已存在」打开建稿弹窗时展示提示条 */
autoSubmitPrefillFromRegisterConflict: false,
/** 本地上传组件强制重建 key超限选新文件时替换 */
autoSubmitUploadKey: 0
};
},
computed: {
/** 存在 Word 附件(.doc / .docx时显示「创建作者账号」 */
hasWordAttachment() {
const att = (this.mailData && this.mailData.attachments) || [];
if (!att.length) return false;
return att.some((f) => {
const n = (f && f.name) || '';
return /\.(doc|docx)$/i.test(n);
});
},
/** 新增稿件上传链路仅接受 .docx */
hasDocxAttachment() {
const att = (this.mailData && this.mailData.attachments) || [];
if (!att.length) return false;
return att.some((f) => {
const n = (f && f.name) || '';
return /\.docx$/i.test(n);
});
},
resolvedJournalId() {
if (this.journalId != null && String(this.journalId).trim() !== '') {
const n = Number(this.journalId);
if (!Number.isNaN(n) && n > 0) return n;
}
try {
const s = localStorage.getItem('mailboxCollect_journal_id');
if (s != null && String(s).trim() !== '') {
const n = Number(s);
if (!Number.isNaN(n) && n > 0) return n;
}
} catch (e) {
/* ignore */
}
return null;
},
resolvedJournalLabel() {
const id = this.resolvedJournalId;
if (id == null) return this.$t('mailboxCollect.autoSubmitJournalUnknown');
const t = this.journalTitle && String(this.journalTitle).trim();
return t ? `${t}ID: ${id}` : `ID: ${id}`;
},
/** 发件接口 sendOne 所需 j_email_id */
resolvedJEmailId() {
if (this.jEmailId != null && String(this.jEmailId).trim() !== '') {
return String(this.jEmailId).trim();
}
try {
const s = localStorage.getItem('mailboxCollect_j_email_id');
if (s != null && String(s).trim() !== '') return String(s).trim();
} catch (e) {
/* ignore */
}
return null;
},
/** 通知邮件正文中的期刊名称不含「ID:」后缀) */
resolvedNotifyJournalName() {
const t = this.journalTitle && String(this.journalTitle).trim();
if (t) return t;
const id = this.resolvedJournalId;
return id != null ? `ID ${id}` : this.$t('mailboxCollect.autoSubmitJournalUnknown');
},
totalAttachmentSize() {
if (!this.mailData.attachments || !this.mailData.attachments.length) return '0B';
const total = this.mailData.attachments.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
return this.formatFileSize(total);
},
localDocxDisplayName() {
const f = this.autoSubmitLocalDocxFile;
if (!f || !f.name) return '';
return f.name;
},
/** 弹窗内只读展示:当前邮件发件人邮箱 */
autoSubmitReadonlyEmailDisplay() {
return this.getPrimaryMailSenderEmail() || '';
},
/** 正文:兼容 content_html / body / html纯文本时包一层 pre */
mailBodyHtml() {
const m = this.mailData || {};
@@ -175,6 +380,173 @@ export default {
}
},
methods: {
stripHtml(html) {
if (html == null || html === '') return '';
const s = String(html);
const d = typeof document !== 'undefined' ? document.createElement('div') : null;
if (d) {
d.innerHTML = s;
return (d.textContent || d.innerText || '').trim();
}
return s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
},
/** 与 partyList 添加用户一致:从正文尝试提取手机号(国内 11 位或含 + 的国际号) */
extractPhoneFromMailBody() {
const m = this.mailData || {};
let blob = '';
['content_text', 'content', 'body', 'content_html', 'html', 'body_html'].forEach((k) => {
const v = m[k];
if (v != null && String(v).trim() !== '') blob += `\n${String(v)}`;
});
const text = this.stripHtml(blob);
const cn = text.match(/(?:^|\D)(1[3-9]\d{9})(?:\D|$)/);
if (cn) return cn[1];
const intl = text.match(/\+\d{1,3}[\s\-]?\d[\d\s\-]{8,18}\d/);
if (intl) return intl[0].replace(/\s+/g, ' ').trim().slice(0, 32);
const labeled = text.match(/(?:Tel|Phone|Mobile|MW)[\s:]*([+()\d][\d\s\-().]{10,40})/i);
if (labeled) return labeled[1].trim().slice(0, 32);
return '';
},
/** 发件人显示名(用于 realname与 partyList 校验规则一致 */
deriveRealnameFromMail() {
const m = this.mailData || {};
const fromEmail = String(m.from_email || '')
.trim()
.toLowerCase();
const localPart = fromEmail.split('@')[0] || 'user';
let rawName = String(m.from_name || '').trim();
let realname = rawName || localPart;
if (this.$validateString && !this.$validateString(realname)) {
realname = localPart;
if (this.$validateString && !this.$validateString(realname)) {
realname = 'Author';
}
}
return realname;
},
/** 当前邮件发件人邮箱(来稿真实地址),用于注册作者与通知收件人 */
getPrimaryMailSenderEmail() {
const raw = this.mailData && this.mailData.from_email != null ? String(this.mailData.from_email).trim() : '';
if (!raw) return '';
const lower = raw.toLowerCase();
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) return lower;
return '';
},
/** 由邮箱推导登录名(与 checkUserByAccount / addUser 常用规则一致) */
deriveLoginAccountFromEmail(email) {
const e = String(email || '').toLowerCase().trim();
const at = e.indexOf('@');
if (at < 1) return 'author';
let acc = e
.slice(0, at)
.replace(/[^a-z0-9_]/gi, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
if (!acc || acc.length < 2) {
acc = 'u_' + e.replace(/[^\w]/g, '').slice(0, 24);
}
if (!acc || acc.length < 2) {
acc = 'author_' + String(Date.now()).slice(-8);
}
if (acc.length > 32) acc = acc.slice(0, 32);
return acc;
},
openAutoSubmitDialogWithCredentialsPrefill({ usernameHint }) {
if (this.resolvedJournalId == null) {
this.$message.warning(this.$t('mailboxCollect.autoSubmitNeedJournal'));
return;
}
this.autoSubmitPrefillFromRegisterConflict = true;
this.autoSubmitSuccessMode = false;
this.autoSubmitSuccessBodyHtml = '';
this.autoSubmitForm.username = usernameHint != null ? String(usernameHint).trim() : '';
this.autoSubmitForm.password = '';
this.autoSubmitDialogVisible = true;
},
/** addUser 返回「邮箱已存在」类文案时改走手动录入 */
isRegisterDuplicateResponse(res, msg) {
const s = String(msg || '');
const lower = s.toLowerCase();
if (lower.includes('already registered') || lower.includes('email is already')) return true;
if (s.includes('已注册') && (s.includes('邮箱') || s.includes('邮件'))) return true;
const hint = String(this.$t('mailboxCollect.registerAuthorExistsEmail') || '').toLowerCase();
if (hint && lower.includes(hint.slice(0, 12))) return true;
return false;
},
async registerAuthorFromMail() {
if (!this.hasWordAttachment) return;
const realname = this.deriveRealnameFromMail();
const email2 = this.getPrimaryMailSenderEmail();
if (!email2) {
this.$message.error(this.$t('mailboxCollect.registerAuthorNeedEmail'));
return;
}
const account2 = this.deriveLoginAccountFromEmail(email2);
this.registerAuthorLoading = true;
try {
const r1 = await this.$api.post('api/User/checkUserByEmail', { email: email2, account: account2 });
const r2 = await this.$api.post('api/User/checkUserByAccount', { email: email2, account: account2 });
if (!(r1 && r2 && Number(r1.code) === 0 && Number(r2.code) === 0)) {
this.$message.error((r1 && r1.msg) || (r2 && r2.msg) || this.$t('mailboxCollect.registerAuthorFail'));
return;
}
if (r1.data && Number(r1.data.has) !== 0) {
this.openAutoSubmitDialogWithCredentialsPrefill({ usernameHint: email2 });
return;
}
if (r2.data && Number(r2.data.has) !== 0) {
this.openAutoSubmitDialogWithCredentialsPrefill({ usernameHint: account2 });
return;
}
} catch (err) {
console.error(err);
this.$message.error(this.$t('mailboxCollect.registerAuthorFail'));
return;
} finally {
this.registerAuthorLoading = false;
}
const pwd = '123456qwe';
this.$notify({
title: this.$t('mailboxCollect.registerAuthorBtn'),
message: this.$t('mailboxCollect.registerAuthorConfirmShort', { email: email2, password: pwd }),
type: 'info',
duration: 8000
});
this.registerAuthorLoading = true;
try {
const phone2 = this.extractPhoneFromMailBody() || '';
const addForm = {
email: email2,
account: account2,
password: pwd,
repassword: pwd,
realname,
phone: phone2 || ''
};
const res = await this.$api.post('api/User/addUser', addForm);
if (res && Number(res.code) === 0) {
this.autoSubmitForm.username = account2;
this.autoSubmitForm.password = pwd;
this.$nextTick(() => {
if (this.resolvedJournalId != null) {
this.openAutoSubmitDialog();
}
});
} else {
const msg = (res && res.msg) || '';
if (this.isRegisterDuplicateResponse(res, msg)) {
this.openAutoSubmitDialogWithCredentialsPrefill({ usernameHint: email2 });
} else {
this.$message.error(msg || this.$t('mailboxCollect.registerAuthorFail'));
}
}
} catch (err) {
console.error(err);
this.$message.error(this.$t('mailboxCollect.registerAuthorFail'));
} finally {
this.registerAuthorLoading = false;
}
},
escapeHtml(text) {
if (text == null) return '';
return String(text)
@@ -183,6 +555,297 @@ export default {
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
},
buildAbsoluteArticleAddUrl(articleId) {
const id = articleId == null ? '' : String(articleId);
const base = SUBMISSION_SITE_ORIGIN.replace(/\/+$/, '');
return `${base}/articleAdd?id=${encodeURIComponent(id)}`;
},
buildAuthorNotifyMailHtml(articleUrl, username, passwordPlain) {
const j = this.escapeHtml(this.resolvedNotifyJournalName);
const a = this.escapeHtml(username);
const p = this.escapeHtml(passwordPlain);
const href = this.escapeHtml(articleUrl);
const link = `<a href="${href}" target="_blank" rel="noopener noreferrer">${href}</a>`;
const loc = (this.$i18n && this.$i18n.locale) || '';
if (String(loc).toLowerCase().startsWith('en')) {
return `<p>I am an editor of <strong>${j}</strong>. <br/>Thank you for your submission. An author account has been created for you: username <strong>${a}</strong>, default password <strong>${p}</strong>. <br/>Please complete your manuscript here: ${link}</p>`;
}
return `<p>我是《${j}》的编辑,很高兴收到你的投稿。<br/>已帮您创建好了投稿账号,登录名为 <strong>${a}</strong>,默认密码为 <strong>${p}</strong>。<br/>请您进一步完善您的稿件,投稿地址:${link}</p>`;
},
async getAutoSubmitNotifyMailResult({ articleUrl, username, passwordPlain }) {
const jEmailId = this.resolvedJEmailId;
const journalId = this.resolvedJournalId;
if (!jEmailId || journalId == null) {
return 'skipped_config';
}
const toEmail = this.getPrimaryMailSenderEmail();
if (!toEmail) {
return 'skipped_recipient';
}
try {
const journalName = this.resolvedNotifyJournalName;
const subject = this.$t('mailboxCollect.autoSubmitNotifyMailSubject', { journal: journalName });
const content = this.buildAuthorNotifyMailHtml(articleUrl, username, passwordPlain);
const res = await this.$api.post('api/email_client/sendOne', {
to_email: toEmail,
subject: String(subject || '').trim(),
content,
j_email_id: String(jEmailId),
journal_id: String(journalId)
});
if (!res || Number(res.code) !== 0) {
return 'failed';
}
return 'sent';
} catch (e) {
console.error(e);
return 'failed';
}
},
buildAutoSubmitSuccessDialogHtml({ username, passwordPlain, articleId, articleUrl, mailResult }) {
const href = this.escapeHtml(articleUrl);
const linkHtml = `<a href="${href}" target="_blank" rel="noopener noreferrer">${href}</a>`;
const line1 = this.escapeHtml(
this.$t('mailboxCollect.autoSubmitSuccessLineAccount', { account: username })
);
const line2 = this.escapeHtml(
this.$t('mailboxCollect.autoSubmitSuccessLinePassword', { password: passwordPlain })
);
const line3 = this.escapeHtml(
this.$t('mailboxCollect.autoSubmitSuccessLineDraft', { id: String(articleId == null ? '' : articleId) })
);
const linkPrefix = this.escapeHtml(this.$t('mailboxCollect.autoSubmitSuccessLineLinkPrefix'));
let line5;
if (mailResult === 'sent') {
line5 = `<p class="auto-submit-success-mail-sent">${this.escapeHtml(
this.$t('mailboxCollect.autoSubmitSuccessMailSent')
)}</p>`;
} else if (mailResult === 'skipped_config') {
line5 = `<p class="auto-submit-success-mail-muted">${this.escapeHtml(
this.$t('mailboxCollect.autoSubmitSuccessMailSkipped')
)}</p>`;
} else if (mailResult === 'skipped_recipient') {
line5 = `<p class="auto-submit-success-mail-muted">${this.escapeHtml(
this.$t('mailboxCollect.autoSubmitSuccessMailSkippedRecipient')
)}</p>`;
} else {
line5 = `<p class="auto-submit-success-mail-muted">${this.escapeHtml(
this.$t('mailboxCollect.autoSubmitSuccessMailFailed')
)}</p>`;
}
return `<div class="auto-submit-success-lines"><p>${line1}</p><p>${line2}</p><p>${line3}</p><p>${linkPrefix}${linkHtml}</p>${line5}</div>`;
},
openAutoSubmitDialog() {
if (this.resolvedJournalId == null) {
this.$message.warning(this.$t('mailboxCollect.autoSubmitNeedJournal'));
return;
}
this.autoSubmitPrefillFromRegisterConflict = false;
this.autoSubmitSuccessMode = false;
this.autoSubmitSuccessBodyHtml = '';
this.autoSubmitDialogVisible = true;
},
onAutoSubmitLocalDocxChange(file, fileList) {
const ref = this.$refs.autoSubmitLocalUpload;
if (ref && fileList && fileList.length > 1) {
try {
ref.handleRemove(fileList[0]);
} catch (e) {
/* ignore */
}
return;
}
const raw = file && file.raw;
if (!raw) return;
const name = raw.name || '';
if (!/\.docx$/i.test(name)) {
this.$message.warning(this.$t('mailboxCollect.autoSubmitNoDocx'));
this.$nextTick(() => {
if (this.$refs.autoSubmitLocalUpload) {
this.$refs.autoSubmitLocalUpload.clearFiles();
}
});
return;
}
this.autoSubmitLocalDocxFile = raw;
},
onAutoSubmitLocalDocxExceed(files) {
const wrap = files && files[0];
const raw = wrap && wrap.raw;
if (!raw || !/\.docx$/i.test(raw.name || '')) {
this.$message.warning(this.$t('mailboxCollect.autoSubmitNoDocx'));
return;
}
if (this.$refs.autoSubmitLocalUpload) {
this.$refs.autoSubmitLocalUpload.clearFiles();
}
this.autoSubmitLocalDocxFile = raw;
this.autoSubmitUploadKey += 1;
},
onAutoSubmitLocalDocxRemove() {
this.autoSubmitLocalDocxFile = null;
},
onAutoSubmitDialogClose() {
clearAuthorShadowSession();
this.autoSubmitPrefillFromRegisterConflict = false;
this.autoSubmitSuccessMode = false;
this.autoSubmitSuccessBodyHtml = '';
this.autoSubmitLocalDocxFile = null;
this.autoSubmitUploadKey = 0;
this.$nextTick(() => {
if (this.$refs.autoSubmitLocalUpload) {
this.$refs.autoSubmitLocalUpload.clearFiles();
}
if (this.$refs.autoSubmitFormRef) {
this.$refs.autoSubmitFormRef.clearValidate();
}
});
},
/**
* 从邮件附件公网路径拉取 docxaxios blob + withCredentials再走 manuscirpt 上传。
*/
async fetchMailDocxBlobFromAttachment(mailFile) {
const rel = await this.ensureFileUrl(mailFile);
if (!rel) {
throw new Error('NO_ATTACHMENT_URL');
}
const url = this.resolvePublicFileUrl(rel);
const res = await axios.get(url, {
responseType: 'blob',
withCredentials: true,
timeout: 180000
});
if (!res || res.status < 200 || res.status >= 300 || !res.data) {
throw new Error('DOWNLOAD_HTTP');
}
const blob = res.data;
if (!(blob instanceof Blob) || blob.size === 0) {
throw new Error('EMPTY_BLOB');
}
return blob;
},
getFirstDocxAttachment() {
const att = (this.mailData && this.mailData.attachments) || [];
for (let i = 0; i < att.length; i++) {
const f = att[i];
const n = (f && f.name) || '';
if (/\.docx$/i.test(n)) {
return f;
}
}
return null;
},
submitAutoSubmitManuscript() {
let username = String((this.autoSubmitForm && this.autoSubmitForm.username) || '').trim();
if (!username) {
const em = this.getPrimaryMailSenderEmail();
if (em) username = em;
}
if (!username) {
this.$message.warning(this.$t('mailboxCollect.registerAuthorNeedEmail'));
return;
}
this.autoSubmitForm.username = username;
if (!(this.autoSubmitForm && this.autoSubmitForm.password)) {
this.$message.warning(this.$t('mailboxCollect.autoSubmitPasswordRequired'));
return;
}
if (this.resolvedJournalId == null) {
this.$message.warning(this.$t('mailboxCollect.autoSubmitNeedJournal'));
return;
}
const mailDocx = this.getFirstDocxAttachment();
const localFile = this.autoSubmitLocalDocxFile;
if (!localFile && !mailDocx) {
this.$message.warning(this.$t('mailboxCollect.autoSubmitNeedDocxSource'));
return;
}
this.autoSubmitLoading = true;
(async () => {
try {
const docxMime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
let blob;
let fileName;
if (localFile) {
blob = localFile;
fileName = localFile.name || 'manuscript.docx';
} else {
blob = await this.fetchMailDocxBlobFromAttachment(mailDocx);
fileName = mailDocx.name || 'manuscript.docx';
}
const r = await runMailManuscriptPipeline({
api: this.$api,
baseUrl: Common.baseUrl,
preserveEditorLocalStorage: true,
journal_id: this.resolvedJournalId,
extractWordTables: (consumer) =>
new Promise((resolve, reject) => {
const f = new File([blob], fileName, { type: docxMime });
this.$commonJS.extractWordTablesToArrays(f, async (wordTables) => {
try {
await consumer(wordTables || []);
} catch (e) {
reject(e);
return;
}
resolve();
});
}),
login: {
username,
password: this.autoSubmitForm.password,
code: ''
},
blob,
fileName
});
if (r.ok) {
bus.$emit('editorSessionLocalRestored');
const link = this.buildAbsoluteArticleAddUrl(r.article_id);
const passwordPlain = this.autoSubmitForm.password;
const mailResult = await this.getAutoSubmitNotifyMailResult({
articleUrl: link,
username,
passwordPlain
});
if (mailResult === 'failed') {
this.$message.warning(this.$t('mailboxCollect.autoSubmitNotifyMailFail'));
}
this.autoSubmitSuccessBodyHtml = this.buildAutoSubmitSuccessDialogHtml({
username,
passwordPlain,
articleId: r.article_id,
articleUrl: link,
mailResult
});
this.autoSubmitSuccessMode = true;
} else {
const base = r.msg || this.$t('mailboxCollect.autoSubmitFail');
const tail =
r.article_id != null
? ' ' + this.$t('mailboxCollect.autoSubmitFailPartial', { id: r.article_id })
: '';
this.$message.error(base + tail);
}
} catch (err) {
console.error(err);
const msg = (err && err.message) || '';
const httpErr = err && err.response && err.response.status;
if (
httpErr ||
msg === 'NO_ATTACHMENT_URL' ||
msg === 'DOWNLOAD_HTTP' ||
msg === 'EMPTY_BLOB'
) {
this.$message.error(this.$t('mailboxCollect.autoSubmitDownloadFail'));
} else {
this.$message.error(this.$t('mailboxCollect.autoSubmitFail'));
}
} finally {
this.autoSubmitLoading = false;
}
})();
},
scrollToAttachments() {
// 1. 获取目标元素的 DOM
const target = this.$el.querySelector('.attachment-section');
@@ -234,6 +897,22 @@ export default {
}
return '';
},
/**
* 将 Common.mediaUrl 与后端返回的 file_url 拼成可访问 URL。
* file_url 常为 /attachments/…mediaUrl 常为 …/public/,直接拼接会得到 …/public//attachments/… 导致 404。
* 稿件上传仍走 up_file/type/manuscirpt此处仅用于先把附件下载为 Blob。
*/
resolvePublicFileUrl(pathOrUrl) {
const p = pathOrUrl == null ? '' : String(pathOrUrl).trim();
if (!p) return '';
if (/^https?:\/\//i.test(p)) return p;
let base = String(this.mediaUrl || '').replace(/\/+$/, '');
let path = p.replace(/^\/+/, '');
if (/\/public$/i.test(base) && /^public\//i.test(path)) {
path = path.replace(/^public\//i, '');
}
return `${base}/${path}`;
},
async downloadAll() {
if (this.packingAll) return;
this.packingAll = true;
@@ -242,7 +921,7 @@ export default {
const promises = this.mailData.attachments.map(async (file) => {
const url = await this.ensureFileUrl(file);
if (!url) return;
const fileUrl = this.mediaUrl + url;
const fileUrl = this.resolvePublicFileUrl(url);
const resp = await fetch(fileUrl);
const blob = await resp.blob();
zip.file(file.name || 'attachment', blob);
@@ -261,7 +940,7 @@ export default {
console.log("🚀 ~ previewAttachment ~ file:", file);
var that = this;
that.$refs.previewDialog.init(file, this.mediaUrl+file.file_url || '');
that.$refs.previewDialog.init(file, this.resolvePublicFileUrl(file.file_url) || '');
// 1. 获取后端返回的 URL
const res = await this.$api.post('api/email_client/getAttachment', {
inbox_id: String(this.mailData.inbox_id),
@@ -269,7 +948,7 @@ const res = await this.$api.post('api/email_client/getAttachment', {
});
if (res.data && res.data.file_url) {
const fullUrl = this.mediaUrl + res.data.file_url;
const fullUrl = this.resolvePublicFileUrl(res.data.file_url);
// 2. 调用弹窗组件的 init 方法
// 注意:微软预览要求 fullUrl 必须是公网可访问的!
@@ -287,7 +966,7 @@ const res = await this.$api.post('api/email_client/getAttachment', {
this.$message.warning(this.$t('mailboxCollect.downloadFail') || '下载失败');
return;
}
const fileUrl = this.mediaUrl + url;
const fileUrl = this.resolvePublicFileUrl(url);
const fileName = file.name || 'attachment';
const resp = await fetch(fileUrl);
const blob = await resp.blob();
@@ -383,6 +1062,72 @@ const res = await this.$api.post('api/email_client/getAttachment', {
color: #606266;
cursor: pointer;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.register-author-btn {
margin-right: 8px;
}
.auto-submit-email-readonly /deep/ .el-input__inner {
color: #606266;
cursor: default;
background-color: #f5f7fa;
border-color: #e4e7ed;
}
.auto-submit-existing-tip {
margin-bottom: 12px;
}
.auto-submit-local-picked {
margin: 6px 0 0;
font-size: 13px;
color: #67c23a;
word-break: break-all;
}
.auto-submit-source-hint {
margin: 8px 0 0;
font-size: 12px;
color: #909399;
line-height: 1.45;
}
.auto-submit-success-body {
font-size: 14px;
color: #606266;
line-height: 1.65;
word-break: break-word;
}
.auto-submit-success-body p {
margin: 0;
}
.auto-submit-success-body a {
color: #409eff;
}
.auto-submit-success-lines p {
margin: 0 0 10px;
line-height: 1.55;
}
.auto-submit-success-lines p:last-child {
margin-bottom: 0;
}
.auto-submit-success-mail-sent {
color: #67c23a;
font-weight: 500;
}
.auto-submit-success-mail-muted {
color: #e6a23c;
font-size: 13px;
}
.auto-submit-local-upload {
display: inline-block;
}
.auto-submit-journal-readonly {
color: #606266;
font-size: 13px;
line-height: 1.5;
word-break: break-all;
}
.action-icon:hover {
color: #409eff;
}
@@ -665,7 +1410,9 @@ const res = await this.$api.post('api/email_client/getAttachment', {
}
.attachment-brief-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 8px 0;
font-size: 13px;
color: #606266;
@@ -674,14 +1421,17 @@ const res = await this.$api.post('api/email_client/getAttachment', {
}
.attachment-brief-bar .brief-info {
}
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.attachment-brief-bar .first-file-name {
color: #909399;
margin-left: 4px;
}
.jump-link {
margin-left: 15px;
margin-left: 0;
font-size: 13px;
}
.attachment-section {

View File

@@ -112,6 +112,9 @@
<mail-detail
v-if="detailMail && String(detailMail.inbox_id || '') !== ''"
:mailData="detailMail"
:journal-id="mailDetailJournalId"
:journal-title="mailDetailJournalTitle"
:j-email-id="mailDetailJEmailId"
@close="closeDetail"
/>
</div>
@@ -220,6 +223,39 @@ import { normalizeEmailHtmlForInlineDisplay } from '@/utils/emailHtmlView';
},
selectedAccountEmail() {
return this.selectedAccount ? this.selectedAccount.smtp_user : '';
},
/** 当前邮箱账号绑定的期刊,供邮件内一键建稿与 articleAdd 回填一致 */
mailDetailJournalId() {
const a = this.selectedAccount;
if (a && a.journal_id != null && String(a.journal_id).trim() !== '') {
return a.journal_id;
}
try {
const s = localStorage.getItem('mailboxCollect_journal_id');
return s != null && String(s).trim() !== '' ? s : null;
} catch (e) {
return null;
}
},
mailDetailJournalTitle() {
const id = this.mailDetailJournalId;
if (id == null) return '';
const list = this.journalList || [];
const j = list.find((x) => String(x.journal_id) === String(id));
return j && j.title ? j.title : '';
},
/** 当前发件/收件箱账号,供 sendOne 与 mailboxSend 一致 */
mailDetailJEmailId() {
const a = this.selectedAccount;
if (a && a.j_email_id != null && String(a.j_email_id).trim() !== '') {
return a.j_email_id;
}
try {
const s = localStorage.getItem('mailboxCollect_j_email_id');
return s != null && String(s).trim() !== '' ? s : null;
} catch (e) {
return null;
}
}
},
created() {

View File

@@ -50,6 +50,9 @@
</el-button>
<div class="right-actions">
<el-button type="warning" plain icon="el-icon-upload2" @click="openTemplateBatchImportDialog">{{
$t('mailboxMould.batchImportBtn')
}}</el-button>
<el-button type="primary" plain icon="el-icon-plus" @click="handleCreateTemplate">{{ $t('mailboxMould.createTemplate') }}</el-button>
</div>
</div>
@@ -136,6 +139,36 @@
<el-button @click="previewVisible = false">{{ $t('mailboxMould.previewClose') }}</el-button>
</span>
</el-dialog>
<el-dialog
:title="$t('mailboxMould.batchImportTitle')"
:visible.sync="batchTplImportVisible"
width="720px"
append-to-body
:close-on-click-modal="false"
custom-class="mailbox-mould-batch-import-dialog"
@closed="batchTplImporting = false"
>
<p class="batch-tpl-hint">{{ $t('mailboxMould.batchImportHint') }}</p>
<p class="batch-tpl-tip">{{ $t('mailboxMould.batchImportCommonTip') }}</p>
<div class="batch-tpl-journal-row">
<span class="batch-tpl-label">{{ $t('mailboxMould.batchImportJournalId') }}</span>
<el-input
v-model="batchTplImportJournalId"
clearable
size="small"
:placeholder="$t('mailboxMould.batchImportJournalPlaceholder')"
class="batch-tpl-journal-input"
/>
</div>
<el-input v-model="batchTplImportText" type="textarea" :rows="14" class="batch-tpl-textarea" spellcheck="false" />
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="batchTplImportVisible = false">{{ $t('mailboxMould.cancel') }}</el-button>
<el-button type="primary" size="small" :loading="batchTplImporting" @click="runTemplateBatchImport">{{
$t('mailboxMould.batchImportRun')
}}</el-button>
</span>
</el-dialog>
</div>
</template>
@@ -145,7 +178,8 @@ const API = {
listStyles: 'api/mail_template/listStyles',
getAllJournal: 'api/Article/getJournal',
deleteTemplate: 'api/mail_template/deleteTemplate',
deleteStyle: 'api/mail_template/deleteStyle'
deleteStyle: 'api/mail_template/deleteStyle',
saveTemplate: 'api/mail_template/saveTemplate'
};
// 仅在当前 SPA 会话内记忆筛选(刷新页面即重置)
const mailboxMouldSessionMemory = {
@@ -177,7 +211,13 @@ export default {
// --- 共用预览 ---
previewVisible: false,
previewContent: ''
previewContent: '',
/** 邮件模板 JSON 批量导入(期刊 ID 单独输入,与每条合并后调 saveTemplate */
batchTplImportVisible: false,
batchTplImportText: '',
batchTplImportJournalId: '',
batchTplImporting: false
};
},
created() {
@@ -281,6 +321,147 @@ export default {
const journalId = this.tplFilters && this.tplFilters.journalId ? String(this.tplFilters.journalId) : '';
this.$router.push({ path: '/mailboxMouldDetail', query: journalId ? { journal_id: journalId } : {} });
},
openTemplateBatchImportDialog() {
const cur = String((this.tplFilters && this.tplFilters.journalId) || '').trim();
if (cur) this.batchTplImportJournalId = cur;
if (!this.batchTplImportText || !String(this.batchTplImportText).trim()) {
this.batchTplImportText = this.defaultTemplateBatchImportSample();
}
this.batchTplImportVisible = true;
},
defaultTemplateBatchImportSample() {
return (
'[\n' +
' {\n' +
' "title": "示例标题",\n' +
' "subject": "示例主题",\n' +
' "scene": "invite_submission",\n' +
' "language": "en",\n' +
' "version": "1.0.0",\n' +
' "body_html": "<p>正文 HTML</p>",\n' +
' "variables_json": "",\n' +
' "is_active": 1\n' +
' }\n' +
']\n'
);
},
/** 与 mailboxMouldDetail._doSave 提交 saveTemplate 字段对齐;支持 body、lang、variables 别名 */
normalizeMailTemplateSavePayload(row) {
if (!row || typeof row !== 'object') return {};
const bodyHtml =
row.body_html != null
? String(row.body_html)
: row.body != null
? String(row.body)
: '';
const bodyTextRaw = row.body_text != null ? String(row.body_text) : '';
const bodyText =
bodyTextRaw.trim() ||
bodyHtml
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const lang = String(row.language != null ? row.language : row.lang != null ? row.lang : 'en').toLowerCase();
let isActive = '1';
if (row.is_active === 0 || row.is_active === '0' || row.is_active === false) isActive = '0';
const out = {
journal_id: String(row.journal_id != null ? row.journal_id : row.journalId != null ? row.journalId : '').trim(),
scene: String(row.scene || 'invite_submission'),
language: lang,
title: String(row.title || ''),
subject: String(row.subject || ''),
body_html: bodyHtml,
body_text: bodyText,
variables_json: String(
row.variables_json != null ? row.variables_json : row.variables != null ? row.variables : ''
),
version: String(row.version != null ? row.version : '1.0.0'),
is_active: isActive
};
const tid = row.template_id != null ? row.template_id : row.id;
if (tid != null && String(tid).trim() !== '') out.template_id = String(tid).trim();
return out;
},
applyTemplateBatchImportJournal(payload) {
const jid = String(this.batchTplImportJournalId || '').trim();
if (jid) payload.journal_id = jid;
},
validateMailTemplateSavePayload(p, index) {
if (!p.journal_id || !String(p.journal_id).trim()) {
return this.$t('mailboxMould.batchImportMissingJournal', { index: index + 1 });
}
if (!p.title || !String(p.title).trim()) {
return this.$t('mailboxMould.batchImportMissingField', { index: index + 1, field: 'title' });
}
if (!p.subject || !String(p.subject).trim()) {
return this.$t('mailboxMould.batchImportMissingField', { index: index + 1, field: 'subject' });
}
if (!p.body_html || !String(p.body_html).trim()) {
return this.$t('mailboxMould.batchImportMissingField', { index: index + 1, field: 'body_html' });
}
return '';
},
async runTemplateBatchImport() {
let rows;
try {
rows = JSON.parse(this.batchTplImportText || '[]');
} catch (e) {
this.$message.error(this.$t('mailboxMould.batchImportBadJson'));
return;
}
if (!Array.isArray(rows) || !rows.length) {
this.$message.warning(this.$t('mailboxMould.batchImportEmpty'));
return;
}
this.batchTplImporting = true;
let ok = 0;
let fail = 0;
const errLines = [];
try {
for (let i = 0; i < rows.length; i++) {
const payload = this.normalizeMailTemplateSavePayload(rows[i]);
this.applyTemplateBatchImportJournal(payload);
const ve = this.validateMailTemplateSavePayload(payload, i);
if (ve) {
fail++;
errLines.push(ve);
continue;
}
try {
const res = await this.$api.post(API.saveTemplate, payload);
if (res && res.code === 0) {
ok++;
} else {
fail++;
errLines.push(
this.$t('mailboxMould.batchImportRowFail', {
index: i + 1,
msg: (res && res.msg) || this.$t('mailboxMould.batchImportSaveFail')
})
);
}
} catch (e) {
console.error(e);
fail++;
errLines.push(this.$t('mailboxMould.batchImportRowNetwork', { index: i + 1 }));
}
}
this.$message.success(this.$t('mailboxMould.batchImportDone', { ok, fail }));
if (errLines.length) {
this.$notify({
title: this.$t('mailboxMould.batchImportErrorsTitle'),
message: errLines.slice(0, 8).join('\n'),
type: fail && !ok ? 'error' : 'warning',
duration: 12000
});
}
this.batchTplImportVisible = false;
this.syncTplFilterMemory();
this.fetchTemplates();
} finally {
this.batchTplImporting = false;
}
},
handleEditTemplate(row) {
this.syncTplFilterMemory();
const templateId = row && (row.template_id || row.id);
@@ -419,4 +600,39 @@ export default {
background: #fff;
box-sizing: border-box;
}
.batch-tpl-hint {
font-size: 12px;
line-height: 1.55;
color: #606266;
margin: 0 0 8px;
}
.batch-tpl-tip {
font-size: 12px;
line-height: 1.5;
color: #909399;
margin: 0 0 12px;
}
.batch-tpl-journal-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.batch-tpl-label {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: #606266;
min-width: 72px;
}
.batch-tpl-journal-input {
flex: 1;
max-width: 360px;
}
.mailbox-mould-batch-import-dialog .batch-tpl-textarea >>> textarea {
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
line-height: 1.45;
}
</style>

View File

@@ -0,0 +1,15 @@
/**
* 基于 iThenticate 标准的颜色映射(与编辑端常见分档一致)
* @param {number|string} score 相似度百分比(可为小数,内部会规范化)
* @returns {{ color: string, label: string }}
*/
export function getSimilarityStyle(score) {
let n = Number(score);
if (!Number.isFinite(n) || n < 0) n = 0;
const s = Math.round(n);
if (s === 0) return { color: '#0000FF', label: 'Blue' };
if (s <= 24) return { color: '#008000', label: 'Green' };
if (s <= 49) return { color: '#EDBD3E', label: 'Yellow' };
if (s <= 74) return { color: '#FF8C00', label: 'Orange' };
return { color: '#FF0000', label: 'Red' };
}

View File

@@ -0,0 +1,381 @@
/**
* 邮件 Word → checkLogin作者→ multipart manuscirpt → contribute → 回填。
* 默认 preserveEditorLocalStorage不覆盖 U_*,作者 user_id 仅用接口返回值;作者账号写入 sessionStorage 备用键。
* 服务端会话在作者 checkLogin 后仍为作者,需用编辑密码再次 checkLogin 恢复(见 MailDetail
*/
import axios from 'axios';
const LS_KEYS = ['U_status', 'U_role', 'U_name', 'U_id', 'U_email', 'U_relname'];
export function backupEditorLocalStorage() {
const b = {};
LS_KEYS.forEach((k) => {
b[k] = localStorage.getItem(k);
});
return b;
}
export function restoreLocalStorage(backup) {
if (!backup) return;
LS_KEYS.forEach((k) => {
if (backup[k] != null) localStorage.setItem(k, backup[k]);
else localStorage.removeItem(k);
});
}
/**
* @param {*} api - this.$api
* @param {{ username: string, password: string, code?: string, baseUrl?: string }} login
*/
export async function postCheckLogin(api, login) {
const baseUrl = login.baseUrl != null ? login.baseUrl : '/api';
const random_num = Math.random();
const image = `${baseUrl}api/User/retrieveCaptcha?a=${random_num}`;
return api.post('api/User/checkLogin', {
username: login.username,
password: login.password,
random_num,
image,
code: login.code != null && login.code !== '' ? login.code : ''
});
}
/** 校验作者 checkLogin 响应,不写 localStorage */
export function validateAuthorLoginResponse(res) {
if (!res || Number(res.code) === 1) {
return { ok: false, msg: (res && res.msg) || 'Login failed' };
}
const roles = res.data && res.data.roles;
const userinfo = res.data && res.data.userinfo;
if (!userinfo) {
return { ok: false, msg: 'Invalid login response' };
}
if (roles && String(roles).includes('editor')) {
return { ok: false, msg: 'Editor accounts cannot use manuscript auto-submit in this flow' };
}
return { ok: true, userinfo };
}
const SHADOW_AUTHOR_ID = 'mailManuscript_author_id';
const SHADOW_AUTHOR_ACCOUNT = 'mailManuscript_author_account';
export function persistAuthorShadowSession(userinfo) {
if (!userinfo || typeof sessionStorage === 'undefined') return;
try {
sessionStorage.setItem(SHADOW_AUTHOR_ID, String(userinfo.user_id));
sessionStorage.setItem(SHADOW_AUTHOR_ACCOUNT, String(userinfo.account || ''));
} catch (e) {
/* ignore */
}
}
export function clearAuthorShadowSession() {
if (typeof sessionStorage === 'undefined') return;
try {
sessionStorage.removeItem(SHADOW_AUTHOR_ID);
sessionStorage.removeItem(SHADOW_AUTHOR_ACCOUNT);
} catch (e) {
/* ignore */
}
}
export function applyAuthorSessionFromLoginResponse(res) {
const v = validateAuthorLoginResponse(res);
if (!v.ok) {
return { ok: false, msg: v.msg };
}
const { userinfo } = v;
const roles = res.data && res.data.roles;
localStorage.setItem('U_status', '2');
localStorage.setItem('U_role', roles != null && roles !== '' ? roles : '');
localStorage.setItem('U_name', userinfo.account);
localStorage.setItem('U_id', userinfo.user_id);
localStorage.setItem('U_relname', userinfo.realname || '');
localStorage.setItem('U_email', userinfo.email || '');
return { ok: true };
}
/** 与 Login.vue 编辑账号成功分支一致,用于一键建稿后恢复编辑服务端会话 */
export function applyEditorSessionFromLoginResponse(res) {
if (!res || Number(res.code) === 1) {
return { ok: false, msg: (res && res.msg) || 'Login failed' };
}
const roles = res.data && res.data.roles;
const userinfo = res.data && res.data.userinfo;
if (!userinfo) {
return { ok: false, msg: 'Invalid login response' };
}
const roleStr = Array.isArray(roles) ? roles.join(',') : String(roles || '');
if (!roleStr.includes('editor')) {
return { ok: false, msg: 'Not an editor account' };
}
localStorage.setItem('U_status', '1');
localStorage.setItem('U_role', roles != null && roles !== '' ? roles : '');
localStorage.setItem('U_name', userinfo.account);
localStorage.setItem('U_id', userinfo.user_id);
localStorage.setItem('U_email', userinfo.email || '');
if (userinfo.realname) {
localStorage.setItem('U_relname', userinfo.realname);
} else {
localStorage.removeItem('U_relname');
}
return { ok: true };
}
/** 与 articleAdd.upload_manuscirpt`baseUrl + 'api/Article/up_file/type/manuscirpt'` */
export async function uploadManuscriptDocxMultipart(baseUrl, blob, filename) {
const url = `${baseUrl}api/Article/up_file/type/manuscirpt`;
const fd = new FormData();
let name = filename || 'manuscript.docx';
if (!String(name).toLowerCase().endsWith('.docx')) {
name = `${name}.docx`;
}
fd.append('manuscirpt', blob, name);
const { data } = await axios.post(url, fd, {
withCredentials: true,
headers: { 'Content-Type': 'multipart/form-data' }
});
return data;
}
export async function postContribute(api, { upurl, user_id, article_id }) {
const payload = {
file_url: '/public/manuscirpt/' + upurl,
user_id: String(user_id)
};
if (article_id != null && article_id !== '') {
payload.article_id = String(article_id);
}
return api.post('api/Contribute/contribute', payload);
}
function buildKeywordsString(article) {
if (!article || article.keywords == null || String(article.keywords).trim() === '') {
return '';
}
return String(article.keywords)
.split(',')
.map((k) => k.trim())
.filter(Boolean)
.join(',');
}
/** 与 articleAdd.addWordTablesList → api/Article/addArticleTable */
export async function postAddArticleTableFromWordTables(api, article_id, wordTables) {
if (!wordTables || !wordTables.length) return { code: 0 };
const data = {
article_id,
list: wordTables.map((e) => ({
table: JSON.stringify([...e]),
type: 0,
html_data: ''
}))
};
return api.post('api/Article/addArticleTable', data);
}
/**
* @param {*} api
* @param {{ user_id: string, article_id: string, journal_id: number, article: object, upurl: string, staging_username?: string, extractWordTables?: (consumer: (tables: any[]) => Promise<void>) => Promise<void> }} opts
*/
export async function runPostContributeArticleAddBackfill(api, opts) {
const { user_id, article_id, journal_id, article, upurl, extractWordTables, staging_username } = opts;
const relPath = 'manuscirpt/' + upurl;
const jid = journal_id != null && String(journal_id).trim() !== '' ? Number(journal_id) : NaN;
const hasJournal = !Number.isNaN(jid) && jid > 0;
if (hasJournal) {
const cj = await api.post('api/Article/changeJournal', {
article_id,
journal_id: jid
});
if (cj && Number(cj.code) !== 0) {
return { ok: false, step: 'changeJournal', msg: (cj && cj.msg) || 'changeJournal failed' };
}
}
const keyWords = buildKeywordsString(article);
const stagingPayload = {
article_id,
journal: hasJournal ? jid : article.journal_id != null ? article.journal_id : '',
title: (article && article.title) || '',
keywords: keyWords,
abstrart: (article && article.abstrart) || '',
type: article && article.type != null ? article.type : '',
username:
staging_username != null && String(staging_username).trim() !== ''
? String(staging_username).trim()
: (typeof localStorage !== 'undefined' && localStorage.getItem('U_name')) || '',
user_id,
approval: '',
approval_file: '',
approval_content: '',
fund: (article && article.fund) || ''
};
const st = await api.post('api/Article/addArticleStaging', stagingPayload);
if (!st || Number(st.status) !== 1) {
return {
ok: false,
step: 'addArticleStaging',
msg: (st && st.msg) || 'addArticleStaging failed'
};
}
const af = await api.post('api/Article/addArticlefile', {
article_id,
type: 'manuscirpt',
url: relPath
});
if (!af || Number(af.code) !== 0) {
return { ok: false, step: 'addArticlefile', msg: (af && af.msg) || 'addArticlefile failed' };
}
await api.post('api/Article/reloadArticleImages', { article_id });
await api.post('api/Article/reloadArticleTable', { article_id });
if (typeof extractWordTables === 'function') {
try {
await extractWordTables(async (tables) => {
if (tables && tables.length > 0) {
await postAddArticleTableFromWordTables(api, article_id, tables);
}
});
} catch (e) {
return {
ok: false,
step: 'addArticleTable',
msg: (e && e.message) || String(e)
};
}
}
await api.post('api/Article/getAuthors', { article_id });
await api.post('api/Article/getArticleState', { article_id, user_id });
return { ok: true };
}
/**
* @param {object} ctx
* @param {*} ctx.api
* @param {string} ctx.baseUrl
* @param {object} ctx.login
* @param {Blob} ctx.blob
* @param {string} ctx.fileName
* @param {number|string} [ctx.journal_id] — 当前邮件账号期刊(必填,与 mailbox 一致)
* @param {(consumer: (tables: any[]) => Promise<void>) => Promise<void>} [ctx.extractWordTables]
* @param {boolean} [ctx.preserveEditorLocalStorage=true] 为 true 时不改 U_*,仅用作者登录返回的 user_id
*/
export async function runMailManuscriptPipeline(ctx) {
const preserve = ctx.preserveEditorLocalStorage !== false;
const backup = backupEditorLocalStorage();
const { api, baseUrl, login, blob, fileName, journal_id, extractWordTables } = ctx;
const restoreIfNeeded = () => {
if (!preserve) {
restoreLocalStorage(backup);
}
};
try {
const loginRes = await postCheckLogin(api, { ...login, baseUrl });
let authorUserId;
let authorAccount = '';
if (preserve) {
const v = validateAuthorLoginResponse(loginRes);
if (!v.ok) {
return { ok: false, msg: v.msg, backup };
}
authorUserId = String(v.userinfo.user_id);
authorAccount = String(v.userinfo.account || '').trim();
persistAuthorShadowSession(v.userinfo);
} else {
const applied = applyAuthorSessionFromLoginResponse(loginRes);
if (!applied.ok) {
restoreIfNeeded();
return { ok: false, msg: applied.msg, backup };
}
authorUserId = localStorage.getItem('U_id');
authorAccount = String(localStorage.getItem('U_name') || '').trim();
}
if (!authorUserId) {
restoreIfNeeded();
clearAuthorShadowSession();
return { ok: false, msg: 'Missing user_id after login', backup };
}
const uploadRes = await uploadManuscriptDocxMultipart(baseUrl, blob, fileName);
if (!uploadRes || Number(uploadRes.code) !== 0) {
restoreIfNeeded();
clearAuthorShadowSession();
return {
ok: false,
msg: (uploadRes && uploadRes.msg) || 'Manuscript upload failed',
backup
};
}
const upurl = uploadRes.upurl;
if (!upurl) {
restoreIfNeeded();
clearAuthorShadowSession();
return { ok: false, msg: 'Upload response missing upurl', backup };
}
const cr = await postContribute(api, { upurl, user_id: authorUserId });
if (!cr || Number(cr.status) !== 1 || !cr.article || cr.article.article_id == null) {
restoreIfNeeded();
clearAuthorShadowSession();
return {
ok: false,
msg: (cr && cr.msg) || 'Contribute failed',
backup,
contributeRes: cr
};
}
const articleId = String(cr.article.article_id);
const jid = journal_id != null && String(journal_id).trim() !== '' ? Number(journal_id) : NaN;
if (Number.isNaN(jid) || jid <= 0) {
restoreIfNeeded();
clearAuthorShadowSession();
return {
ok: false,
msg: 'Missing journal_id for post-contribute backfill',
backup,
contributeRes: cr
};
}
const bf = await runPostContributeArticleAddBackfill(api, {
user_id: authorUserId,
article_id: articleId,
journal_id: jid,
article: cr.article,
upurl,
staging_username: authorAccount,
extractWordTables
});
if (!bf.ok) {
restoreIfNeeded();
clearAuthorShadowSession();
return {
ok: false,
msg: bf.msg || 'Post-contribute backfill failed',
backup,
step: bf.step,
article_id: articleId,
contributeRes: cr
};
}
clearAuthorShadowSession();
return { ok: true, article_id: articleId, backup };
} catch (e) {
restoreIfNeeded();
clearAuthorShadowSession();
return { ok: false, msg: (e && e.message) || String(e), backup };
}
}