22 Commits

Author SHA1 Message Date
48dc82326d Merge branch 'master' of https://git.nuttyreading.com/wangjinlei/tougao_web into Editorial-Board 2026-05-06 11:22:20 +08:00
6cb5dbccf0 修改 参考文献排序 2026-04-14 11:18:04 +08:00
26981f83c4 Merge branch 'master' of https://git.nuttyreading.com/wangjinlei/tougao_web into Editorial-Board 2026-04-14 09:10:02 +08:00
a28d0d5079 提交 2026-04-13 17:00:49 +08:00
eff107aa15 表格参考文献的预览 2026-04-13 16:42:26 +08:00
563def58a5 tijiao 2026-04-13 14:42:13 +08:00
222a00c1c7 Merge branch 'master' of https://git.nuttyreading.com/wangjinlei/tougao_web into Editorial-Board 2026-04-13 14:20:03 +08:00
1deb5bba86 tijiao 2026-04-13 13:06:31 +08:00
d613aa7d0d tijiao 2026-04-10 15:53:15 +08:00
0a56b16fe4 Merge branch 'master' of https://git.nuttyreading.com/wangjinlei/tougao_web into Editorial-Board 2026-04-10 09:21:39 +08:00
7f725cad52 Merge branch 'master' of https://git.nuttyreading.com/wangjinlei/tougao_web into Editorial-Board 2026-04-10 09:13:17 +08:00
797ca258f8 提交 2026-04-10 09:12:40 +08:00
9c44064f51 参考文献 2026-04-09 11:52:33 +08:00
7527a6ef54 参考文献的暂存 2026-04-09 09:39:58 +08:00
c4b86be0d5 提交 2026-04-08 17:25:19 +08:00
4fc78e1fe7 提交 2026-04-08 13:09:02 +08:00
0d35b76c3a 提交 2026-04-08 13:03:54 +08:00
36f6c02376 提交 2026-04-03 09:58:39 +08:00
dd07a03d7b Merge branch 'master' of https://git.nuttyreading.com/wangjinlei/tougao_web into Editorial-Board 2026-04-03 09:52:11 +08:00
620a35f958 提交 2026-04-03 09:05:56 +08:00
95b52b4d06 提交 2026-04-02 10:56:37 +08:00
1f29fb5baf 根据标签autoCite显示参考文献 2026-04-01 17:30:40 +08:00
42 changed files with 9038 additions and 5259 deletions

View File

@@ -19,8 +19,8 @@ const service = axios.create({
// baseURL: 'https://submission.tmrjournals.com/', //正式 记得切换
// baseURL: 'http://www.tougao.com/', //测试本地 记得切换
// baseURL: 'http://192.168.110.110/tougao/public/index.php/',
baseURL: '/api', //本地
// baseURL: '/', //正式
// baseURL: '/api', //本地
baseURL: '/', //正式
});
@@ -102,6 +102,22 @@ export default {
})
},
/**
* POST x-www-form-urlencoded数组字段序列化为 list[]=a&list[]=b兼容 PHP 批量接口)
*/
postFormBracket(url, params) {
return new Promise((resolve, reject) => {
service
.post(url, qs.stringify(params, { arrayFormat: 'brackets' }))
.then((res) => {
resolve(res.data)
})
.catch((err) => {
reject(err)
})
})
},
/**
* post方法对应post请求
* @param {String} url [请求的url地址]

View File

@@ -0,0 +1,169 @@
/**
* 表格弹窗预览:将单元格内空 mycite 转为可见 [n],与 word.vue renderCiteLabels 一致(纯函数,无 Vue 依赖)
*/
export function formatCiteNumbers(nums) {
if (!nums || !nums.length) return '';
const sorted = [...new Set(nums)].sort((a, b) => a - b);
const result = [];
let i = 0;
while (i < sorted.length) {
let j = i;
while (j < sorted.length - 1 && sorted[j + 1] === sorted[j] + 1) j++;
if (j - i >= 2) {
result.push(`${sorted[i]}${sorted[j]}`);
} else {
for (let k = i; k <= j; k++) result.push(sorted[k]);
}
i = j + 1;
}
return result.join(', ');
}
export function sortAutociteIdsByCiteNumber(ids, citeMap) {
const map = citeMap || {};
const uniq = [...new Set(ids.map(String))];
return uniq.sort((a, b) => {
const na = map[a];
const nb = map[b];
const ha = na != null && na !== '';
const hb = nb != null && nb !== '';
if (ha && hb) return Number(na) - Number(nb);
if (ha) return -1;
if (hb) return 1;
return String(a).localeCompare(String(b));
});
}
/**
* @param {Array} refs - chanFerForm项含 p_refer_id
* @param {Array<string|number>} bodyCiteIdOrder - 正文首次出现顺序;空则按列表行序 1..n
*/
export function buildCiteMapFromRefs(refs, bodyCiteIdOrder) {
const refList = Array.isArray(refs) ? refs : [];
const refIdSet = new Set(
refList.map((r) => (r && r.p_refer_id != null ? String(r.p_refer_id) : '')).filter(Boolean)
);
let orderedIds = [];
if (Array.isArray(bodyCiteIdOrder) && bodyCiteIdOrder.length > 0) {
orderedIds = bodyCiteIdOrder.map(String);
}
if (orderedIds.length === 0) {
const map = {};
refList.forEach((row, idx) => {
const key = row && row.p_refer_id != null ? String(row.p_refer_id) : '';
if (key) map[key] = idx + 1;
});
return map;
}
const filtered = [];
orderedIds.forEach((id) => {
if (refIdSet.has(id) && !filtered.includes(id)) filtered.push(id);
});
const map = {};
filtered.forEach((id, idx) => {
map[id] = idx + 1;
});
let next = filtered.length + 1;
refList.forEach((r) => {
const key = r && r.p_refer_id != null ? String(r.p_refer_id) : '';
if (!key || map[key] != null) return;
map[key] = next++;
});
return map;
}
function escAttr(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
/**
* @param {string} html
* @param {Array} refs - chanFerForm
* @param {Object} citeMap - p_refer_id -> 显示序号
*/
export function renderCiteLabelsInHtml(html, refs, citeMap) {
if (!html || typeof html !== 'string') return html || '';
if (!Array.isArray(refs) || refs.length === 0) return html;
/** 与稿面一致:历史/表格 JSON 中可能为 <autocite> */
let h = html.replace(/<\/autocite>/gi, '</mycite>').replace(/<autocite\b/gi, '<mycite');
const refList = refs;
const refMap = refList.reduce((acc, item) => {
const key = item && item.p_refer_id != null ? String(item.p_refer_id) : '';
if (key) acc[key] = item;
return acc;
}, {});
let normalized = h.replace(/<mycite\b[^>]*\/?>[\s\S]*?(?:<\/mycite>)?/gi, (fullTag) => {
const attrMatch = fullTag.match(/\bdata-id\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
const dataId = (attrMatch && (attrMatch[1] || attrMatch[2] || attrMatch[3])) || '';
if (!dataId) return '';
return `<mycite data-id="${escAttr(dataId)}"></mycite>`;
});
const citeGroupRe = /(?:<mycite\s+data-id="([^"]*)"\s*><\/mycite>\s*)+/gi;
normalized = normalized.replace(citeGroupRe, (groupMatch) => {
const ids = [];
const innerRe = /<mycite\s+data-id="([^"]*)"\s*><\/mycite>/gi;
let m;
while ((m = innerRe.exec(groupMatch)) !== null) {
m[1].split(',').forEach((part) => {
const id = part.trim();
if (id) ids.push(id);
});
}
const sortedAll = sortAutociteIdsByCiteNumber(ids, citeMap);
const validIds = sortedAll.filter((id) => refMap[id]);
const sortedIds = validIds.length ? sortAutociteIdsByCiteNumber(validIds, citeMap) : [];
const map = citeMap || {};
const parts = sortedIds.map((id) => {
const ref = refMap[id];
const no = ref ? map[id] : null;
const num = no != null && no !== '' ? String(no) : null;
return { id, ref, num };
});
const numsForLabel = parts
.map((p) => p.num)
.filter((n) => n != null && n !== '')
.map(Number);
const label = numsForLabel.length > 0 ? formatCiteNumbers(numsForLabel) : '';
if (!label) {
return '';
}
const dataIdAttr = escAttr(sortedIds.join(','));
return `<mycite data-id="${dataIdAttr}">[${label}]</mycite>`;
});
return normalized.replace(/<mycite[^>]*data-id=""[^>]*>[\s\S]*?<\/mycite>/gi, '');
}
export function applyCiteLabelsToTableRows(rows, refs, citeMap) {
if (!Array.isArray(rows) || !rows.length) return rows;
if (!Array.isArray(refs) || refs.length === 0) return rows;
return rows.map((row) => {
if (!Array.isArray(row)) return row;
const nextRow = row.map((cell) => {
if (!cell || typeof cell !== 'object') return cell;
const t = cell.text;
const lower = typeof t === 'string' ? t.toLowerCase() : '';
if (!lower || (!lower.includes('mycite') && !lower.includes('autocite'))) return cell;
const next = renderCiteLabelsInHtml(t, refs, citeMap);
if (next === t) return cell;
return { ...cell, text: next };
});
// TableUtils.addRowIdToData 会给数组行对象挂 rowId渲染 oddColor 依赖它,不能在 map 后丢失
if (row.rowId != null) {
nextRow.rowId = row.rowId;
}
return nextRow;
});
}

View File

@@ -71,7 +71,51 @@ function findExtentElement(blipElement) {
export default {
async searchTitleByDOI(doi) {
if (!doi) {
this.$message.warning('Please enter a DOI');
return null;
}
// 开启全局 Loading防止编辑重复点击
try {
// 1. 数据清洗
const cleanDoi = doi.trim()
.replace(/^doi:/i, '')
.replace(/https?:\/\/doi\.org\//i, '');
// 2. 请求 Crossref 接口
const response = await fetch(`https://api.crossref.org/works/${encodeURIComponent(cleanDoi)}`, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (!response.ok) throw new Error('DOI not found');
const data = await response.json();
// 3. 提取标题并跳转
const title = data.message?data.message.title?data.message.title[0]:'':'';
if (title) {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(title)}`;
window.open(searchUrl, '_blank');
return title;
} else {
this.$message.error('Title not found in metadata.');
}
} catch (error) {
console.error("DOI Retrieval Error:", error);
this.$message.error('Failed to fetch title. Please check the DOI.');
} finally {
}
return null;
},
getJournalTypeName(value) {
var list = JSON.parse(localStorage.getItem('journalTypeDataAll'));
@@ -226,8 +270,7 @@ export default {
str = this.transformHtmlString(processedContent, 'table',{ keepBr: true })
console.log("🚀 ~ extractContentWithoutOuterSpan888888 ~ str:", str);
// 创建一个临时的 DOM 元素来解析 HTML
const div = document.createElement('div');
@@ -391,8 +434,7 @@ str = str.replace(regex, function (match, content, offset, fullString) {
const allTables = [];
if (!tables || tables.length === 0) {
console.warn("未找到表格内容,请检查 XML 结构");
callback([]);
return;
return [];
}
for (const table of tables) {
const rows = table.getElementsByTagNameNS(namespace, "tr");
@@ -918,9 +960,9 @@ str = str.replace(regex, function (match, content, offset, fullString) {
});
// 2. 删除所有不需要的标签 (除 `strong`, `em`, `sub`, `sup`, `b`, `i` 外的所有标签)
if (type == 'table') {
inputHtml = inputHtml.replace(/<(?!\/?(strong|em|sub|sup|b|i|blue|wmath|img|myfigure|mytable|myh3))[^>]+>/g, ''); // 删除不需要的标签
inputHtml = inputHtml.replace(/<(?!\/?(strong|em|sub|sup|b|i|blue|wmath|img|myfigure|mytable|myh3|mycite))[^>]+>/g, ''); // 删除不需要的标签
} else {
inputHtml = inputHtml.replace(/<(?!\/?(strong|em|sub|sup|b|i|blue|wmath|myfigure|mytable|myh3))[^>]+>/g, ''); // 删除不需要的标签
inputHtml = inputHtml.replace(/<(?!\/?(strong|em|sub|sup|b|i|blue|wmath|myfigure|mytable|myh3|mycite))[^>]+>/g, ''); // 删除不需要的标签
}
@@ -993,8 +1035,7 @@ str = str.replace(regex, function (match, content, offset, fullString) {
const cells = row.querySelectorAll('th, td'); // 获取每个行中的单元格(包括 <th> 和 <td>
return await Promise.all(
Array.from(cells).map(async (cell) => {
console.log("🚀 ~ parseTableToArray777 ~ cell:", cell);
const text = await this.extractMathJaxLatex(cell);
return {
text,

View File

@@ -128,7 +128,6 @@ 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';
@@ -163,11 +162,6 @@ export default {
}
},
methods: {
syncLsUserToHeader() {
this.user_id = localStorage.getItem('U_id');
this.user_cap = localStorage.getItem('U_role');
this.$forceUpdate();
},
goHome() {
this.$router.push('/');
},
@@ -301,9 +295,6 @@ export default {
},
immediate: true
}
},
beforeDestroy() {
bus.$off('editorSessionLocalRestored', this.syncLsUserToHeader);
}
};
</script>

View File

@@ -120,7 +120,6 @@ 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';
@@ -156,10 +155,6 @@ export default {
}
},
methods: {
onEditorSessionLocalRestored() {
this.updateUsername();
this.$forceUpdate();
},
updateUsername() {
this.localUsername = localStorage.getItem('U_name');
@@ -295,7 +290,6 @@ 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

@@ -47,25 +47,6 @@ const en = {
status: 'Status',
delete: 'Delete',
deleteInfo: 'Are you sure you want to delete this journal installment?',
plagiarismNotChecked: 'Not checked',
plagiarismChecking: 'Checking…',
plagiarismRecheck: 'Re-check',
plagiarismDuplicateCheck: 'Re-check',
plagiarismCheckFailed: 'Failed to start plagiarism check.',
plagiarismStatusFailed: 'Failed to load plagiarism status.',
plagiarismNoReportUrl: 'Report link is not available yet.',
plagiarismReportDetailFailed: 'Could not load manuscript details. Please try again.',
plagiarismListTitle: 'Plagiarism check history',
plagiarismAutoCheck: 'Auto plagiarism check',
plagiarismRefresh: 'Refresh',
plagiarismEmptyList: 'No plagiarism checks yet',
plagiarismSimilarity: 'Similarity',
plagiarismFile: 'File',
plagiarismPreviewPdf: 'Preview report',
plagiarismReportLink: 'Report',
plagiarismNoPdfLink: 'No link',
plagiarismPreviewClose: 'Close',
plagiarismPreviewOpenTab: 'Open in new tab',
},
menu: {
main: 'Personal Center',
@@ -522,7 +503,7 @@ const en = {
languagePlaceholder: 'Language',
searchBtn: 'Search',
createTemplate: 'Create Template',
colTitle: 'Template title',
colTitle: 'Template title',
colSubject: 'Email subject',
colScene: 'Scene',
colLanguage: 'Language',
@@ -538,23 +519,6 @@ const en = {
deleteFail: 'Delete failed',
previewTitle: 'Template preview',
previewClose: 'Close',
batchImportBtn: 'Batch import',
batchImportTitle: 'Batch import templates (JSON)',
batchImportHint:
'Paste a JSON array. Each item is saved via the same API as the editor (omit template_id to create; include template_id to update). Fields: title, subject, scene, language (or lang), version, body_html (or body), variables_json (or variables), is_active.',
batchImportCommonTip: 'Journal ID is set in the field below; when non-empty it overrides journal_id / journalId on every row.',
batchImportJournalId: 'Journal ID',
batchImportJournalPlaceholder: 'Match list filter or type manually',
batchImportRun: 'Run import',
batchImportBadJson: 'Invalid JSON',
batchImportEmpty: 'Array must contain at least one object',
batchImportMissingJournal: 'Row {index}: missing journal_id (use the input above or put journal_id in JSON)',
batchImportMissingField: 'Row {index}: missing {field}',
batchImportRowFail: 'Row {index} failed: {msg}',
batchImportRowNetwork: 'Row {index}: request error',
batchImportDone: 'Done: {ok} succeeded, {fail} failed',
batchImportErrorsTitle: 'Errors (first 8)',
batchImportSaveFail: 'Save failed',
},
mailboxStyle: {
title: 'Email Styles',
@@ -686,72 +650,6 @@ const en = {
printBtn: 'Print',
previewNotSupported: 'This file format cannot be previewed online',
downloadToView: 'Download to view locally',
registerAuthorBtn: 'Auto submit',
registerAuthorConfirm:
'Create an account via the same admin API as User Management: login name "{account}", display name "{realname}", email "{email}", initial password 123456qwe (no captcha). Continue?',
registerAuthorSuccess: 'Author account created.',
registerAuthorSuccessWithEmail: 'Created: {email}, password: 123456qwe',
registerAuthorFail: 'Creation failed. Try again later or add the user manually in User Management.',
registerAuthorExistsEmail: 'This email is already registered.',
registerAuthorExistsAccount: 'This login name is already taken. Edit the sender display name or add the user manually.',
registerAuthorNeedEmail: 'Sender email is missing; cannot create an account.',
registerAuthorNoQq: 'QQ Mail is not supported for author accounts. Please add the user manually.',
registerAuthorConfirmShort: 'Email: {email}\nPassword: {password}',
registerAuthorPickEmailFail: 'Could not allocate an available email after several attempts. Try again later or add the user manually.',
autoSubmitBtn: 'Submit first .docx as manuscript',
autoSubmitTitle: 'Log in as author and upload the first .docx attachment',
autoSubmitSessionTip:
'Author checkLogin does not change your U_* local account. The server cookie is the author during upload. After finishing, enter your editor password below and log in again to restore the editor server session.',
autoSubmitUsername: 'Username',
autoSubmitPassword: 'Password',
autoSubmitSenderEmailLabel: 'Sender email',
autoSubmitSenderEmailPlaceholder: '(No sender email)',
autoSubmitCode: 'Captcha',
autoSubmitCodePh: 'Fill only if the server requires captcha; often leave empty',
autoSubmitCancel: 'Cancel',
autoSubmitDialogTitle: 'Auto submit',
autoSubmitConfirm: 'Auto submit',
autoSubmitExistingAccountTip:
'This sender email may already have an account. Enter your password. Re-selecting a local file replaces the previous one.',
autoSubmitNotifyMailSubject: '[{journal}] Please complete your submission',
autoSubmitNotifyMailFail: 'Could not send the notification email. You can resend from the compose page.',
autoSubmitNotifyMailSkipped: 'No sender mailbox (j_email_id) found; skipped automatic email.',
autoSubmitNoDocx: 'No .docx attachment in this message (only .docx is supported, same as new submission).',
autoSubmitDownloadFail: 'Could not download the attachment. Try again later.',
autoSubmitSuccessTitle: 'Manuscript created',
autoSubmitDialogClose: 'Close',
autoSubmitSuccessLineAccount: 'Account: {account}',
autoSubmitSuccessLinePassword: 'Password: {password}',
autoSubmitSuccessLineDraft: 'Manuscript ID: {id}. Draft created in staging.',
autoSubmitSuccessLineLinkPrefix: 'Submission link: ',
autoSubmitSuccessMailSent: 'Notification email sent',
autoSubmitSuccessMailSkipped: 'No notification email (journal sender mailbox not configured).',
autoSubmitSuccessMailSkippedRecipient: 'No notification email (no valid From address on this message).',
autoSubmitSuccessMailFailed: 'Notification email was not sent; try again from the compose page.',
autoSubmitSuccessBodyLocalOnly:
'Article ID: {id}. The UI still shows your editor account; the server session may still be the author after author login. Refresh or log in again as editor.',
autoSubmitSuccessBodyServerRestored:
'Article ID: {id}. Logged in again as editor; both local storage and server session should match your editor account.',
autoSubmitSuccessNotify:
'Article ID: {id}<br/><a href="{link}" target="_blank" rel="noopener noreferrer">Open articleAdd</a>',
autoSubmitEditorRestorePwd: 'Editor password (restore session)',
autoSubmitEditorRestorePwdPh:
'Optional: password for the current editor account shown in the header, used after success to restore the server session',
autoSubmitEditorReloginFail:
'Could not restore the editor server session; local values were restored where possible. Refresh the page or log in again as editor.',
autoSubmitFail: 'Submission failed. Check credentials or network and try again.',
autoSubmitUsernameRequired: 'Username is required',
autoSubmitPasswordRequired: 'Password is required',
autoSubmitJournalLabel: 'Journal',
autoSubmitJournalUnknown: 'No journal (switch to a mailbox account that is bound to a journal)',
autoSubmitNeedJournal:
'This mailbox has no journal ID; staging backfill cannot match the submission page. Switch mailbox account first.',
autoSubmitFailPartial: '(If contribute succeeded, article ID may be {id}; please verify in admin.)',
autoSubmitManuscriptSource: 'Manuscript file',
autoSubmitPickLocalDocx: 'Upload .docx from disk',
autoSubmitSourceHint: 'Optional: upload a file; otherwise the first .docx in the email is used. Choosing again replaces the current local file.',
autoSubmitLocalPicked: 'Local file: {name}',
autoSubmitNeedDocxSource: 'Upload a .docx file, or ensure the email has a .docx attachment.',
},
crawlerKeywords: {
pageTitle: 'Keyword Configuration',
@@ -1097,8 +995,8 @@ const en = {
step3: 'References',
step: 'step',
Information: 'Fill in information',
startPreAccept: 'Start the pre-acceptance process',
startPreAcceptWithPayment: 'Start the pre-acceptance process and complete your payment',
},
Formula: {
FormulaTemplate: 'Formula Template'
@@ -1273,55 +1171,6 @@ const en = {
onlySaveConfig: 'Save configuration only',
enableNowNextDay: 'Enable auto promotion now (starts next day)',
factoryCreateBtn: 'Create automated promotion task',
factoryBatchImportBtn: 'Batch import (JSON)',
factoryBatchImportTitle: 'Batch create tasks (JSON)',
factoryBatchImportHintShort: 'Submit a JSON array; non-empty fields merge into each row. You can still edit JSON.',
factoryBatchImportHint:
'Paste a JSON array… Pick journal (getAllJournal) or type ID; template/style/fetch_ids below override JSON when non-empty. Load promotion fields to tick IDs or type comma-separated fetch_ids; load accounts for j_email_id. expert_type "5" needs partitions/countries. Shorthand: zones, countries, email_id_list.',
factoryBatchImportCommonTip: 'Journal from cover or ID field; non-empty template ID, style ID, or fetch_ids override JSON on every row.',
factoryBatchImportJournalId: 'Journal ID',
factoryBatchImportJournalPick: 'Journal',
factoryBatchImportJournalEmpty: 'No journals returned. Check api/Journal/getAllJournal.',
factoryBatchImportJournalManualPlaceholder: 'Filled when you pick a cover, or type manually',
factoryBatchImportTemplateId: 'Template ID',
factoryBatchImportStyleId: 'Style ID',
factoryBatchImportJournalPlaceholder: 'Merged into payload',
factoryBatchImportTemplatePlaceholder: 'Merged into payload',
factoryBatchImportStylePlaceholder: 'Merged into payload',
factoryBatchImportFetchIdsLabel: 'Promotion fields (fetch_ids)',
factoryBatchImportLoadFields: 'Load available fields',
factoryBatchImportFetchTip: 'Uses current journal: pick journal, then load. Search by name or ID (multiple tokens: space or comma). “Select all” selects the filtered list when search is set, otherwise all fields. Checkboxes sync with the comma text; when non-empty, overrides fetch_ids on every row.',
factoryBatchImportFetchIdsManual: 'Merged IDs (comma-separated, editable)',
factoryBatchImportFetchIdsPlaceholder: 'e.g. 1,2,3 or use checkboxes',
factoryBatchImportNeedJournalForFields: 'Select or enter journal ID first',
factoryBatchImportLoadAccounts: 'Load accounts for journal',
factoryBatchImportAccountsApiTip: 'POST api/email_client/getAccounts with journal_id',
factoryBatchImportColEmailId: 'j_email_id',
factoryBatchImportColAddress: 'Sender address',
factoryBatchImportColQuota: 'Remaining / daily limit',
factoryBatchImportCopyEmailIds: 'Copy email_ids (comma)',
factoryBatchImportNeedJournalForAccounts: 'Enter journal ID first',
factoryBatchImportNoAccounts: 'No mailbox accounts for this journal',
factoryBatchImportAccountsFail: 'Failed to load accounts',
factoryBatchImportCopyEmailIdsEmpty: 'Load the account list first',
factoryBatchImportIdsCopied: 'Copied j_email_id list to clipboard',
factoryBatchImportCopyFail: 'Copy failed; select and copy manually',
factoryBatchImportSyncToJson: 'Apply top form to JSON',
factoryBatchImportSyncFromJson: 'Load first row into form',
factoryBatchImportSyncIncludeEmails: 'When applying, set each row email_ids from loaded accounts',
factoryBatchImportSyncTip: 'You can still edit JSON manually; non-empty top fields are merged again on submit.',
factoryBatchImportJsonFromUiOk: 'JSON updated from the form',
factoryBatchImportUiFromJsonOk: 'Form updated from the first JSON row',
factoryBatchImportRun: 'Run batch create',
factoryBatchImportBadJson: 'Invalid JSON; check brackets and quotes',
factoryBatchImportEmpty: 'Array must contain at least one object',
factoryBatchImportMissing: 'Row {index}: missing field {field}',
factoryBatchImportNeedFetch: 'Row {index}: expert database requires fetch_ids',
factoryBatchImportNeedZone: 'Row {index}: expert database requires target_partitions or target_country_ids',
factoryBatchImportRowFail: 'Row {index} failed: {msg}',
factoryBatchImportRowNetwork: 'Row {index}: request error',
factoryBatchImportDone: 'Done: {ok} succeeded, {fail} failed',
factoryBatchImportErrorsTitle: 'Errors (first 8)',
factoryDialogTitle: 'Create task',
factoryJournal: 'Journal',
factoryJournalPlaceholder: 'Select a journal',
@@ -1375,8 +1224,6 @@ const en = {
factoryExpertYoungBoard: 'Young editorial board',
factoryExpertAuthor: 'Author',
factoryExpertDb: 'Expert database',
factoryExpertYoungBoardBefore2025: 'Young board (before 2025)',
factoryExpertAuthorBefore2025: 'Author (before 2025)',
factoryExpertJump: 'View',
factoryOfficialEmailTip: 'For this type, the system uses the official sender email by default. No account selection is required.',
factoryScenario: 'Scenario',
@@ -1396,7 +1243,6 @@ const en = {
,
autoPromotionLogs: {
detail: 'Auto Promotion Details',
pipelineHistory: 'PIPELINE HISTORY',
factoryTaskSelectPlaceholder: 'Select promotion task',
configured: 'Configured',
editConfig: 'Edit auto promotion configuration',
@@ -1464,7 +1310,6 @@ const en = {
taskLogState2: 'Failed',
taskLogState3: 'Bounced',
taskLogState4: 'Cancelled',
logColIndex: 'No.',
logColExpert: 'Expert',
logColSendTime: 'Sent at',
logColPreparedAt: 'Prepared at',
@@ -1491,6 +1336,9 @@ const en = {
deleteLogSuccess: 'Deleted',
deleteLogFailed: 'Delete failed',
noFailureReason: 'No failure reason',
logDetailEditTitle: 'Edit delivery log',
logDetailPreviewTitle: 'Preview delivery log',
logLoadFailed: 'Failed to load logs',
deletedSuccess: 'Deleted',
mockPromotionSubject: 'Promotion for {journal}',
mockPromotionContent: '<p>Dear {name},</p><p>Check out our latest journal updates...</p>'
@@ -1502,6 +1350,32 @@ const en = {
previewWithVariablesHint: 'The expert data is an example, used for variable spelling check only.',
close: 'Close',
placeholder: 'Please enter email content'
},
wordCite: {
noRefs: 'Reference list is not loaded yet. Please wait or refresh the page.',
missingBoundRef: 'The bound reference no longer exists. Please cite again.',
notFoundById: 'No reference found for citation ID {id}',
selectRef: 'Select Reference',
originalOrder: 'Original order',
reference: 'Reference',
uncited: 'Uncited',
cancel: 'Cancel',
confirm: 'Confirm',
remove: 'Remove',
selected: 'Selected',
modifyRef: 'Edit citation',
removeRefTag: 'Remove citation',
citeUpdateFail: 'Could not update the citation in the text. Try again or use Edit.',
matchBracketRefs: 'Auto-link References',
matchBracketRefsDone: 'Converted {n} bracket citation(s) to autocite.',
matchBracketRefsNone: "No active citation links were detected in the text. To enable automatic numbering, please use the 'Reference' tool to link your sources.",
removeRefNeedClickCite: 'Click a citation in the text first, then choose Reference remove.',
quickPickPlaceholder: 'Enter e.g. [5, 6, 10-15] to auto-select',
quickPickApply: 'Link References',
quickPickClear: 'Clear selection',
currentCiteNo: 'Current',
locateInBody: 'Click to scroll to citation in text',
locateInRefHint: 'Highlight this entry in the list below'
}

View File

@@ -45,25 +45,6 @@ const zh = {
status: '状态',
delete: '删除',
deleteInfo: '您确定要删除该期刊分期吗?',
plagiarismNotChecked: '未检测',
plagiarismChecking: '正在检测…',
plagiarismRecheck: '重新查重',
plagiarismDuplicateCheck: '重复检查',
plagiarismCheckFailed: '查重任务启动失败。',
plagiarismStatusFailed: '获取查重状态失败。',
plagiarismNoReportUrl: '报告链接暂不可用。',
plagiarismReportDetailFailed: '获取稿件详情失败,请稍后重试。',
plagiarismListTitle: '自动查重记录',
plagiarismAutoCheck: '自动查重',
plagiarismRefresh: '刷新',
plagiarismEmptyList: '暂无查重记录',
plagiarismSimilarity: '相似度',
plagiarismFile: '文件',
plagiarismPreviewPdf: '预览报告',
plagiarismReportLink: '报告',
plagiarismNoPdfLink: '无链接',
plagiarismPreviewClose: '关闭',
plagiarismPreviewOpenTab: '新窗口打开',
},
menu: {
main: '个人中心',
@@ -527,23 +508,6 @@ const zh = {
deleteFail: '删除失败',
previewTitle: '模板预览',
previewClose: '关闭',
batchImportBtn: '批量导入',
batchImportTitle: '批量导入邮件模板JSON',
batchImportHint:
'粘贴 JSON 数组,每条对应一次保存接口(新建不传 template_id更新可带 template_id。字段与编辑页一致title、subject、scene、language可用 lang、version、body_html可用 body、variables_json可用 variables、is_active。',
batchImportCommonTip: '期刊 ID 在下方单独填写;若填写非空,会覆盖每条 JSON 中的 journal_id / journalId。',
batchImportJournalId: '期刊 ID',
batchImportJournalPlaceholder: '可与列表筛选一致,或手填',
batchImportRun: '开始导入',
batchImportBadJson: 'JSON 解析失败',
batchImportEmpty: '请至少包含一条对象',
batchImportMissingJournal: '第 {index} 条:缺少期刊 ID请填写上方输入框或在 JSON 中提供 journal_id',
batchImportMissingField: '第 {index} 条:缺少字段 {field}',
batchImportRowFail: '第 {index} 条保存失败:{msg}',
batchImportRowNetwork: '第 {index} 条请求异常',
batchImportDone: '完成:成功 {ok},失败 {fail}',
batchImportErrorsTitle: '失败明细(最多 8 条)',
batchImportSaveFail: '保存失败',
},
mailboxStyle: {
title: '邮件风格',
@@ -675,68 +639,6 @@ const zh = {
printBtn: '打印',
previewNotSupported: '该文件格式无法在线预览',
downloadToView: '下载到本地查看',
registerAuthorBtn: '自动投稿',
registerAuthorConfirm:
'将使用推广后台「添加用户」接口创建账号:登录名「{account}」,显示名「{realname}」,邮箱「{email}」,初始密码 123456qwe无需验证码。是否继续',
registerAuthorSuccess: '作者账号已创建。',
registerAuthorSuccessWithEmail: '已创建:{email}密码123456qwe',
registerAuthorFail: '创建失败,请稍后重试或到用户管理中手动添加。',
registerAuthorExistsEmail: '该邮箱已被注册。',
registerAuthorExistsAccount: '该登录名已被占用,请人工处理或修改发件人显示名后重试。',
registerAuthorNeedEmail: '缺少发件人邮箱,无法创建账号。',
registerAuthorNoQq: '本站不支持 QQ 邮箱作为作者账号,请在用户管理中手动添加。',
registerAuthorConfirmShort: '邮箱:{email}\n密码{password}',
registerAuthorPickEmailFail: '多次尝试后仍无法分配到可用邮箱,请稍后重试或手动添加用户。',
autoSubmitBtn: '附件一键建稿',
autoSubmitTitle: '用作者账号登录并上传首份 .docx 附件',
autoSubmitSessionTip:
'作者 checkLogin 不会修改顶部 U_* 本地账号;上传期间服务端 Cookie 为作者。完成后请填写「编辑密码」再登录一次以恢复编辑服务端会话。',
autoSubmitUsername: '登录名',
autoSubmitPassword: '密码',
autoSubmitSenderEmailLabel: '发件人邮箱',
autoSubmitSenderEmailPlaceholder: '(未识别发件人邮箱)',
autoSubmitCode: '验证码',
autoSubmitCodePh: '若后台要求验证码则填写,一般可留空',
autoSubmitCancel: '取消',
autoSubmitDialogTitle: '自动投稿',
autoSubmitConfirm: '自动投稿',
autoSubmitExistingAccountTip:
'该发件邮箱可能已有账号。请填写密码;本地文件可多次重选,新文件会替换上一份。',
autoSubmitNotifyMailSubject: '【{journal}】投稿完善提醒',
autoSubmitNotifyMailFail: '通知邮件发送失败,可稍后在发件页手动补发。',
autoSubmitNotifyMailSkipped: '未识别发件邮箱账号,已跳过自动发信。',
autoSubmitNoDocx: '邮件中无 .docx 附件(仅支持 docx与新增稿件一致。',
autoSubmitDownloadFail: '无法下载附件,请稍后重试。',
autoSubmitSuccessTitle: '建稿成功',
autoSubmitDialogClose: '关闭',
autoSubmitSuccessLineAccount: '账号{account}',
autoSubmitSuccessLinePassword: '密码{password}',
autoSubmitSuccessLineDraft: '稿号id{id} 已创建草稿箱',
autoSubmitSuccessLineLinkPrefix: '稿件链接地址是:',
autoSubmitSuccessMailSent: '邮件通知已发送',
autoSubmitSuccessMailSkipped: '未发送通知邮件(未配置期刊发件邮箱)',
autoSubmitSuccessMailSkippedRecipient: '未发送通知邮件(邮件中缺少有效发件人邮箱)',
autoSubmitSuccessMailFailed: '邮件通知未发送,请稍后在发件页补发',
autoSubmitSuccessBodyLocalOnly:
'文章 ID{id}。界面仍为当前编辑账号;服务端在作者登录后可能仍为作者会话,请刷新或重新登录编辑账号。',
autoSubmitSuccessBodyServerRestored: '文章 ID{id}。已通过编辑账号重新登录,本地与服务端会话均已恢复为编辑。',
autoSubmitSuccessNotify:
'文章 ID{id}<br/><a href="{link}" target="_blank" rel="noopener noreferrer">打开 articleAdd 继续编辑</a>',
autoSubmitEditorRestorePwd: '编辑密码(恢复会话)',
autoSubmitEditorRestorePwdPh: '选填:与顶部当前登录名对应的编辑密码,用于上传成功后恢复服务端会话',
autoSubmitEditorReloginFail: '恢复编辑服务端会话失败,已尽量恢复本地信息,请刷新页面或重新登录。',
autoSubmitFail: '建稿失败,请检查账号密码或网络后重试。',
autoSubmitUsernameRequired: '请填写登录名',
autoSubmitPasswordRequired: '请填写密码',
autoSubmitJournalLabel: '目标期刊',
autoSubmitJournalUnknown: '未识别期刊(请先在邮件列表切换绑定期刊的邮箱账号)',
autoSubmitNeedJournal: '当前邮箱账号没有期刊信息,无法与投稿页一致回填。请通过「切换邮箱账号」选择绑定期刊的账号。',
autoSubmitFailPartial: '(若 contribute 已成功,文章 ID 可能为:{id},请到后台核对)',
autoSubmitManuscriptSource: '稿件文件',
autoSubmitPickLocalDocx: '本地上传 .docx',
autoSubmitSourceHint: '可选本地上传;否则使用邮件中第一份 .docx。再次选择会替换当前本机文件。',
autoSubmitLocalPicked: '当前本机文件:{name}',
autoSubmitNeedDocxSource: '请在本地上传 .docx或确保邮件中带有 .docx 附件。',
},
crawlerKeywords: {
pageTitle: '关键词配置',
@@ -1082,8 +984,8 @@ const zh = {
step3: '参考',
step: 'step',
Information: 'Fill in information',
startPreAccept: '开始预接收流程',
startPreAcceptWithPayment: '开始预接收流程并完成支付',
},
Formula:{
FormulaTemplate:'公式模版'
@@ -1254,55 +1156,6 @@ const zh = {
onlySaveConfig: '仅保存配置',
enableNowNextDay: '立即激活自动推广(次日开始自动推广)',
factoryCreateBtn: '创建自动化推广任务',
factoryBatchImportBtn: '临时批量导入',
factoryBatchImportTitle: '批量创建推广任务JSON',
factoryBatchImportHintShort: '数组提交;上方非空项会合并进每条请求,仍可直接改 JSON。',
factoryBatchImportHint:
'粘贴 JSON 数组…期刊用封面getAllJournal或手填 ID模板/样式/推广领域 fetch_ids 在下方非空则覆盖 JSON。推广领域可「加载可选领域」勾选或手改逗号 ID填期刊后可查邮箱 j_email_idgetAccounts。expert_type 为 5 时须分区或国家。简写zones、countries、email_id_list。',
factoryBatchImportCommonTip: '期刊以封面或下方 ID 为准;模板 ID、样式 ID、推广领域 fetch_ids 任一非空则覆盖每条 JSON 中对应字段后再请求接口。',
factoryBatchImportJournalId: '期刊 ID',
factoryBatchImportJournalPick: '选择期刊',
factoryBatchImportJournalEmpty: '未获取到期刊列表,请检查接口或稍后重试',
factoryBatchImportJournalManualPlaceholder: '点击上方封面自动填入,也可手改',
factoryBatchImportTemplateId: '模板 ID',
factoryBatchImportStyleId: '样式 ID',
factoryBatchImportJournalPlaceholder: '与 JSON 合并',
factoryBatchImportTemplatePlaceholder: '与 JSON 合并',
factoryBatchImportStylePlaceholder: '与 JSON 合并',
factoryBatchImportFetchIdsLabel: '推广领域 fetch_ids',
factoryBatchImportLoadFields: '加载可选领域',
factoryBatchImportFetchTip: '依赖当前期刊:先选期刊再加载。上方可搜索名称或 ID多关键词用空格或逗号有搜索时「全选」勾选当前筛选结果无搜索时「全选」为全部。勾选与下方逗号文本同步非空则覆盖每条 JSON 的 fetch_ids。',
factoryBatchImportFetchIdsManual: '合并用 ID逗号分隔可手改',
factoryBatchImportFetchIdsPlaceholder: '例1,2,3或与勾选联动',
factoryBatchImportNeedJournalForFields: '请先选择或填写期刊 ID',
factoryBatchImportLoadAccounts: '查询该期刊邮箱',
factoryBatchImportAccountsApiTip: 'POST api/email_client/getAccounts参数 journal_id',
factoryBatchImportColEmailId: 'j_email_id',
factoryBatchImportColAddress: '发件地址',
factoryBatchImportColQuota: '今日剩余 / 日上限',
factoryBatchImportCopyEmailIds: '复制 email_ids逗号',
factoryBatchImportNeedJournalForAccounts: '请先填写期刊 ID',
factoryBatchImportNoAccounts: '该期刊下暂无邮箱账号',
factoryBatchImportAccountsFail: '拉取邮箱列表失败',
factoryBatchImportCopyEmailIdsEmpty: '请先查询出账号列表',
factoryBatchImportIdsCopied: '已复制 j_email_id 列表到剪贴板',
factoryBatchImportCopyFail: '复制失败,请手动选中复制',
factoryBatchImportSyncToJson: '上方选项写入 JSON',
factoryBatchImportSyncFromJson: '首条 JSON 回显到上方',
factoryBatchImportSyncIncludeEmails: '写入时用当前邮箱列表覆盖每条 email_ids',
factoryBatchImportSyncTip: '写入后仍可单独改 JSON提交时若上方输入框非空仍会再合并覆盖。',
factoryBatchImportJsonFromUiOk: '已根据上方选项更新 JSON',
factoryBatchImportUiFromJsonOk: '已用首条 JSON 更新上方表单',
factoryBatchImportRun: '开始批量创建',
factoryBatchImportBadJson: 'JSON 解析失败,请检查括号与引号',
factoryBatchImportEmpty: '请至少包含一条对象',
factoryBatchImportMissing: '第 {index} 条缺少字段:{field}',
factoryBatchImportNeedFetch: '第 {index} 条:专家库需填写 fetch_ids推广领域',
factoryBatchImportNeedZone: '第 {index} 条专家库需至少填写分区或国家target_partitions / target_country_ids',
factoryBatchImportRowFail: '第 {index} 条创建失败:{msg}',
factoryBatchImportRowNetwork: '第 {index} 条请求异常',
factoryBatchImportDone: '完成:成功 {ok},失败 {fail}',
factoryBatchImportErrorsTitle: '失败明细(最多显示 8 条)',
factoryDialogTitle: '创建任务',
factoryJournal: '期刊',
factoryJournalPlaceholder: '请选择期刊',
@@ -1356,8 +1209,6 @@ const zh = {
factoryExpertYoungBoard: '青年编委',
factoryExpertAuthor: '作者',
factoryExpertDb: 'expert库',
factoryExpertYoungBoardBefore2025: '2025前青年编委',
factoryExpertAuthorBefore2025: '2025前作者',
factoryExpertJump: '查看',
factoryOfficialEmailTip: '此类型默认使用系统官方邮箱发送,无需选择邮箱账号。',
factoryScenario: '场景',
@@ -1377,7 +1228,6 @@ const zh = {
,
autoPromotionLogs: {
detail: '自动推广详情',
pipelineHistory: '流水线历史',
factoryTaskSelectPlaceholder: '选择推广任务',
configured: '已配置',
editConfig: '修改期刊自动推广配置',
@@ -1445,7 +1295,6 @@ const zh = {
taskLogState2: '失败',
taskLogState3: '退信',
taskLogState4: '取消',
logColIndex: '序号',
logColExpert: '专家信息',
logColSendTime: '发送时间',
logColPreparedAt: '预处理完成时间',
@@ -1472,6 +1321,9 @@ const zh = {
deleteLogSuccess: '删除成功',
deleteLogFailed: '删除失败',
noFailureReason: '暂无失败原因',
logDetailEditTitle: '编辑发送记录',
logDetailPreviewTitle: '预览发送记录',
logLoadFailed: '加载日志失败',
deletedSuccess: '已删除',
mockPromotionSubject: '自动推广:{journal}',
mockPromotionContent: '<p>亲爱的 {name}</p><p>请查看我们最新的期刊更新...</p>'
@@ -1483,6 +1335,32 @@ const zh = {
previewWithVariablesHint: '专家数据仅为示例,仅用于变量拼写检查。',
close: '关闭',
placeholder: '请输入邮件内容'
},
wordCite: {
noRefs: '参考文献列表尚未加载,请稍候再试或刷新页面',
missingBoundRef: '当前绑定的参考文献不存在,请重新引用',
notFoundById: '未查询到编号{id}相关参考文献',
selectRef: '选择参考文献',
originalOrder: '原排序',
reference: '参考文献',
uncited: '未引用',
cancel: '取消',
confirm: '确认',
remove: '移除',
selected: '已选择',
modifyRef: '修改引用',
removeRefTag: '移除引用',
citeUpdateFail: '未能更新正文中的引用标签,请重试或进入编辑修改',
matchBracketRefs: '自动链接参考文献',
matchBracketRefsDone: '已转换 {n} 处 [n] 为可点击角标',
matchBracketRefsNone: '正文中未检测到可转换的纯文本引用 [n]。若要自动编号并关联参考文献请使用工具栏中的「Reference」插入引用。',
removeRefNeedClickCite: '请先在正文中点击要删除的引用角标,再点「移除参考文献」。',
quickPickPlaceholder: '输入如 [5, 6, 10-15] 自动勾选对应参考文献',
quickPickApply: '链接参考文献',
quickPickClear: '清空勾选',
currentCiteNo: '当前序号',
locateInBody: '点击定位正文角标',
locateInRefHint: '在下方列表中高亮该条'
}

View File

@@ -136,16 +136,7 @@
<!-- <div v-else style="padding: 20px; box-sizing: border-box"></div> -->
</div>
</div>
<div class="content_box mt20 stepbox">
<!-- 文章引用 -->
<div class="con">
<h4 class="con-title">{{ this.$t('PreAccept.step2') }}</h4>
<p style="color: #505050; font-size: 14px; padding: 20px; box-sizing: border-box">
<el-button @click="goGenerateCharts(thisArtcleId)" icon="el-icon-edit" type="text">Edit</el-button>
</p>
<!-- <div v-else style="padding: 20px; box-sizing: border-box"></div> -->
</div>
</div>
<div class="content_box mt20 stepbox">
<!-- 文章引用 -->
<div class="con">
@@ -170,7 +161,16 @@
<!-- <div v-else style="padding: 20px; box-sizing: border-box"></div> -->
</div>
</div>
<div class="content_box mt20 stepbox">
<!-- 文章引用 -->
<div class="con">
<h4 class="con-title">{{ this.$t('PreAccept.step2') }}</h4>
<p style="color: #505050; font-size: 14px; padding: 20px; box-sizing: border-box">
<el-button @click="goGenerateCharts(thisArtcleId)" icon="el-icon-edit" type="text">Edit</el-button>
</p>
<!-- <div v-else style="padding: 20px; box-sizing: border-box"></div> -->
</div>
</div>
<!-- 答疑 -->
<div class="mt20 helpcontent">
<div class="flexbox">

File diff suppressed because it is too large Load Diff

View File

@@ -332,11 +332,11 @@ export default {
// refName: 'setFiveRef',
// rongCont: 'Modify the article body.'
// },
{
name: 'Text Proofread',
refName: 'setThreeRef',
rongCont: 'HTML layout.'
}
// {
// name: 'Text Proofread',
// refName: 'setThreeRef',
// rongCont: 'HTML layout.'
// }
// {
// name: 'Create Build',
// refName: 'setSevenRef',
@@ -1337,7 +1337,7 @@ export default {
// 5----重新获取加载参考文献
changeRefer(val) {
console.log('重新获取参考文献');
this.$api
.post('api/Production/getReferList', {
p_article_id: this.p_article_id
@@ -1348,7 +1348,7 @@ export default {
for (var i = 0; i < this.chanFerForm.length; i++) {
this.chanFerForm[i].edit_mark = 1;
}
console.log(this.chanFerForm);
})
.catch((err) => {
console.log(err);

View File

@@ -1433,7 +1433,7 @@ export default {
// 5----重新获取加载参考文献
changeRefer(val) {
console.log('重新获取参考文献');
this.$api
.post('api/Production/getReferList', {
p_article_id: this.p_article_id
@@ -1444,7 +1444,7 @@ export default {
for (var i = 0; i < this.chanFerForm.length; i++) {
this.chanFerForm[i].edit_mark = 1;
}
console.log(this.chanFerForm);
})
.catch((err) => {
console.log(err);

View File

@@ -1,5 +1,5 @@
<template>
<div class="article-detail-editor-page">
<div>
<div class="crumbs">
<div class="art_state_message_id" style="padding-left: 18px">
<font
@@ -915,38 +915,9 @@
}}</b>
<!-- <el-button type="text" @click="testedit" icon="el-icon-edit">Change</el-button> -->
</div>
<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>
<span>Repetition : </span>
<b>{{ form.repetition }}%</b>
</div>
<!--<br clear="both">
<el-button type="primary" @click="showResubmit" style="margin: 15px 0 0 0;">Resubmit the manuscript
@@ -966,38 +937,9 @@
}}</b>
<el-button style="padding: 0" type="text" @click="testedit" icon="el-icon-edit">Change</el-button>
</div>
<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>
<div>
<span>Repetition : </span>
<b>{{ form.repetition }}%</b>
<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>
@@ -1005,77 +947,6 @@
</a>
<el-button type="text" @click="changeRepe" icon="el-icon-edit">Change</el-button>
</div>
<div v-if="plagiarismListReady" class="plagiarism-check-shell">
<el-button
v-if="!plagiarismList.length"
class="plagiarism-auto-check-btn"
icon="el-icon-search"
:loading="plagiarismSubmitLoading"
@click="submitPlagiarismCheck"
>
{{ $t('articleListEditor.plagiarismAutoCheck') }}
</el-button>
<div
v-else
class="plagiarism-check-block"
v-loading="plagiarismListLoading"
element-loading-background="transparent"
>
<div class="plagiarism-check-header">
<span class="plagiarism-check-title">{{ $t('articleListEditor.plagiarismListTitle') }}</span>
<div class="plagiarism-check-actions">
<!-- <el-button
type="text"
size="mini"
icon="el-icon-document-checked"
:loading="plagiarismSubmitLoading"
@click="submitPlagiarismCheck"
>
{{ $t('articleListEditor.plagiarismDuplicateCheck') }}
</el-button> -->
<el-button
type="text"
size="mini"
icon="el-icon-refresh"
:loading="plagiarismListLoading"
@click="fetchPlagiarismList(true)"
>
{{ $t('articleListEditor.plagiarismRefresh') }}
</el-button>
</div>
</div>
<div class="plagiarism-check-list-wrap">
<div
v-for="row in plagiarismList"
:key="row.check_id || row.id"
class="plagiarism-check-row"
:title="row.source_file_name || ''"
>
<span class="plagiarism-sim-dot" :class="getPlagiarismSimilarityLevel(row)"></span>
<span class="plagiarism-sim-pct" :class="getPlagiarismSimilarityLevel(row)">
{{ formatPlagiarismSimilarity(row) }}
</span>
<span class="plagiarism-sim-date">{{ formatPlagiarismDate(row) }}</span>
<span class="plagiarism-sim-state" :class="getPlagiarismStateClass(row)">
{{ formatPlagiarismStateLabel(row) }}
</span>
<span class="plagiarism-sim-report">
<a
v-if="hasPlagiarismPdf(row)"
href="javascript:;"
class="plagiarism-report-preview"
:title="$t('articleListEditor.plagiarismPreviewOpenTab')"
@click.prevent="openPlagiarismReportPage(row)"
>
{{ $t('articleListEditor.plagiarismPreviewPdf') }}
<i class="el-icon-link"></i>
</a>
<span v-else class="plagiarism-check-no-pdf">{{ $t('articleListEditor.plagiarismNoPdfLink') }}</span>
</span>
</div>
</div>
</div>
</div>
<div>
<span style="display: inline-block; vertical-align: top; margin-top: 7px">Manuscript : </span>
<el-upload
@@ -1271,31 +1142,6 @@
<el-button type="primary" @click="saveRepe">Save</el-button>
</span>
</el-dialog>
<el-dialog
:title="plagiarismPdfPreviewTitle"
:visible.sync="plagiarismPdfPreviewVisible"
width="90%"
top="4vh"
append-to-body
custom-class="plagiarism-pdf-preview-dialog"
@closed="onPlagiarismPdfPreviewClosed"
>
<div v-loading="plagiarismPdfPreviewLoading" class="plagiarism-pdf-preview-body">
<iframe
v-if="plagiarismPdfPreviewUrl"
:src="plagiarismPdfPreviewUrl"
class="plagiarism-pdf-preview-iframe"
frameborder="0"
@load="plagiarismPdfPreviewLoading = false"
></iframe>
</div>
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="plagiarismPdfPreviewVisible = false">{{ $t('articleListEditor.plagiarismPreviewClose') }}</el-button>
<el-button size="small" type="primary" v-if="plagiarismPdfPreviewUrl" @click="openPlagiarismPdfInNewTab">
{{ $t('articleListEditor.plagiarismPreviewOpenTab') }}
</el-button>
</span>
</el-dialog>
<el-dialog
title="Resubmit the manuscript"
:visible.sync="resubmitVisible"
@@ -1580,8 +1426,6 @@
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,
@@ -1705,9 +1549,6 @@ export default {
approval_content: '',
is_figure_copyright: '',
repetition: '',
plagiarism_similarity: '',
plagiarism_report_url: '',
plagiarism_job_state: '',
manuscirpt: '',
remarks: '',
state: '',
@@ -1902,18 +1743,7 @@ export default {
underReview: ['1'],
finalDecision: ['1'],
is_figure_copyright: '',
figurecopyright_file: '',
plagiarismDetailPollTimer: null,
plagiarismDetailPending: false,
plagiarismList: [],
plagiarismListLoading: false,
plagiarismListReady: false,
plagiarismPollTimer: null,
plagiarismSubmitLoading: false,
plagiarismPdfPreviewVisible: false,
plagiarismPdfPreviewUrl: '',
plagiarismPdfPreviewTitle: '',
plagiarismPdfPreviewLoading: false
figurecopyright_file: ''
};
},
async created() {
@@ -1923,17 +1753,6 @@ export default {
this.getWordimgList();
this.getWordTablesList();
this.getFinalList();
this.startPlagiarismPolling();
},
activated() {
this.startPlagiarismPolling(false);
},
deactivated() {
this.stopPlagiarismPolling();
},
beforeDestroy() {
this.stopPlagiarismPolling();
this.detailStopPlagiarismPolling();
},
computed: {
// coverLetterUrl: function() {
@@ -2643,7 +2462,6 @@ export default {
background: 'rgba(0, 0, 0, 0.7)'
});
this.$api.post('api/Article/changeRepetition', this.repeform).then((res) => {
this.repebox = false;
load.close();
this.$message.success('success');
this.initarticle();
@@ -2723,26 +2541,6 @@ 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;
@@ -3058,364 +2856,6 @@ 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) {
this.plagiarismListReady = false;
this.plagiarismList = [];
}
this.fetchPlagiarismList(false);
this.plagiarismPollTimer = setInterval(() => {
this.fetchPlagiarismList(false);
}, 3 * 60 * 1000);
},
stopPlagiarismPolling() {
if (this.plagiarismPollTimer) {
clearInterval(this.plagiarismPollTimer);
this.plagiarismPollTimer = null;
}
},
async submitPlagiarismCheck() {
const articleId = String((this.editform && this.editform.articleId) || this.$route.query.id || '').trim();
if (!articleId) {
this.$message.warning(this.$t('articleListEditor.plagiarismReportDetailFailed'));
return;
}
this.plagiarismSubmitLoading = true;
try {
const res = await this.$api.post('api/Plagiarism/submit', { article_id: articleId });
if (res && Number(res.code) === 0) {
this.$message.success((res && res.msg) || this.$t('articleListEditor.plagiarismChecking'));
await this.fetchPlagiarismList(true);
} else {
this.$message.error((res && res.msg) || this.$t('articleListEditor.plagiarismCheckFailed'));
}
} catch (e) {
this.$message.error(this.$t('articleListEditor.plagiarismCheckFailed'));
} finally {
this.plagiarismSubmitLoading = false;
}
},
async fetchPlagiarismList(manual) {
const articleId = String((this.editform && this.editform.articleId) || this.$route.query.id || '').trim();
if (!articleId) {
this.plagiarismListReady = true;
return;
}
if (manual && this.plagiarismList.length) {
this.plagiarismListLoading = true;
}
try {
const res = await this.$api.post('api/Plagiarism/getList', { article_id: articleId });
if (res && Number(res.code) === 0) {
const payload = res.data || {};
const list = Array.isArray(payload.list) ? payload.list : Array.isArray(payload) ? payload : [];
this.plagiarismList = list;
} else if (manual) {
this.$message.error((res && res.msg) || this.$t('articleListEditor.plagiarismStatusFailed'));
}
} catch (e) {
if (manual) {
this.$message.error(this.$t('articleListEditor.plagiarismStatusFailed'));
}
} finally {
this.plagiarismListLoading = false;
this.plagiarismListReady = true;
}
},
formatPlagiarismState(state) {
const s = String(state != null ? state : '').trim();
if (!s) return '-';
return s;
},
formatPlagiarismStateLabel(row) {
if (!row || typeof row !== 'object') return '';
const label = String(row.state_label || row.stateLabel || '').trim();
if (label) return label;
const s = row.state;
if (s == null || String(s).trim() === '') return '';
return this.formatPlagiarismState(s);
},
getPlagiarismStateClass(row) {
const s = Number(row && row.state);
if (s === 1) return 'state-uploading';
if (s === 2 || s === 3) return 'state-done';
if (s === 4 || s === 5) return 'state-fail';
return '';
},
getPlagiarismSimilarityScore(row) {
if (!row || typeof row !== 'object') return null;
const raw = row.similarity_score != null ? row.similarity_score : row.similarity;
if (raw == null || String(raw).trim() === '') return null;
const n = Number(raw);
return isNaN(n) ? null : n;
},
formatPlagiarismSimilarity(row) {
const n = this.getPlagiarismSimilarityScore(row);
return n == null ? '' : n + '%';
},
/** Crossref 相似度色块0 绿、129 蓝、30+ 橙 */
getPlagiarismSimilarityLevel(row) {
const n = this.getPlagiarismSimilarityScore(row);
if (n == null) return 'sim-unknown';
if (n <= 0) return 'sim-zero';
if (n < 30) return 'sim-low';
return 'sim-high';
},
formatPlagiarismDate(row) {
if (!row || typeof row !== 'object') return '';
const raw =
row.finish_time ||
row.finished_at ||
row.update_time ||
row.ctime ||
row.create_time ||
row.created_at ||
'';
if (raw == null || String(raw).trim() === '') return '';
const s = String(raw).trim();
if (/^\d+$/.test(s)) {
const num = Number(s);
const ts = num > 1e12 ? num : num * 1000;
try {
return this.formatDate(Math.floor(ts / 1000));
} catch (e) {
return s;
}
}
const d = new Date(s.replace(/-/g, '/'));
if (!isNaN(d.getTime())) {
const pad = (v) => String(v).padStart(2, '0');
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
return s.length > 10 ? s.slice(0, 10) : s;
},
resolvePlagiarismPdfUrl(row) {
if (!row || typeof row !== 'object') return '';
const raw = String(row.viewer_url || row.local_pdf_url || row.localPdfUrl || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
let path = raw.replace(/^\/+/, '');
if (!/^public\//i.test(path)) {
const media = String(this.mediaUrl || '/public/').replace(/\/+$/, '');
if (/^https?:\/\//i.test(media)) {
return media + '/' + path;
}
path = (media.startsWith('/') ? media : '/' + media) + '/' + path;
} else {
path = '/' + path;
}
path = path.replace(/\/+/g, '/');
if (typeof window !== 'undefined' && window.location && window.location.origin && path.startsWith('/')) {
return window.location.origin + path;
}
return path;
},
hasPlagiarismPdf(row) {
return !!this.resolvePlagiarismPdfUrl(row);
},
openPlagiarismReportPage(row) {
const url = this.resolvePlagiarismPdfUrl(row);
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
},
openPlagiarismPdfPreview(row) {
const url = this.resolvePlagiarismPdfUrl(row);
if (!url) return;
const name = row && row.source_file_name ? String(row.source_file_name) : 'report.pdf';
const id = row && row.check_id != null ? row.check_id : '';
this.plagiarismPdfPreviewTitle =
this.$t('articleListEditor.plagiarismPreviewPdf') + (id ? ' #' + id : '') + ' - ' + name;
this.plagiarismPdfPreviewUrl = url;
this.plagiarismPdfPreviewLoading = true;
this.plagiarismPdfPreviewVisible = true;
},
onPlagiarismPdfPreviewClosed() {
this.plagiarismPdfPreviewUrl = '';
this.plagiarismPdfPreviewTitle = '';
this.plagiarismPdfPreviewLoading = false;
},
openPlagiarismPdfInNewTab() {
if (!this.plagiarismPdfPreviewUrl) return;
window.open(this.plagiarismPdfPreviewUrl, '_blank', 'noopener');
}
},
mounted() {
@@ -3480,169 +2920,6 @@ export default {
text-decoration: underline;
}
.plagiarism-check-shell {
margin: 0 0 10px 0;
}
.plagiarism-check-block >>> .el-loading-mask {
background-color: transparent !important;
}
.plagiarism-auto-check-btn.el-button {
display: block;
width: 200px;
border: none;
color: #fff;
font-size: 13px;
margin-left: 75px;
font-weight: 500;
letter-spacing: 0.3px;
padding: 11px 20px;
border-radius: 4px;
background: linear-gradient(135deg, #2ec4b6 0%, #0d9b8f 45%, #0a7f76 100%);
box-shadow: 0 2px 8px rgba(13, 155, 143, 0.35);
transition: opacity 0.2s ease, box-shadow 0.2s ease;
}
.plagiarism-auto-check-btn.el-button:hover,
.plagiarism-auto-check-btn.el-button:focus {
color: #fff;
background: linear-gradient(135deg, #3dd4c6 0%, #14b0a3 45%, #0e948a 100%);
box-shadow: 0 4px 12px rgba(13, 155, 143, 0.45);
}
.plagiarism-auto-check-btn.el-button.is-loading {
background: linear-gradient(135deg, #2ec4b6 0%, #0d9b8f 45%, #0a7f76 100%);
}
.plagiarism-check-block {
margin: 0 0 10px 0;
padding: 10px 12px;
background: #f8fafc;
border: 1px solid #e8edf3;
border-radius: 4px;
}
.plagiarism-check-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.plagiarism-check-actions {
display: flex;
align-items: center;
gap: 4px;
}
.plagiarism-check-actions .el-button {
padding: 0 6px;
}
.plagiarism-check-title {
font-size: 13px;
color: #606266;
font-weight: 500;
}
.plagiarism-check-list-wrap {
min-height: 32px;
}
.plagiarism-check-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-top: 1px solid #eef1f5;
font-size: 12px;
line-height: 20px;
}
.plagiarism-check-row:first-child {
border-top: none;
padding-top: 0;
}
.plagiarism-sim-dot {
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 1px;
}
.plagiarism-sim-dot.sim-zero {
background: #00a99d;
}
.plagiarism-sim-dot.sim-low {
background: #0070c0;
}
.plagiarism-sim-dot.sim-high {
background: #c87f0a;
}
.plagiarism-sim-dot.sim-unknown {
background: #c0c4cc;
}
.plagiarism-sim-pct {
flex-shrink: 0;
min-width: 36px;
font-weight: 600;
}
.plagiarism-sim-pct.sim-zero {
color: #00a99d;
}
.plagiarism-sim-pct.sim-low {
color: #0070c0;
}
.plagiarism-sim-pct.sim-high {
color: #c87f0a;
}
.plagiarism-sim-pct.sim-unknown {
color: #909399;
}
.plagiarism-sim-date {
flex-shrink: 0;
color: #909399;
}
.plagiarism-sim-state {
flex: 1;
min-width: 48px;
color: #909399;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plagiarism-sim-state.state-uploading {
color: #e6a23c;
}
.plagiarism-sim-state.state-done {
color: #67c23a;
}
.plagiarism-sim-state.state-fail {
color: #f56c6c;
}
.plagiarism-sim-report {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.plagiarism-report-preview {
color: #409eff;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.plagiarism-report-preview:hover {
text-decoration: underline;
color: #66b1ff;
}
.plagiarism-report-preview .el-icon-link {
font-size: 14px;
}
.plagiarism-check-no-pdf {
color: #f56c6c;
font-size: 12px;
}
.plagiarism-pdf-preview-body {
min-height: 70vh;
}
.plagiarism-pdf-preview-iframe {
width: 100%;
height: 70vh;
border: none;
background: #f5f7fa;
}
.el-upload__tip {
display: inline-block;
line-height: 32px;
@@ -4068,80 +3345,4 @@ 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

@@ -107,11 +107,11 @@
</span>
<span v-if="item.state == 6 && preAcceptButtonReady(item)" style="text-decoration: none;margin-left: 20px;">
<span v-if="item.state == 6" style="text-decoration: none;margin-left: 20px;">
<span @click="goPre_ingested(item.article_id)" class="preButton">
<!-- <el-badge is-dot class="item" > -->
<i class="el-icon-bank-card"></i>{{ preAcceptEntryButtonText(item) }}
<i class="el-icon-bank-card"></i>Start the pre-acceptance process and complete your payment
<!-- </el-badge> -->
</span>
</span>
@@ -539,61 +539,6 @@
this.getdate();
},
methods: {
/** 与 Complete_profile.vue getDetail 中订单/支付展示逻辑一致preOrderDetail → is_buy、期刊费、折后费、isFree */
_feeNum(v) {
if (v == null || v === '') return 0;
const n = Number(String(v).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
},
computePreacceptShortButton(articleInfo, journalInfo) {
if (!articleInfo || !journalInfo) return false;
const journalFee = journalInfo.fee;
const articleFee = articleInfo.fee;
const isBuy = Number(articleInfo.is_buy) === 1;
const tableFee = journalFee ? this._feeNum(articleFee) : 0;
const isFree = isBuy && tableFee === 0;
const noJournalFee = !journalFee || this._feeNum(journalFee) === 0 || String(journalFee) === '0.00';
// Complete_profile: active=1 当已付,或 未付但期刊无 APC
if (isFree) return true;
if (isBuy) return true;
if (!isBuy && noJournalFee) return true;
return false;
},
preAcceptButtonReady(item) {
const p = item._preacceptPay;
return !!(p && p.loading === false);
},
async hydratePreacceptPaymentForList(rows) {
if (!rows || !rows.length) return;
const targets = rows.filter((r) => Number(r.state) === 6);
await Promise.all(
targets.map(async (row) => {
try {
const res = await this.$api.post('api/Order/preOrderDetail', {
article_id: row.article_id
});
if (res && Number(res.code) === 0) {
const article = res.data.article_detail || {};
const journal = res.data.journal_detail || {};
const shortButton = this.computePreacceptShortButton(article, journal);
this.$set(row, '_preacceptPay', { loading: false, shortButton });
} else {
this.$set(row, '_preacceptPay', { loading: false, shortButton: false });
}
} catch (e) {
console.error(e);
this.$set(row, '_preacceptPay', { loading: false, shortButton: false });
}
})
);
},
preAcceptEntryButtonText(item) {
const pay = item._preacceptPay;
if (!pay || pay.loading) return '';
return pay.shortButton
? this.$t('PreAccept.startPreAccept')
: this.$t('PreAccept.startPreAcceptWithPayment');
},
formatToHtml(val) {
if (!val) return '';
@@ -667,12 +612,8 @@ return processedText;
1 + '-';
let D = date.getDate() < 10 ? '0' + date.getDate() : date.getDate();
res.data[i].ctime = Y + M + D;
if (Number(res.data[i].state) === 6) {
this.$set(res.data[i], '_preacceptPay', { loading: true, shortButton: false });
}
}
this.tableData = res.data;
this.hydratePreacceptPaymentForList(res.data);
this.tableData = res.data
})
.catch(err => {
console.log(err);

View File

@@ -116,36 +116,9 @@
<i class="el-icon-data-line"></i> Manuscript Tracking
</b>
<span style="float: right" class="plagiarism-header-bar">
<span style="float: right">
<span class="labelTitle" style="font-weight: 500; font-size: 13px">Plagiarism Check :</span>
<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>
<font style="margin-right: 16px; font-size: 13px; font-weight: 700"> {{ item.repetition }} % </font>
</span>
<span style="margin: 0 10px; float: right">| </span>
@@ -1092,7 +1065,6 @@
</template>
<script>
import axios from 'axios';
import { Loading } from 'element-ui';
import timetalk from './time_talk';
import commonRemarkList from './articleListEditor_A_list.vue';
@@ -1460,18 +1432,12 @@ export default {
editVisible1: false,
bankVisible: false,
majorData: {},
googleSearchInfo: '',
/** Crossref / plagiarism async polling */
plagiarismPollTimer: null,
plagiarismPendingIds: {}
googleSearchInfo: ''
};
},
created() {
this.getdate();
},
beforeDestroy() {
this.stopPlagiarismPolling();
},
computed: {
upload_zip: function () {
return this.baseUrl + 'api/Article/up_file/type/repezip';
@@ -2106,7 +2072,6 @@ export default {
this.tableData[i].reportList = this.tableData[i].reportList.slice(0, 3);
this.$forceUpdate();
}
this.initPlagiarismFromList();
loading.close();
})
@@ -2693,234 +2658,6 @@ 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');
},
//文章类型
@@ -3689,65 +3426,4 @@ 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>

File diff suppressed because it is too large Load Diff

View File

@@ -17,51 +17,24 @@
<span class="label">{{ $t('autoPromotion.journal') }} : </span>
{{ currentJournalName }}
<el-select
v-if="selectedJournalId"
v-if="config.initialized && selectedJournalId"
v-model="headerPromotionFactoryId"
class="header-factory-task-select custom-pipeline-select"
class="header-factory-task-select"
size="small"
filterable
:loading="factoryTasksHeaderLoading"
:placeholder="$t('autoPromotionLogs.factoryTaskSelectPlaceholder')"
@change="onHeaderFactoryTaskChange"
popper-class="pipeline-popper"
>
<el-option-group >
<el-option
v-for="opt in factoryTaskOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
class="pipeline-option"
>
<div class="option-content">
<div class="row-top">
<span class="task-title">{{ mapFactoryTaskTypeLabel(opt.task.type) }}</span>
<el-tag :type="getStatusType(opt.task.start_promotion)" size="mini" effect="plain" class="status-tag">
{{ opt.task.start_promotion==1 ? $t('autoPromotion.running') : $t('autoPromotion.stopped') }}
</el-tag>
</div>
<div class="row-bottom">
<span class="meta-item database">{{ mapFactoryExpertTypeLabel(opt.task.expert_type) }}</span>
<template v-if="opt.task.expert_type==5">
<span class="separator"></span>
<span class="meta-item region">{{ opt.task.country_scope_label }}</span>
</template>
<span class="separator"></span>
<span class="meta-item time">{{ opt.task.ctime_text }}</span>
</div>
</div>
</el-option>
</el-option-group>
<el-option
v-for="opt in factoryTaskOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-tag
v-if="selectedJournalId && headerFactoryTaskRunning !== null"
v-if="config.initialized && selectedJournalId && headerFactoryTaskRunning !== null"
:type="headerFactoryTaskRunning ? 'success' : 'info'"
size="small"
effect="plain"
@@ -72,12 +45,21 @@
{{ headerFactoryTaskRunning ? $t('autoPromotion.running') : $t('autoPromotion.stopped') }}
</el-tag>
<el-button type="text" size="small" style="margin-left: 10px" @click="openFactoryTaskDialogFromLogs">
<i class="el-icon-edit"></i>
{{ $t('autoPromotionLogs.editConfig') }}
</el-button>
<!-- <template v-if="config.initialized"> -->
<!-- <el-tag type="success" size="small" effect="plain" style="margin-left: 10px">
<i class="el-icon-circle-check"></i> {{ $t('autoPromotionLogs.configured') }}
</el-tag> -->
<el-button type="text" size="small" style="margin-left: 10px" @click="openFactoryTaskDialogFromLogs">
<i class="el-icon-edit"></i>
{{ config.initialized ? $t('autoPromotionLogs.editConfig') : $t('autoPromotionLogs.startConfig') }}
</el-button>
<!-- </template> -->
<!-- <el-tag v-else type="info" size="small" effect="plain" style="margin-left: 10px">
<i class="el-icon-info"></i> {{ $t('autoPromotionLogs.notConfigured') }}
</el-tag> -->
</div>
<div v-if="selectedJournalId && headerPromotionFactoryId" class="right">
<div v-if="config.initialized && selectedJournalId && headerPromotionFactoryId" class="right">
<el-button
type="primary"
size="small"
@@ -92,8 +74,34 @@
</el-card>
<div v-loading="loading" class="main-body">
<div class="manage-mode">
<el-card v-if="!config.initialized" shadow="never" class="wizard-card">
<auto-promotion-wizard
mode="inline"
:config="config"
:wizardStartDate.sync="wizardStartDate"
:selectedFieldIds.sync="selectedFieldIds"
:selectedCountryIds.sync="selectedCountryIds"
:availableFields="availableFields"
:availableCountries="availableCountries"
:fieldsLoading="fieldsLoading"
:fieldsSaving="fieldsSaving"
:currentJournalName="currentJournalName"
:selectedTemplateThumbHtml="selectedTemplateThumbHtml"
:selectedTemplateName="selectedTemplateName"
:selectedStyleName="selectedStyleName"
:saving="saving"
:title="$t('autoPromotion.title')"
@open-template-selector="showTemplateDialog = true"
@confirm-fields="savePromotionFieldsNow"
@confirm-countries="savePromotionCountriesNow"
@confirm="completeInitialization"
/>
</el-card>
<div v-else class="manage-mode">
<el-card shadow="never" class="list-card">
<div class="filter-header-row">
<div class="tmr-capsule-group">
<el-tabs v-model="query.state" type="card" @tab-click="handleTabClick">
@@ -127,9 +135,7 @@
<div class="task-column">
<div class="task-name">{{ scope.row.task_name || '-' }}</div>
<div class="task-id-tags">
<span class="id-tag"
>{{ $t('autoPromotionLogs.templateIdLabel') }}: {{ scope.row.template_id }}</span
>
<span class="id-tag">{{ $t('autoPromotionLogs.templateIdLabel') }}: {{ scope.row.template_id }}</span>
<span class="id-tag">{{ $t('autoPromotionLogs.styleIdLabel') }}: {{ scope.row.style_id }}</span>
</div>
</div>
@@ -218,11 +224,7 @@
:icon="String(scope.row.state) === '5' ? 'el-icon-edit-outline' : 'el-icon-view'"
@click="previewRow(scope.row)"
>
{{
String(scope.row.state) === '5'
? $t('autoPromotionLogs.editAction')
: $t('autoPromotionLogs.previewAction')
}}
{{ String(scope.row.state) === '5' ? $t('autoPromotionLogs.editAction') : $t('autoPromotionLogs.previewAction') }}
</el-button>
</div>
</template>
@@ -246,6 +248,30 @@
</div>
</div>
<auto-promotion-wizard
mode="dialog"
:visible.sync="showWizardDialog"
:config="config"
:wizardStartDate.sync="wizardStartDate"
:selectedFieldIds.sync="selectedFieldIds"
:selectedCountryIds.sync="selectedCountryIds"
:availableFields="availableFields"
:availableCountries="availableCountries"
:fieldsLoading="fieldsLoading"
:fieldsSaving="fieldsSaving"
:currentJournalName="currentJournalName"
:selectedTemplateThumbHtml="selectedTemplateThumbHtml"
:selectedTemplateName="selectedTemplateName"
:selectedStyleName="selectedStyleName"
:saving="saving"
:title="$t('autoPromotion.title')"
@open-template-selector="showTemplateDialog = true"
@confirm-fields="savePromotionFieldsNow"
@confirm-countries="savePromotionCountriesNow"
@cancel="showWizardDialog = false"
@confirm="completeInitialization"
/>
<template-selector-dialog
v-if="showTemplateDialog"
:visible.sync="showTemplateDialog"
@@ -311,6 +337,7 @@
<script>
import CkeditorMail from '@/components/page/components/email/CkeditorMail.vue';
import TemplateSelectorDialog from '@/components/page/components/email/TemplateSelectorDialog.vue';
import AutoPromotionWizard from '@/components/page/components/autoPromotion/AutoPromotionWizard.vue';
import PromotionFactoryTaskDialog from '@/components/page/components/autoPromotion/PromotionFactoryTaskDialog.vue';
import PromotionDetailDrawer from '@/components/page/components/autoPromotion/PromotionDetailDrawer.vue';
// 这里假设你已经定义了 API 地址
@@ -324,8 +351,8 @@ const API = {
};
export default {
name: 'autoPromotionLogs',
components: { TemplateSelectorDialog, PromotionFactoryTaskDialog, CkeditorMail, PromotionDetailDrawer },
name: 'autoPromotion',
components: { TemplateSelectorDialog, AutoPromotionWizard, PromotionFactoryTaskDialog, CkeditorMail, PromotionDetailDrawer },
data() {
return {
handleRefreshList: [],
@@ -400,50 +427,15 @@ export default {
const opt = this.factoryTaskOptions.find((o) => String(o.value) === id);
if (opt && typeof opt.running === 'boolean') return opt.running;
return null;
},
/**
* 顶部 select 前缀展示用:与 headerPromotionFactoryId 对应的 option含 task
* URL 回退插入的占位项可能没有 task这里统一成可安全渲染的对象。
*/
currentSelectedTask() {
const emptyTask = { type: '', ctime_text: '' };
const id = String(this.headerPromotionFactoryId || '').trim();
if (!id || !Array.isArray(this.factoryTaskOptions) || !this.factoryTaskOptions.length) {
return { value: '', label: '', running: false, task: { ...emptyTask } };
}
const opt = this.factoryTaskOptions.find((o) => String(o.value) === id);
if (!opt) {
return { value: id, label: id, running: false, task: { ...emptyTask } };
}
const raw = opt.task && typeof opt.task === 'object' ? opt.task : {};
const ctimeText =
raw.ctime_text != null && String(raw.ctime_text).trim() !== ''
? String(raw.ctime_text).trim()
: this.formatFactoryHeaderTaskCreateTime(raw);
return {
value: opt.value,
label: opt.label,
running: typeof opt.running === 'boolean' ? opt.running : this.isFactoryHeaderTaskRunning(raw),
task: {
...raw,
type: raw.type != null ? String(raw.type) : '',
ctime_text: ctimeText || ''
}
};
}
},
watch: {
'$route.query.journal_id'(val) {
const s = String(val != null ? val : '').trim();
if (s === String(this.selectedJournalId || '').trim()) return;
this.initPage();
},
'$route.query.promotion_factory_id'(val) {
const s = String(val != null ? val : '').trim();
if (s === String(this.headerPromotionFactoryId || '').trim()) return;
this.headerPromotionFactoryId = s;
this.routePromotionFactoryId = s;
if (this.selectedJournalId) {
if (this.config.initialized && this.selectedJournalId) {
this.query.pageIndex = 1;
this.fetchList();
}
@@ -452,58 +444,13 @@ export default {
created() {
this.initPage();
},
activated() {
const routeJid = String((this.$route.query && this.$route.query.journal_id) || '').trim();
const curJid = String(this.selectedJournalId || '').trim();
if (routeJid && routeJid !== curJid) {
this.initPage();
return;
}
const routePid = String(
(this.$route.query && this.$route.query.promotion_factory_id) ||
(this.$route.query && this.$route.query.taskId) ||
''
).trim();
const curPid = String(this.headerPromotionFactoryId || '').trim();
if (routePid && routePid !== curPid) {
this.headerPromotionFactoryId = routePid;
this.routePromotionFactoryId = routePid;
if (this.selectedJournalId) {
this.query.pageIndex = 1;
this.fetchList();
}
}
},
methods: {
mapFactoryTaskTypeLabel(type) {
const t = String(type || '');
if (t === '1') return this.$t('autoPromotion.factoryScenarioSolicit');
if (t === '2') return this.$t('autoPromotion.factoryScenarioPromoteCitation');
if (t === '3') return this.$t('autoPromotion.factoryScenarioGeneralThanks');
if (t === '4') return this.$t('autoPromotion.autoSolicit');
return this.$t('autoPromotion.autoSolicit');
},
mapFactoryExpertTypeLabel(expertType) {
const t = String(expertType || '').trim();
if (t === '1') return this.$t('autoPromotion.factoryExpertChief');
if (t === '2') return this.$t('autoPromotion.factoryExpertBoard');
if (t === '3') return this.$t('autoPromotion.factoryExpertYoungBoard');
if (t === '4') return this.$t('autoPromotion.factoryExpertAuthor');
if (t === '5') return this.$t('autoPromotion.factoryExpertDb');
if (t === '6') return this.$t('autoPromotion.factoryExpertYoungBoardBefore2025');
if (t === '7') return this.$t('autoPromotion.factoryExpertAuthorBefore2025');
return '-';
},
getStatusType(status) {
return status==1?'success':'info';
},
handleTabClick(tab) {
// tab.name 对应的就是原来的 value ("0", "1" 等)
// 注意el-tabs 的 v-model 绑定的是字符串
this.query.state = tab.name;
this.handleStateChange(); // 触发你原有的搜索逻辑
},
// tab.name 对应的就是原来的 value ("0", "1" 等)
// 注意el-tabs 的 v-model 绑定的是字符串
this.query.state = tab.name;
this.handleStateChange(); // 触发你原有的搜索逻辑
},
getPercent(row) {
if (!row.total_count || row.total_count === 0) return 0;
// 计算已发送占比
@@ -607,7 +554,9 @@ export default {
this.hidePage = false;
var journal_id = (this.$route.query && this.$route.query.journal_id) || '';
var pfid =
(this.$route.query && this.$route.query.promotion_factory_id) || (this.$route.query && this.$route.query.taskId) || '';
(this.$route.query && this.$route.query.promotion_factory_id) ||
(this.$route.query && this.$route.query.taskId) ||
'';
this.routePromotionFactoryId = String(pfid || '');
this.headerPromotionFactoryId = this.routePromotionFactoryId;
this.selectedJournalId = String(journal_id);
@@ -615,6 +564,10 @@ export default {
try {
await this.fetchJournalDetail();
if (this.selectedJournalId) {
this.loadPromotionFields(this.selectedJournalId);
}
if (this.config.initialized) {
await this.fetchTemplates();
await this.fetchFactoryTasksForHeader();
await this.fetchList();
}
@@ -709,7 +662,7 @@ export default {
}
if (!dt || isNaN(dt.getTime())) return String(raw).trim();
const pad = (v) => String(v).padStart(2, '0');
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`;
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
},
isFactoryHeaderTaskRunning(task) {
if (!task || typeof task !== 'object') return false;
@@ -723,12 +676,9 @@ export default {
},
/** 下拉仅展示「类型 - 创建日期」,运行状态单独用 el-tag */
buildFactoryHeaderOptionMainLabel(task, pidFallback) {
const typePart = this.getFactoryHeaderTaskTypeLabel(task) || String(pidFallback || '').trim() || '—';
const expertTypePart = this.mapFactoryExpertTypeLabel(task.expert_type);
const datePart = this.formatFactoryHeaderTaskCreateTime(task);
return datePart ? `${typePart} | ${expertTypePart}${task.expert_type==5 ? ` | ${task.country_scope_label} ` :''} | ${datePart}` : typePart;
return datePart ? `${typePart} - ${datePart}` : typePart;
},
replacePromotionFactoryIdInUrl(promotionFactoryId) {
try {
@@ -740,7 +690,7 @@ export default {
}
},
async fetchFactoryTasksForHeader() {
if (!this.selectedJournalId) {
if (!this.selectedJournalId || !this.config.initialized) {
this.factoryTaskOptions = [];
return;
}
@@ -754,20 +704,18 @@ export default {
});
const payload = (res && res.data) || {};
const list = this.findArray(payload) || this.findArray(res) || [];
let opts = (Array.isArray(list) ? list : [])
.map((task, idx) => {
const pid =
task && task.promotion_factory_id != null
? String(task.promotion_factory_id)
: task && task.id != null
? String(task.id)
: '';
if (!pid) return null;
const label = this.buildFactoryHeaderOptionMainLabel(task, pid);
const running = this.isFactoryHeaderTaskRunning(task);
return { value: pid, label, running,task:task }
})
.filter(Boolean);
let opts = (Array.isArray(list) ? list : []).map((task, idx) => {
const pid =
task && task.promotion_factory_id != null
? String(task.promotion_factory_id)
: task && task.id != null
? String(task.id)
: '';
if (!pid) return null;
const label = this.buildFactoryHeaderOptionMainLabel(task, pid);
const running = this.isFactoryHeaderTaskRunning(task);
return { value: pid, label, running };
}).filter(Boolean);
let cur = String(this.routePromotionFactoryId || this.headerPromotionFactoryId || '').trim();
const ids = new Set(opts.map((o) => o.value));
@@ -851,14 +799,10 @@ export default {
selectedPayload.country_fetch_ids != null
? selectedPayload.country_fetch_ids
: selectedPayload.country_ids != null
? selectedPayload.country_ids
: '';
? selectedPayload.country_ids
: '';
if (typeof raw === 'string' && raw.trim()) {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(String);
return raw.split(',').map((s) => s.trim()).filter(Boolean).map(String);
}
return [];
},
@@ -881,7 +825,7 @@ export default {
let availableArr = this.findArray(availablePayload);
if (!availableArr) availableArr = Array.isArray(availablePayload) ? availablePayload : [];
this.availableFields = availableArr.map((item, idx) => {
const id = item.expert_fetch_id || item.fetch_id || item.id || item.field_id || idx + 1;
const id = item.expert_fetch_id || item.fetch_id || item.id || item.field_id || (idx + 1);
const label = item.field || item.title || item.name || item.label || String(id);
return { id: String(id), label };
});
@@ -954,10 +898,10 @@ export default {
matched.promotion_factory_id != null
? String(matched.promotion_factory_id)
: matched.id != null
? String(matched.id)
: matched.task_id != null
? String(matched.task_id)
: '';
? String(matched.id)
: matched.task_id != null
? String(matched.task_id)
: '';
}
}
// promotion_factory/getDetail 必须使用地址栏 promotion_factory_id避免列表首行 id 与路由不一致
@@ -1104,7 +1048,11 @@ export default {
const runAt = item.run_at || item.run_time || item.plan_time || item.execute_time || item.send_date || '';
const state = String(item.state != null ? item.state : '');
const promotionFactoryId =
item.promotion_factory_id != null ? item.promotion_factory_id : item.id != null ? item.id : item.task_id;
item.promotion_factory_id != null
? item.promotion_factory_id
: item.id != null
? item.id
: item.task_id;
return {
id: item.id || item.task_id || `task_${idx + 1}`,
promotion_factory_id: promotionFactoryId != null ? String(promotionFactoryId) : '',
@@ -1258,16 +1206,16 @@ export default {
return Object.prototype.hasOwnProperty.call(stateTextMap, key) ? stateTextMap[key] : '-';
},
getTaskStatusClass(state) {
const stateClassMap = {
0: 'status-draft', // Draft - 浅灰色
5: 'status-preparing', // Preparing - 橘色
1: 'status-running', // Running - 深蓝色
2: 'status-paused', // Paused - 深灰色
3: 'status-completed', // Completed - 绿色
4: 'status-cancelled' // Cancelled - 浅红色
};
return stateClassMap[Number(state)] || '';
}
const stateClassMap = {
0: 'status-draft', // Draft - 浅灰色
5: 'status-preparing', // Preparing - 橘色
1: 'status-running', // Running - 深蓝色
2: 'status-paused', // Paused - 深灰色
3: 'status-completed', // Completed - 绿色
4: 'status-cancelled' // Cancelled - 浅红色
};
return stateClassMap[Number(state)] || '';
}
}
};
</script>
@@ -1281,13 +1229,11 @@ export default {
margin-bottom: 15px;
}
.config-bar {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.config-bar .left {
width: calc(100% - 100px);
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -1812,96 +1758,96 @@ export default {
}
.filter-header-row {
display: flex;
align-items: center; /* 垂直居中对齐 */
gap: 16px; /* 胶囊和Search按钮之间的间距 */
margin-bottom: 16px;
background-color: transparent; /* 容器透明,不厚重 */
display: flex;
align-items: center; /* 垂直居中对齐 */
gap: 16px; /* 胶囊和Search按钮之间的间距 */
margin-bottom: 16px;
background-color: transparent; /* 容器透明,不厚重 */
}
/* 2. 彻底重置 el-tabs 的原生样式 (最丑的地方) */
.tmr-capsule-group {
flex: 1; /* 占据左侧空间 */
flex: 1; /* 占据左侧空间 */
}
/* 强制隐藏默认灰色横线和卡片灰边 */
.tmr-capsule-group .el-tabs__header {
margin: 0 !important;
border-bottom: none !important;
margin: 0 !important;
border-bottom: none !important;
}
.tmr-capsule-group .el-tabs__nav {
border: none !important;
border: none !important;
}
/* 3. 重新定义每个 Tab 的样式 (让其变成按钮) */
.tmr-capsule-group .el-tabs--card > .el-tabs__header .el-tabs__item {
height: 32px !important; /* 紧凑高度 */
line-height: 32px !important;
font-size: 13px; /* 紧凑字体 */
border: none !important; /* 彻底隐藏原生卡片边框 */
background-color: transparent; /* 默认状态下透明背景 */
color: #515a6e; /* 默认状态深灰文字,更专业 */
transition: all 0.2s ease-in-out;
padding: 0 16px !important; /* 适当内边距 */
margin-right: 8px; /* 每个 Tab 之间的间距 */
border-radius: 6px !important; /* 先统一圆角 */
overflow: visible; /* 确保选中的阴影显示完全 */
height: 32px !important; /* 紧凑高度 */
line-height: 32px !important;
font-size: 13px; /* 紧凑字体 */
border: none !important; /* 彻底隐藏原生卡片边框 */
background-color: transparent; /* 默认状态下透明背景 */
color: #515a6e; /* 默认状态深灰文字,更专业 */
transition: all 0.2s ease-in-out;
padding: 0 16px !important; /* 适当内边距 */
margin-right: 8px; /* 每个 Tab 之间的间距 */
border-radius: 6px !important; /* 先统一圆角 */
overflow: visible; /* 确保选中的阴影显示完全 */
}
/* 首尾 Tab 的圆角处理 (形成整体感) */
.tmr-capsule-group .el-tabs--card > .el-tabs__header .el-tabs__item:first-child {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
}
.tmr-capsule-group .el-tabs--card > .el-tabs__header .el-tabs__item:last-child {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
margin-right: 0;
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
margin-right: 0;
}
/* 中间 Tab 的处理 (去掉左右圆角,紧凑对齐) */
.tmr-capsule-group .el-tabs--card > .el-tabs__header .el-tabs__item:not(:first-child):not(:last-child) {
border-radius: 0;
border-radius: 0;
}
/* 4. **选中效果 (Active) - 胶囊浮动核心** */
.tmr-capsule-group .el-tabs--card > .el-tabs__header .el-tabs__item.is-active {
background-color: #ffffff !important; /* 白色底色 */
color: #409eff !important; /* 主题蓝色文字 */
font-weight: 500;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; /* **关键:增加轻微阴影,浮动感** */
border-radius: 6px; /* 选中时恢复圆角 */
position: relative;
z-index: 2; /* 确保选中态在最前面,不被覆盖线破坏 */
background-color: #ffffff !important; /* 白色底色 */
color: #409eff !important; /* 主题蓝色文字 */
font-weight: 500;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; /* **关键:增加轻微阴影,浮动感** */
border-radius: 6px; /* 选中时恢复圆角 */
position: relative;
z-index: 2; /* 确保选中态在最前面,不被覆盖线破坏 */
}
/* 5. 处理 Tab 之间的断层 (像素级微调) */
.tmr-capsule-group .el-tabs--card > .el-tabs__header .el-tabs__item {
position: relative;
position: relative;
}
/* 首尾 Tab 之间的像素级处理 */
.tmr-capsule-group .el-tabs--card > .el-tabs__header .el-tabs__item:not(:last-child)::after {
content: '';
position: absolute;
right: -5px; /* 让 Tab 之间紧密无缝 */
top: 0;
height: 100%;
width: 1px;
background: transparent; /* 移除灰线,不丑 */
content: '';
position: absolute;
right: -5px; /* 让 Tab 之间紧密无缝 */
top: 0;
height: 100%;
width: 1px;
background: transparent; /* 移除灰线,不丑 */
}
/* 6. 搜索按钮对齐微调 */
.filter-actions .el-button {
height: 32px; /* 与 Tab 高度一致 */
padding: 0 16px;
border-radius: 6px;
transition: all 0.2s;
height: 32px; /* 与 Tab 高度一致 */
padding: 0 16px;
border-radius: 6px;
transition: all 0.2s;
}
.filter-actions {
margin-left: auto;
margin-left: auto;
}
/* 基础 Badge 样式 */
.status-badge {
@@ -1948,25 +1894,21 @@ export default {
background-color: #f1f5f9;
color: #64748b;
}
.status-draft::before {
background-color: #94a3b8;
}
.status-draft::before { background-color: #94a3b8; }
/* 5: Preparing - 橘色 */
.status-preparing {
background-color: #fff7ed;
color: #c2410c;
}
.status-preparing::before {
background-color: #f97316;
}
.status-preparing::before { background-color: #f97316; }
/* 1: Running - 深蓝色 */
.status-running {
background-color: #eff6ff;
color: #1d4ed8;
}
.status-running::before {
.status-running::before {
background-color: #3b82f6;
box-shadow: 0 0 4px rgba(59, 130, 246, 0.5); /* 模拟运行中的发光感 */
}
@@ -1977,104 +1919,19 @@ export default {
color: #334155;
border: 1px solid #e2e8f0; /* 停用状态加个微边框增加分量感 */
}
.status-paused::before {
background-color: #475569;
}
.status-paused::before { background-color: #475569; }
/* 3: Completed - 绿色 */
.status-completed {
background-color: #f0fdf4;
color: #15803d;
}
.status-completed::before {
background-color: #22c55e;
}
.status-completed::before { background-color: #22c55e; }
/* 4: Cancelled - 浅红色 */
.status-cancelled {
background-color: #fef2f2;
color: #b91c1c;
}
.status-cancelled::before {
background-color: #ef4444;
}
/* 基础 Select 宽度 */
.custom-pipeline-select {
width: 580px;
}
/* 分组标题el-option-group避免在 ul 内放 div */
.pipeline-popper .el-select-group__title {
padding: 10px 20px;
font-size: 12px;
font-weight: bold;
color: #909399;
letter-spacing: 1px;
border-bottom: 1px solid #f0f2f5;
line-height: 1.2;
}
/* 单个选项容器 */
.pipeline-popper .el-select-dropdown__item {
height: auto; /* 允许高度自适应 */
padding: 12px 20px;
line-height: normal;
border-left: 3px solid transparent; /* 预留边框位置防止抖动 */
}
/* 选中状态 */
.pipeline-popper .el-select-dropdown__item.selected {
background-color: #f5f7fa;
border-left: 3px solid #409eff; /* 选中时的左侧蓝条 */
color: #606266; /* 覆盖 Element 默认高亮色 */
}
/* 选项内容布局 */
.pipeline-popper .option-content {
display: flex;
flex-direction: column;
gap: 6px;
}
/* 第一行:标题 + 状态标签 */
.pipeline-popper .option-content .row-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.pipeline-popper .option-content .row-top .task-title {
font-weight: 600;
color: #303133;
font-size: 14px;
}
.pipeline-popper .option-content .row-top .status-tag {
border-radius: 12px;
padding: 0 8px;
height: 20px;
line-height: 18px;
}
/* 第二行:元数据信息 */
.pipeline-popper .option-content .row-bottom {
display: flex;
align-items: center;
font-size: 11px;
color: #909399;
}
.pipeline-popper .option-content .row-bottom .meta-item.database {
color: #5856d6;
font-weight: bold;
/* text-transform: uppercase; */
}
.pipeline-popper .option-content .row-bottom .separator {
margin: 0 6px;
color: #dcdfe6;
}
.status-cancelled::before { background-color: #ef4444; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,17 @@
<template>
<div class="tinymce-container editor-container">
<textarea class="tinymce-textarea" :id="tinymceId"></textarea>
<div class="tinymce-editor-surface">
<textarea class="tinymce-textarea" :id="tinymceId"></textarea>
</div>
<div
v-if="showRefButton && hasReferencesForAutoLink"
ref="autoLinkFooterWrap"
class="tinymce-autolink-footer"
>
<span @click="handleAutoLinkRefsClick" style="cursor: pointer;color: #409eff;font: 12px;font-weight: 700;">
<i class="el-icon-link"></i> {{ $t('wordCite.matchBracketRefs') }}
</span>
</div>
</div>
</template>
<script>
@@ -63,6 +74,107 @@ export default {
},
articleId: {
default: ''
},
chanFerForm: {
type: Array,
default: () => []
},
/** 由父级根据全文(稿面 + Edit Content 草稿合并)扫描得到,与 word.vue citeMap 一致 */
bodyCiteIdOrder: {
type: Array,
default: () => []
},
/** mytable(data-id) -> 该表内已出现引用的最大全局序号(由父组件计算) */
tableLinkCiteMaxMap: {
type: Object,
default: () => ({})
},
/** 为 false 时不显示 Ref 工具栏按钮,也不自动在 LateX 后注入 insertRef用于图表标题等 */
showRefButton: {
type: Boolean,
default: true
},
/**
* true仅「表格单元格编辑」场景角标/自动匹配 [n] 按 old_index+1或 order_index
* false与稿面一致始终用 bodyCiteIdOrder 的 citeMapEdit Content 等虽 type=table 放宽标签,也必须 false
*/
useTableLocalCitationIndex: {
type: Boolean,
default: false
}
},
computed: {
/** 是否启用表格局部序号(与全文 citeMap 互斥) */
tableLocalCiteActive() {
if (this.type !== 'table' || !this.useTableLocalCitationIndex) return false;
return Object.keys(this.tableBracketNumToRefIdMap || {}).length > 0;
},
citeMap() {
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
const refIdSet = new Set(
refs.map((r) => (r && r.p_refer_id != null ? String(r.p_refer_id) : '')).filter(Boolean)
);
if (!Array.isArray(this.bodyCiteIdOrder) || this.bodyCiteIdOrder.length === 0) {
const map = {};
refs.forEach((row, idx) => {
const key = row && row.p_refer_id != null ? String(row.p_refer_id) : '';
if (key) map[key] = idx + 1;
});
return map;
}
const orderedIds = this.bodyCiteIdOrder.map(String);
const filtered = [];
orderedIds.forEach((id) => {
if (refIdSet.has(id) && !filtered.includes(id)) filtered.push(id);
});
const map = {};
filtered.forEach((id, idx) => {
map[id] = idx + 1;
});
let next = filtered.length + 1;
refs.forEach((r) => {
const key = r && r.p_refer_id != null ? String(r.p_refer_id) : '';
if (!key || map[key] != null) return;
map[key] = next++;
});
return map;
},
/** 有参考文献列表时才显示「自动链接」:与 insertRef 一致,依赖 chanFerForm 中有效 p_refer_id */
hasReferencesForAutoLink() {
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
return refs.some((r) => r && r.p_refer_id != null && String(r.p_refer_id).trim() !== '');
},
/**
* 表格编辑器专用:单元格 [n] 中的 n → p_refer_id。
* - 有 old_index0 起n = old_index + 1
* - 仅有 order_index 时:按接口约定已为与 [n] 一致的序号n = order_index不再 +1
*/
tableBracketNumToRefIdMap() {
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
const map = {};
refs.forEach((r) => {
if (!r || r.p_refer_id == null) return;
const n = this._getTableBracketNoForRow(r);
if (n == null) return;
map[n] = String(r.p_refer_id);
});
return map;
},
/** p_refer_id → 表格角标数字 n与 [n] 一致) */
tableRefIdToBracketNum() {
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
const m = {};
refs.forEach((r) => {
if (!r || r.p_refer_id == null) return;
const n = this._getTableBracketNoForRow(r);
if (n == null) return;
m[String(r.p_refer_id)] = n;
});
return m;
}
},
data() {
@@ -124,22 +236,52 @@ export default {
hasChange: false,
hasInit: false,
editorInstance: null,
tinymceId: this.id || 'vue-tinymce-' + +new Date()
tinymceId: this.id || 'vue-tinymce-' + +new Date(),
/** 将底部栏移入 TinyMCE 容器前,记录原 DOM 位置以便销毁时移回 */
_autoLinkFooterRestore: null
};
},
watch: {
value: {
handler(val) {
if (!this.hasChange && this.hasInit) {
if (!this.hasChange && this.hasInit) {
this.$nextTick(() => {
this.handleSetContent(val);
});
}
},
immediate: true
},
chanFerForm: {
handler() {
this.$nextTick(() => {
window.tinymce.get(this.tinymceId).setContent(val);
if (this.editorInstance) {
this.renderAutociteInEditor(this.editorInstance);
}
});
},
deep: true
},
bodyCiteIdOrder: {
handler() {
this.$nextTick(() => {
if (this.editorInstance) {
this.renderAutociteInEditor(this.editorInstance);
}
});
},
deep: true
},
hasReferencesForAutoLink(val) {
if (!val) {
this.restoreAutoLinkFooterDom();
return;
}
if (this.showRefButton && this.editorInstance) {
this.$nextTick(() => {
this.mountAutoLinkFooterInsideEditor(this.editorInstance);
});
}
},
immediate: true
}
},
mounted() {
@@ -163,10 +305,526 @@ export default {
this.destroyTinymce();
},
methods: {
parseAutociteDataIds(attr) {
if (!attr || typeof attr !== 'string') return [];
return attr
.split(',')
.map((s) => s.trim())
.filter(Boolean);
},
/**
* 与正文 [n] 一致的角标数字 n。old_index 为 0 起 → n=old_index+1无 old_index 时用 order_index已为 n.
*/
_getTableBracketNoForRow(r) {
if (!r) return null;
const pick = (v) => {
if (v == null || v === '') return null;
const x = Number(v);
return Number.isNaN(x) ? null : x;
};
const oi =
r.old_index != null && r.old_index !== ''
? pick(r.old_index)
: pick(r.oldIndex);
if (oi != null) return oi + 1;
const ord =
r.order_index != null && r.order_index !== ''
? pick(r.order_index)
: pick(r.orderIndex);
return ord;
},
/** 与 word.vue renderCiteLabels合并角标 id 按 citeMap 序号排序 */
sortAutociteIdsByCiteNumber(ids) {
const uniq = [...new Set(ids.map(String))];
const map = this.citeMap || {};
return uniq.sort((a, b) => {
const na = map[a];
const nb = map[b];
const ha = na != null && na !== '';
const hb = nb != null && nb !== '';
if (ha && hb) return Number(na) - Number(nb);
if (ha) return -1;
if (hb) return 1;
return String(a).localeCompare(String(b));
});
},
/** 同 word.vue多 id 均在列表中时对全局序号做 13 区间合并 */
formatCiteNumbers(nums) {
if (!nums || !nums.length) return '';
const sorted = [...new Set(nums)].sort((a, b) => a - b);
const result = [];
let i = 0;
while (i < sorted.length) {
let j = i;
while (j < sorted.length - 1 && sorted[j + 1] === sorted[j] + 1) j++;
if (j - i >= 2) {
result.push(`${sorted[i]}${sorted[j]}`);
} else {
for (let k = i; k <= j; k++) result.push(sorted[k]);
}
i = j + 1;
}
return result.join(', ');
},
/** 全局角标序号 n → p_refer_id用于将正文中的 [n] 转为 mycite */
buildNumToRefIdMap() {
const citeMap = this.citeMap || {};
const map = {};
Object.keys(citeMap).forEach((id) => {
const n = citeMap[id];
if (n != null && n !== '' && !Number.isNaN(Number(n))) {
map[Number(n)] = String(id);
}
});
return map;
},
/** 自动匹配 [n]:表格内用 old_index+1 或 order_index(=n)mytable 之后段落仍用全局 citeMap */
_getBracketNumToIdMaps() {
const globalMap = this.buildNumToRefIdMap();
if (this.type !== 'table' || !this.useTableLocalCitationIndex) {
return { primary: globalMap, afterTable: globalMap };
}
const tm = this.tableBracketNumToRefIdMap || {};
const has = Object.keys(tm).length > 0;
return {
primary: has ? tm : globalMap,
afterTable: globalMap
};
},
sortAutociteIdsByTableBracketNumber(ids) {
const uniq = [...new Set(ids.map(String))];
const numById = this.tableRefIdToBracketNum || {};
return uniq.sort((a, b) => {
const na = numById[a];
const nb = numById[b];
const ha = na != null && na !== '' && !Number.isNaN(Number(na));
const hb = nb != null && nb !== '' && !Number.isNaN(Number(nb));
if (ha && hb) return Number(na) - Number(nb);
if (ha) return -1;
if (hb) return 1;
return String(a).localeCompare(String(b));
});
},
_sortAutociteIdsForDisplay(ids) {
if (this.tableLocalCiteActive) {
return this.sortAutociteIdsByTableBracketNumber(ids);
}
return this.sortAutociteIdsByCiteNumber(ids);
},
_sortIdsAfterBracketMatch(ids, tableOffset) {
if (this.tableLocalCiteActive && tableOffset === 0) {
return this.sortAutociteIdsByTableBracketNumber(ids);
}
return this.sortAutociteIdsByCiteNumber(ids);
},
/** 解析 [1]、[1,2]、[14]、[1, 23, 4] 等括号内数字列表(不含方括号) */
parseBracketInnerToNumbers(inner) {
if (!inner || typeof inner !== 'string') return [];
const t = inner.trim().replace(//g, ',');
// 先按逗号拆段,再对每段解析单号或区间;避免 parseInt('41-42') 只得到 41
if (/[,]/.test(t)) {
const parts = t.split(/[,]/).map((s) => String(s).trim()).filter(Boolean);
const out = [];
parts.forEach((part) => {
out.push(...this.parseBracketInnerToNumbers(part));
});
return out;
}
const range = t.match(/^(\d+)\s*[-–—]\s*(\d+)$/);
if (range) {
const a = Number(range[1]);
const b = Number(range[2]);
if (Number.isNaN(a) || Number.isNaN(b)) return [];
const lo = Math.min(a, b);
const hi = Math.max(a, b);
const out = [];
for (let i = lo; i <= hi; i++) out.push(i);
return out;
}
const n = parseInt(t, 10);
return Number.isNaN(n) ? [] : [n];
},
/**
* 将编辑器内纯文本形式的 [1]、[13]、[1, 2] 按当前 citeMap 转为 <mycite>(跳过已有 mycite/wmath 内文字)
* @returns {{ replaced: number }}
*/
convertPlainBracketCitesToAutocite() {
const ed = this.editorInstance;
if (!ed) return { replaced: 0 };
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
if (!refs.length) {
this.$message.warning(this.$t('wordCite.noRefs'));
return { replaced: 0 };
}
const maps = this._getBracketNumToIdMaps();
if (Object.keys(maps.primary).length === 0) {
this.$message.warning(this.$t('wordCite.noRefs'));
return { replaced: 0 };
}
const doc = ed.getDoc();
const body = ed.getBody();
if (!body) return { replaced: 0 };
let replaced = 0;
ed.undoManager.transact(() => {
// 先把被拆成多个 span 的 [13, 16, 2123] 规整成单段文本,再做自动匹配
this.normalizeSplitBracketCitesInTableCells(body);
replaced = this._replaceBracketCitesInDocOrder(body, doc, maps, { tableOffset: 0 });
});
this.renderAutociteInEditor(ed);
ed.fire('change');
return { replaced };
},
/**
* TinyMCE/粘贴可能把单元格里的 [1, 23] 拆成多个内联节点span/text 混排),
* 导致按文本节点匹配的自动链接无法命中。这里在匹配前把该类节点规整回纯文本。
*/
normalizeSplitBracketCitesInTableCells(root) {
if (!root || !root.querySelectorAll) return;
const cells = root.querySelectorAll('td, th');
cells.forEach((cell) => {
if (!cell || !cell.querySelectorAll) return;
if (cell.querySelector('mycite, autocite, wmath')) return;
if (!cell.querySelector('span')) return;
const raw = (cell.textContent || '').replace(/\u200b/g, '');
const compact = raw.replace(/\s+/g, ' ').trim();
if (!/^\[[\d\s,\-–—]+\]$/.test(compact)) return;
cell.textContent = compact;
});
},
/** 底部「自动链接参考文献」按钮,与原先工具栏 autoLinkRefs 行为一致 */
handleAutoLinkRefsClick() {
const r = this.convertPlainBracketCitesToAutocite();
if (!r) return;
if (r.replaced > 0) {
this.$message.success(this.$t('wordCite.matchBracketRefsDone', { n: r.replaced }));
return;
}
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
const maps = this._getBracketNumToIdMaps();
if (refs.length && Object.keys(maps.primary).length) {
this.$message.info(this.$t('wordCite.matchBracketRefsNone'));
}
},
/** 把「自动链接」栏挂到 TinyMCE 外壳 .tox-tinymce 内部底部,与工具栏/编辑区同一外框 */
mountAutoLinkFooterInsideEditor(ed) {
if (!this.showRefButton || !this.hasReferencesForAutoLink) return;
this.$nextTick(() => {
const el = this.$refs.autoLinkFooterWrap;
if (!el || !ed) return;
const container = ed.getContainer && ed.getContainer();
if (!container) return;
if (el.parentNode === container) return;
this._autoLinkFooterRestore = {
parent: el.parentNode,
next: el.nextSibling
};
container.appendChild(el);
});
},
/** 销毁编辑器前必须移回,否则节点会随 .tox-tinymce 一起被移除 */
restoreAutoLinkFooterDom() {
const el = this.$refs.autoLinkFooterWrap;
const r = this._autoLinkFooterRestore;
this._autoLinkFooterRestore = null;
if (!el || !r || !r.parent) return;
try {
if (r.next && r.next.parentNode === r.parent) {
r.parent.insertBefore(el, r.next);
} else {
r.parent.appendChild(el);
}
} catch (e) {
/* ignore */
}
},
/**
* 按文档顺序遍历,使「表格链接」后任意后续段落里的 [n] 都能用上该表的最大全局序号偏移。
* 旧实现只在同一父节点下、MYTABLE 与后续文本为兄弟时才生效MYTABLE 在上一段、角标在下一段时会失效。
*/
_replaceBracketCitesInDocOrder(node, doc, maps, state) {
if (node.nodeType === 3) {
return this._processTextNodeForBracketCites(node, doc, maps, state.tableOffset);
}
if (node.nodeType !== 1) return 0;
const name = node.nodeName;
if (name === 'AUTOCITE' || name === 'MYCITE' || name === 'WMATH' || name === 'SCRIPT' || name === 'STYLE') {
return 0;
}
const isMytable = name === 'MYTABLE' || (node.tagName && String(node.tagName).toLowerCase() === 'mytable');
if (isMytable) {
let total = 0;
Array.from(node.childNodes).forEach((c) => {
total += this._replaceBracketCitesInDocOrder(c, doc, maps, state);
});
const tid = (node.getAttribute && node.getAttribute('data-id')) || '';
const map = this.tableLinkCiteMaxMap || {};
const tableMax = Number(map[String(tid)]);
if (!Number.isNaN(tableMax) && tableMax > 0) {
state.tableOffset = tableMax;
}
return total;
}
let total = 0;
Array.from(node.childNodes).forEach((c) => {
total += this._replaceBracketCitesInDocOrder(c, doc, maps, state);
});
return total;
},
_processTextNodeForBracketCites(textNode, doc, maps, tableOffset = 0) {
const numToId = tableOffset > 0 ? maps.afterTable : maps.primary;
const text = textNode.textContent;
const re = /\[([\d\s,\-–—]+)\]/g;
let m;
let lastIndex = 0;
const pieces = [];
let replaced = 0;
let skippedSpecial = 0;
while ((m = re.exec(text)) !== null) {
const rawNums = this.parseBracketInnerToNumbers(m[1]);
// 仅 [0]、[-1]、[0, -1] 等「全是 0/-1」不转换与正常号混写时去掉 0/-1 再匹配
const onlyZeroOrNegOne =
rawNums.length > 0 && rawNums.every((n) => n === 0 || n === -1);
if (onlyZeroOrNegOne) {
pieces.push({ type: 'text', s: text.slice(lastIndex, m.index) });
pieces.push({ type: 'text', s: m[0] });
lastIndex = m.index + m[0].length;
skippedSpecial++;
continue;
}
const nums = rawNums.filter((n) => n !== 0 && n !== -1);
const mapNo = (n) => (n > 0 && tableOffset > 0 ? n + tableOffset - 1 : n);
if (!nums.length) {
pieces.push({ type: 'text', s: text.slice(lastIndex, m.index) });
pieces.push({ type: 'text', s: m[0] });
lastIndex = m.index + m[0].length;
continue;
}
const validNums = nums.filter((n) => numToId[mapNo(n)]);
const invalidNums = nums.filter((n) => !numToId[mapNo(n)]);
if (validNums.length === 0) {
pieces.push({ type: 'text', s: text.slice(lastIndex, m.index) });
pieces.push({ type: 'text', s: m[0] });
lastIndex = m.index + m[0].length;
continue;
}
const ids = [];
const seen = new Set();
validNums.forEach((n) => {
const mappedNo = mapNo(n);
const id = numToId[mappedNo];
if (id && !seen.has(id)) {
seen.add(id);
ids.push(id);
}
});
pieces.push({ type: 'text', s: text.slice(lastIndex, m.index) });
if (ids.length > 0) {
const sorted = this._sortIdsAfterBracketMatch(ids, tableOffset);
pieces.push({ type: 'cite', ids: sorted });
if (invalidNums.length) {
const invSorted = [...new Set(invalidNums)].sort((a, b) => a - b);
const invLabel = this.formatCiteNumbers(invSorted);
if (invLabel) {
pieces.push({ type: 'text', s: ', [' + invLabel + ']' });
}
}
replaced++;
} else {
pieces.push({ type: 'text', s: m[0] });
}
lastIndex = m.index + m[0].length;
}
if (replaced === 0) return 0;
pieces.push({ type: 'text', s: text.slice(lastIndex) });
const parent = textNode.parentNode;
if (!parent) return 0;
const frag = doc.createDocumentFragment();
pieces.forEach((p) => {
if (p.type === 'text') {
frag.appendChild(doc.createTextNode(p.s));
} else {
const ac = doc.createElement('mycite');
ac.setAttribute('data-id', p.ids.join(','));
ac.setAttribute('contenteditable', 'false');
ac.appendChild(doc.createTextNode('\u200b'));
frag.appendChild(ac);
}
});
parent.replaceChild(frag, textNode);
if (skippedSpecial > 0) {
// 提示:仅提示一次即可;这里在节点级别提示可能重复,放到宏任务末尾合并展示
clearTimeout(this._autoLinkSkipToastTimer);
this._autoLinkSkipToastTimer = setTimeout(() => {
this.$message.info(`Skipped ${skippedSpecial} bracket cite(s) containing only 0 or -1.`);
}, 0);
}
return replaced;
},
renderAutociteInEditor(ed) {
const body = ed.getBody();
if (!body) return;
const allAutocites = Array.from(
ed.dom && typeof ed.dom.select === 'function'
? ed.dom.select('mycite,autocite', body)
: body.querySelectorAll('mycite, autocite')
);
if (!allAutocites.length) return;
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
const refMap = {};
refs.forEach((item) => {
const key = item && item.p_refer_id != null ? String(item.p_refer_id) : '';
if (key) refMap[key] = item;
});
const citeMap = this.citeMap || {};
const tableNums = this.tableRefIdToBracketNum || {};
const useTableBracketNums = this.tableLocalCiteActive;
allAutocites.forEach((el) => {
ed.dom.setAttrib(el, 'contenteditable', 'false');
el.style.display = '';
const sortedAll = this._sortAutociteIdsForDisplay(this.parseAutociteDataIds(el.getAttribute('data-id')));
const validIds = sortedAll.filter((id) => refMap[String(id)]);
if (validIds.length < sortedAll.length) {
ed.dom.setAttrib(el, 'data-id', validIds.length ? validIds.join(',') : '');
}
const sortedIds = validIds.length ? this._sortAutociteIdsForDisplay(validIds) : [];
const parts = sortedIds.map((id) => {
const ref = refMap[String(id)];
const noCite = ref ? citeMap[String(id)] : null;
const noTable =
useTableBracketNums && tableNums[String(id)] != null ? tableNums[String(id)] : null;
// 单元格内 [n] 按 Original ordertable 序号)匹配;角标展示与正文一致,优先用 citeMap 对应序号
const num =
noCite != null && noCite !== '' && !Number.isNaN(Number(noCite))
? String(noCite)
: noTable != null && noTable !== '' && !Number.isNaN(Number(noTable))
? String(noTable)
: null;
return { id, ref, num };
});
const numsForLabel = parts
.map((p) => p.num)
.filter((n) => n != null && n !== '')
.map(Number);
const label = numsForLabel.length > 0 ? this.formatCiteNumbers(numsForLabel) : '';
if (!label) {
ed.dom.remove(el);
return;
}
el.textContent = `[${label}]`;
el.style.display = '';
if (el.removeAttribute) el.removeAttribute('title');
ed.dom.setAttrib(el, 'data-cite-missing', null);
if (String(el.tagName || '').toLowerCase() === 'autocite') {
const doc = ed.getDoc();
const nu = doc.createElement('mycite');
nu.setAttribute('data-id', el.getAttribute('data-id') || '');
nu.setAttribute('contenteditable', 'false');
const st = el.getAttribute('style');
if (st) nu.setAttribute('style', st);
nu.textContent = el.textContent;
el.parentNode.replaceChild(nu, el);
}
});
this.padAutociteCaretPlaceholder(ed);
},
/** 段尾不可编辑节点后浏览器/TinyMCE 容易把后续输入新开 <p>,在引用后补零宽空格让光标留在同一段 */
padAutociteCaretPlaceholder(ed) {
const doc = ed.getDoc();
const body = doc.body;
if (!body) return;
body.querySelectorAll('mycite, autocite').forEach((el) => {
const next = el.nextSibling;
if (next === null) {
el.parentNode.appendChild(doc.createTextNode('\u200b'));
return;
}
if (next.nodeType === 3) {
if (next.textContent === '\u200b') return;
if (next.textContent === '') {
next.textContent = '\u200b';
}
}
});
},
insertAutocite(refIds) {
const ed = this.editorInstance;
if (!ed) return;
const ids = (Array.isArray(refIds) ? refIds : [refIds]).map(String);
const dataId = ids.join(',');
const escaped = dataId.replace(/"/g, '&quot;');
if (this._editingAutocite) {
this._editingAutocite.setAttribute('data-id', dataId);
this._editingAutocite = null;
ed.fire('change');
} else {
if (this._refBookmark) {
ed.selection.moveToBookmark(this._refBookmark);
}
ed.insertContent(`<mycite data-id="${escaped}" contenteditable="false"></mycite>&#8203;`);
}
this.renderAutociteInEditor(ed);
},
removeAutocite() {
const ed = this.editorInstance;
if (!ed || !this._editingAutocite) return;
ed.dom.remove(this._editingAutocite);
this._editingAutocite = null;
ed.fire('change');
},
/**
* 从当前编辑的 mycite 中去掉指定 p_refer_id去掉后无 id 则删除整段标签。
* ids 为空:删除整段(与 removeAutocite 一致)。
*/
stripAutociteIds(idsToRemove) {
const ed = this.editorInstance;
if (!ed || !this._editingAutocite) return;
const remove = new Set((idsToRemove || []).map((id) => String(id)));
const el = this._editingAutocite;
this._editingAutocite = null;
if (remove.size === 0) {
ed.dom.remove(el);
ed.fire('change');
return;
}
const parts = this.parseAutociteDataIds(el.getAttribute('data-id') || '');
const remaining = parts.filter((id) => !remove.has(String(id)));
if (remaining.length === 0) {
ed.dom.remove(el);
} else {
const sorted = this.sortAutociteIdsByCiteNumber(remaining);
el.setAttribute('data-id', sorted.join(','));
this.renderAutociteInEditor(ed);
}
ed.fire('change');
},
/** TinyMCE 会剔除「空」的行内标签;空 mycite 必须在入编辑器前占位,否则合并引用 [13] 等整段消失 */
normalizeAutociteHtmlForEditor(html) {
if (!html || typeof html !== 'string') return html;
// 历史库/表格里可能为 <autocite>,与 mycite 统一,否则 renderAutociteInEditor 扫不到、无效 id 会残留
let out = html.replace(/<\/autocite>/gi, '</mycite>').replace(/<autocite\b/gi, '<mycite');
/** 角标显示一律由 renderAutociteInEditor 根据 data-id 生成,禁止保留库内遗留的 [1-4]、[14] 等旧文案 */
out = out.replace(/<mycite([^>]*)>[\s\S]*?<\/mycite>/gi, '<mycite$1>&#8203;</mycite>');
// 外侧:连续空格 / &nbsp; 合并为单个 &nbsp;,避免「普通空格 + &nbsp;」叠成大缝
out = out.replace(/(?:\s|&nbsp;|&#160;)+(?=<mycite\b)/gi, '&nbsp;');
out = out.replace(/(?<=<\/mycite>)(?:\s|&nbsp;|&#160;)+/gi, '&nbsp;');
return out;
},
handleSetContent(val) {
if (!this.editorInstance) return;
let finalContent = val || '';
let finalContent = this.normalizeAutociteHtmlForEditor(val || '');
// 你的业务逻辑:自动包裹 <p> 标签
if (!finalContent.includes('wordTableHtml') && !finalContent.startsWith('<p>')) {
finalContent = '<p>' + finalContent + '</p>';
@@ -174,8 +832,11 @@ export default {
this.editorInstance.setContent(finalContent);
// 渲染数学公式
// SetContent 回调里会 renderAutociteInEditor再补一次 nextTick避免时序下仍显示库内旧 [1-4]
this.$nextTick(() => {
if (this.editorInstance) {
this.renderAutociteInEditor(this.editorInstance);
}
if (window.renderMathJax) {
window.renderMathJax(this.tinymceId);
}
@@ -320,7 +981,10 @@ export default {
},
getDetail(val) {
if (this.hasInit == true) {
this.$nextTick(() => window.tinymce.get(this.tinymceId).setContent(val));
this.$nextTick(() => {
const ed = window.tinymce.get(this.tinymceId);
if (ed) ed.setContent(this.normalizeAutociteHtmlForEditor(val || ''));
});
}
},
//将字符串添加到富文本编辑器中
@@ -431,7 +1095,7 @@ export default {
return new Blob([u8arr], { type: mime });
},
formatHtml(val) {
const rawValue = val || ''; // 处理 null
const rawValue = this.normalizeAutociteHtmlForEditor(val || ''); // 须先于 cleanEmptyTags否则空 mycite 被删
const cleanEmptyTags = /<([a-zA-Z1-6]+)\b[^>]*><\/\1>/g;
const replaceSpaces = /\s+(?=<)|(?<=>)\s+/g;
const removeBr = /<br\s*\/?>/gi; // 移除所有 br 标签
@@ -456,7 +1120,7 @@ export default {
}
},
getSafeContent(val) {
const rawValue = val || '';
const rawValue = this.normalizeAutociteHtmlForEditor(val || '');
const cleanEmptyTags = /<([a-zA-Z1-6]+)\b[^>]*><\/\1>/g;
const replaceSpaces = /\s+(?=<)|(?<=>)\s+/g;
@@ -497,9 +1161,10 @@ export default {
window.tinymce.init({
..._this.tinymceOtherInit,
trim_span_elements: false, // 禁止修剪内联标签周围的空格
extended_valid_elements: 'blue[*]',
custom_elements: 'blue',
valid_children: '+blue[#text|i|em|b|strong|span],+body[blue],+p[blue]',
extended_valid_elements: 'blue[*],mycite[*]',
/* ~ 前缀:按行内(类似 span处理否则默认当块级会拆段导致引用后强制换行 */
custom_elements: 'blue,~mycite',
valid_children: '+blue[#text|i|em|b|strong|span],+body[blue|mycite],+p[blue|mycite]',
inline: false, // 使用 iframe 模式
selector: `#${this.tinymceId}`,
@@ -509,7 +1174,7 @@ export default {
valid_elements:
this.type == 'table'
? '*[*]'
: `img[src|alt|width|height],strong,em,sub,sup,blue,table,b,i,myfigure,mytable,wmath${this.valid_elements}`, // 允许的标签和属性
: `img[src|alt|width|height],strong,em,sub,sup,blue,table,b,i,myfigure,mytable,wmath,mycite[data-id|contenteditable|data-cite-missing|style]${this.valid_elements}`, // 允许的标签和属性mycite 不使用 title 悬停)
// valid_elements: '*[*]', // 允许所有 HTML 标签
noneditable_editable_class: 'MathJax',
height: this.height,
@@ -541,6 +1206,34 @@ export default {
font-weight: bold !important;
}
/* inline 与 blue 引用一致,避免 inline-block 在行尾产生多余换行感 */
mycite {
display: inline;
vertical-align: baseline;
color: rgb(0, 130, 170);
// font-weight: bold;
cursor: pointer;
padding: 0 2px;
border-radius: 3px;
background-color: rgba(0, 130, 170, 0.08);
user-select: all;
font-size: 12px;
white-space: nowrap;
}
mycite:hover {
background-color: rgba(0, 130, 170, 0.2);
text-shadow: 0 0 3px rgba(0, 130, 170, 0.3);
}
mycite[data-cite-missing] {
background-color: rgba(0, 130, 170, 0.08) !important;
color: rgb(0, 130, 170) !important;
text-shadow: 0 0 3px rgba(0, 130, 170, 0.3);
box-shadow: none !important;
}
mycite[data-cite-missing]:hover {
background-color: rgba(0, 130, 170, 0.2) !important;
}
@keyframes blueGlow {
0%,
100% {
@@ -559,7 +1252,37 @@ export default {
},
body_class: 'panel-body ',
object_resizing: false,
toolbar: this.toolbar.length > 0 ? this.toolbar : toolbar,
toolbar: (function () {
const tb = _this.toolbar;
const hasCustom =
(Array.isArray(tb) && tb.length > 0) ||
(typeof tb === 'string' && tb.length > 0);
const refInsert = _this.showRefButton ? ' insertRef' : '';
/* 自动匹配 [n] 为角标:底部蓝色按钮,见模板 tinymce-autolink-footer */
/* 与 content.vue 默认一致;含 insertRef 由 showRefButton 控制 */
const defaultToolbar = [
`bold italic |customBlue removeBlue|LateX${refInsert}| myuppercase myuppercasea Line MoreSymbols|subscript superscript|clearButton|searchreplace`
];
if (!hasCustom) return defaultToolbar;
const rows = Array.isArray(tb) ? tb : [tb];
return rows.map((row) => {
if (typeof row !== 'string') return row;
let out = row;
if (!_this.showRefButton) {
out = out
.replace(/\binsertRef\b/g, '')
.replace(/\bautoLinkRefs\b/g, '')
.replace(/\|\s*\|/g, '|')
.replace(/\|\s*$/g, '')
.replace(/^\s*\|/g, '');
} else if (out.indexOf('insertRef') === -1) {
out = out
.replace(/\|LateX\|/g, '|LateX insertRef|')
.replace(/\|\s*LateX\s*\|/g, '| LateX insertRef |');
}
return out;
});
})(),
menubar: false, // 启用菜单栏并保持必要的项目
statusbar: false, // 关闭底部状态栏
custom_colors: false,
@@ -626,7 +1349,27 @@ export default {
let currentPasteImages = [];
_this.$commonJS.initEditorButton(_this, ed);
var currentWmathElement = null;
ed.ui.registry.addButton('insertRef', {
text: 'Reference',
tooltip: 'Insert Reference',
onAction: function () {
_this._refBookmark = ed.selection.getBookmark(2);
_this._editingAutocite = null;
_this.$emit('openRefSelector', { currentRefIds: [] });
}
});
ed.on('click', function (e) {
const autociteEl = e.target.closest('mycite') || e.target.closest('autocite');
if (autociteEl) {
const dataIds = _this.parseAutociteDataIds(autociteEl.getAttribute('data-id'));
_this._refBookmark = ed.selection.getBookmark(2);
_this._editingAutocite = autociteEl;
_this.$emit('openRefSelector', { currentRefIds: dataIds });
return;
}
const wmathElement = e.target.closest('wmath');
if (wmathElement) {
currentWmathElement = wmathElement; // 保存当前点击的元素
@@ -757,6 +1500,8 @@ export default {
subtree: true,
characterData: true
});
_this.mountAutoLinkFooterInsideEditor(ed);
});
// 定义自定义按钮
@@ -791,14 +1536,20 @@ export default {
const editorBody = ed.getBody();
ed.dom.select('wmath', editorBody).forEach(function (wmathElement) {
ed.dom.setAttrib(wmathElement, 'contenteditable', 'false');
// ed.dom.addClass(wmathElement, 'non-editable-wmath');
});
_this.renderAutociteInEditor(ed);
e.content = e.content.replace(/<strong>/g, '<b>').replace(/<\/strong>/g, '</b>');
e.content = e.content.replace(/<em>/g, '<i>').replace(/<\/em>/g, '</i>');
});
ed.on('GetContent', function (e) {
e.content = e.content.replace(/<b>/g, '<strong>').replace(/<\/b>/g, '</strong>');
e.content = e.content.replace(/<i>/g, '<em>').replace(/<\/i>/g, '</em>');
e.content = e.content.replace(/<i>/g, '<em>').replace(/<\/em>/g, '</em>');
e.content = e.content.replace(/<\/mycite>\s*&#8203;/gi, '</mycite>');
e.content = e.content.replace(/<\/mycite>\s*\u200b/g, '</mycite>');
e.content = e.content.replace(/<mycite([^>]*)>[^<]*<\/mycite>/gi, function (match, attrs) {
var clean = attrs.replace(/\s*style="[^"]*"/gi, '').replace(/\s*title="[^"]*"/gi, '').replace(/\s*contenteditable="[^"]*"/gi, '');
return '<mycite' + clean + '></mycite>';
});
});
},
paste_preprocess: function (plugin, args) {
@@ -958,13 +1709,15 @@ export default {
//销毁富文本
destroyTinymce() {
this.onClear();
this.restoreAutoLinkFooterDom();
if (window.tinymce.get(this.tinymceId)) {
window.tinymce.get(this.tinymceId).destroy();
}
},
//设置内容
setContent(value) {
window.tinymce.get(this.tinymceId).setContent(value);
const ed = window.tinymce.get(this.tinymceId);
if (ed) ed.setContent(this.normalizeAutociteHtmlForEditor(value || ''));
},
//获取内容
async getContent(type) {
@@ -973,7 +1726,8 @@ export default {
content = content.replace(/<span[^>]*>/g, '').replace(/<\/span>/g, ''); // 去除span标签
content = content.replace(/<strong>/g, '<b>').replace(/<\/strong>/g, '</b>');
content = content.replace(/<em>/g, '<i>').replace(/<\/em>/g, '</i>');
content = content.replace(/&nbsp;/g, ' '); // 将所有 &nbsp; 替换为空格
content = content.replace(/&nbsp;/g, ' '); // 先统一为空格
content = this.normalizeAutociteHtmlForEditor(content); // mycite 两侧水平空白以 &nbsp; 存库(与入编辑器一致)
this.$emit('getContent', type, content);
},
@@ -1058,6 +1812,26 @@ export default {
};
</script>
<style scoped>
.tinymce-editor-surface {
width: 100%;
}
.tinymce-autolink-footer {
display: flex;
justify-content: flex-end;
align-items: center;
padding-top: 8px;
margin-top: 4px;
border-top: 1px solid #ebeef5;
}
/* 移入 .tox-tinymce 内部后:贴编辑区下沿、与编辑器同宽同框 */
::v-deep .tox-tinymce > .tinymce-autolink-footer {
margin-top: 0;
padding: 0px 10px;
box-sizing: border-box;
border-top: 1px solid #dcdfe6;
background: #fafafa;
flex-shrink: 0;
}
::v-deep .tox-tinymce-aux {
z-index: 9999 !important;
}

View File

@@ -59,22 +59,20 @@
<div class="drawer-body" v-loading="loading">
<div class="list-header">
<div class="col-index">{{ $t('autoPromotionLogs.logColIndex') }}</div>
<div class="col-info">{{ $t('autoPromotionLogs.logColExpert') }}</div>
<div class="col-send" style="font-size: 12px;">{{ $t('autoPromotionLogs.logColSendTime') }}</div>
<div class="col-prepared" style="font-size: 12px;">{{ $t('autoPromotionLogs.logColPreparedAt') }}</div>
<div class="col-status">{{ $t('autoPromotionLogs.logColStatus') }}</div>
<div class="col-action"></div>
<div class="col-action">{{ $t('autoPromotionLogs.logColAction') }}</div>
</div>
<div class="list-wrapper">
<div
v-for="(item, rowIndex) in fullData"
v-for="item in fullData"
:key="item.id"
class="log-row"
:class="{ 'row-error': item.isErrorRow }"
>
<div class="col-index">{{ (currentPage - 1) * pageSize + rowIndex + 1 }}</div>
<div class="col-info">
<div class="expert-main">
<span class="name">{{ item.expertName }}</span>
@@ -784,14 +782,6 @@ export default {
color: #475569;
}
.col-index {
flex: 0 0 52px;
width: 52px;
text-align: center;
color: #64748b;
font-variant-numeric: tabular-nums;
}
.col-info {
flex: 1.8;
}

View File

@@ -354,9 +354,8 @@
chief: null,
board: null,
young: null,
youngBefore2025: null,
authorBefore2025: null,
author: null,
reviewer: null,
expertDb: null
},
expertTypeCountsLoading: false,
@@ -433,22 +432,6 @@
count: counts.young,
jump: { path: '/youthList', queryKey: 'journal_id' }
},
{
value: '6',
label: this.$t('autoPromotion.factoryExpertYoungBoardBefore2025'),
desc: this.$t('autoPromotion.factoryExpertYoungBoardBefore2025'),
icon: 'el-icon-time',
count: counts.youngBefore2025,
jump: { path: '/youthList', queryKey: 'journal_id' }
},
{
value: '7',
label: this.$t('autoPromotion.factoryExpertAuthorBefore2025'),
desc: this.$t('autoPromotion.factoryExpertAuthorBefore2025'),
icon: 'el-icon-document',
count: counts.authorBefore2025,
jump: { path: '/partyListCorr', queryKey: 'journal_id' }
},
{
value: '4',
label: this.$t('autoPromotion.factoryExpertAuthor'),
@@ -901,28 +884,12 @@
this.availableFields = [];
this.factoryFieldIds = [];
this.selectedEmailIds = [];
this.expertTypeCounts = {
chief: null,
board: null,
young: null,
youngBefore2025: null,
authorBefore2025: null,
author: null,
expertDb: null
};
this.expertTypeCounts = { chief: null, board: null, young: null, author: null, reviewer: null, expertDb: null };
this.syncActiveStep();
return;
}
// 切换期刊时:先清空旧人数并立即触发 loading
this.expertTypeCounts = {
chief: null,
board: null,
young: null,
youngBefore2025: null,
authorBefore2025: null,
author: null,
expertDb: null
};
this.expertTypeCounts = { chief: null, board: null, young: null, author: null, reviewer: null, expertDb: null };
this.fetchExpertTypeCounts();
await this.loadAccounts(val);
await Promise.all([
@@ -940,7 +907,6 @@
const payload = {
from: 'promotionFactory',
journal_id: journalId != null && journalId !== '' ? String(journalId) : '',
expert_type: opt && opt.value != null ? String(opt.value) : '',
ts: Date.now(),
targetPath: String(jump.path || '')
};
@@ -973,21 +939,19 @@
return sum;
};
const yboardBase = {
journal_id: journalId,
pageIndex: 1,
pageSize: 1,
keywords: '',
fieldkey: '',
order_remark: 0
};
const [boardRes, youngRes, youngBefore2025Res, authorRes, authorBefore2025Res, expertDbRes] = await Promise.all([
const [boardRes, youngRes, authorRes, reviewerRes, expertDbRes] = await Promise.all([
this.$api.post('api/Board/getBoards', { journal_id: journalId }).catch(() => null),
this.$api
.post('api/User/getYboardlist', Object.assign({}, yboardBase, { type: 1, year: 0 }))
.catch(() => null),
this.$api
.post('api/User/getYboardlist', Object.assign({}, yboardBase, { type: 6, year: 0 }))
.post('api/User/getYboardlist', {
journal_id: journalId,
type: 1,
year: 0,
pageIndex: 1,
pageSize: 1,
keywords: '',
fieldkey: '',
order_remark: 0
})
.catch(() => null),
this.$api
.post('api/User/authorDatabase', {
@@ -998,12 +962,12 @@
})
.catch(() => null),
this.$api
.post('api/User/authorDatabase', {
journal_id: journalId,
type: 7,
keywords: '',
page: 1,
limit: 1
.post('api/Reviewer/getReviewerListByJournal', {
username: localStorage.getItem('U_name'),
journalId: journalId,
class: 0,
pageIndex: 1,
pageSize: 1
})
.catch(() => null),
this.$api
@@ -1014,15 +978,7 @@
.catch(() => null)
]);
const next = {
chief: null,
board: null,
young: null,
youngBefore2025: null,
authorBefore2025: null,
author: null,
expertDb: null
};
const next = { chief: null, board: null, young: null, author: null, reviewer: null, expertDb: null };
if (boardRes && boardRes.code === 0 && boardRes.data && boardRes.data.boards) {
const b = boardRes.data.boards || {};
@@ -1035,14 +991,11 @@
if (youngRes && youngRes.code === 0) {
next.young = safeCount(youngRes.data && youngRes.data.count);
}
if (youngBefore2025Res && youngBefore2025Res.code === 0) {
next.youngBefore2025 = safeCount(youngBefore2025Res.data && youngBefore2025Res.data.count);
}
if (authorRes && authorRes.code === 0) {
next.author = safeCount(authorRes.data && authorRes.data.count);
}
if (authorBefore2025Res && authorBefore2025Res.code === 0) {
next.authorBefore2025 = safeCount(authorBefore2025Res.data && authorBefore2025Res.data.count);
if (reviewerRes && reviewerRes.code === 0) {
next.reviewer = safeCount(reviewerRes.total);
}
if (expertDbRes && expertDbRes.code === 0) {
const total = (expertDbRes.data && (expertDbRes.data.total || expertDbRes.data.count)) || expertDbRes.total;

View File

@@ -5,29 +5,6 @@
<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>
@@ -97,7 +74,7 @@
</div>
</div>
<div class="mail-body-content" v-html="mailBodyHtml"></div>
<div class="mail-body-content" v-html="mailData.content_html"></div>
<div v-if="mailData.attachments && mailData.attachments.length" class="attachment-section">
<div class="attachment-header">
@@ -141,98 +118,14 @@
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
@@ -251,20 +144,6 @@ 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() {
@@ -272,580 +151,17 @@ export default {
mediaUrl: Common.mediaUrl,
isDetailExpanded: false,
downloadingIndex: -1,
packingAll: false,
registerAuthorLoading: false,
autoSubmitDialogVisible: false,
autoSubmitLoading: false,
/** 建稿成功后在同一弹窗内展示摘要,仅保留关闭 */
autoSubmitSuccessMode: false,
autoSubmitSuccessBodyHtml: '',
autoSubmitForm: {
username: '',
password: ''
},
/** 弹窗内本机选择的 .docx优先于邮件附件下载 */
autoSubmitLocalDocxFile: null,
/** 因「邮箱/账号已存在」打开建稿弹窗时展示提示条 */
autoSubmitPrefillFromRegisterConflict: false,
/** 本地上传组件强制重建 key超限选新文件时替换 */
autoSubmitUploadKey: 0
packingAll: false
};
},
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 || {};
let raw = m.content_html || m.body_html || m.html || m.body || m.content || '';
raw = normalizeEmailHtmlForInlineDisplay(raw);
if (raw) return raw;
const text = m.content_text;
if (text != null && String(text).trim() !== '') {
return `<pre class="mail-plain-pre">${this.escapeHtml(String(text))}</pre>`;
}
return '';
}
},
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)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.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');
@@ -897,22 +213,6 @@ 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;
@@ -921,7 +221,7 @@ export default {
const promises = this.mailData.attachments.map(async (file) => {
const url = await this.ensureFileUrl(file);
if (!url) return;
const fileUrl = this.resolvePublicFileUrl(url);
const fileUrl = this.mediaUrl + url;
const resp = await fetch(fileUrl);
const blob = await resp.blob();
zip.file(file.name || 'attachment', blob);
@@ -940,7 +240,7 @@ export default {
console.log("🚀 ~ previewAttachment ~ file:", file);
var that = this;
that.$refs.previewDialog.init(file, this.resolvePublicFileUrl(file.file_url) || '');
that.$refs.previewDialog.init(file, this.mediaUrl+file.file_url || '');
// 1. 获取后端返回的 URL
const res = await this.$api.post('api/email_client/getAttachment', {
inbox_id: String(this.mailData.inbox_id),
@@ -948,7 +248,7 @@ const res = await this.$api.post('api/email_client/getAttachment', {
});
if (res.data && res.data.file_url) {
const fullUrl = this.resolvePublicFileUrl(res.data.file_url);
const fullUrl = this.mediaUrl + res.data.file_url;
// 2. 调用弹窗组件的 init 方法
// 注意:微软预览要求 fullUrl 必须是公网可访问的!
@@ -966,7 +266,7 @@ const res = await this.$api.post('api/email_client/getAttachment', {
this.$message.warning(this.$t('mailboxCollect.downloadFail') || '下载失败');
return;
}
const fileUrl = this.resolvePublicFileUrl(url);
const fileUrl = this.mediaUrl + url;
const fileName = file.name || 'attachment';
const resp = await fetch(fileUrl);
const blob = await resp.blob();
@@ -1062,72 +362,6 @@ 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;
}
@@ -1207,15 +441,6 @@ const res = await this.$api.post('api/email_client/getAttachment', {
margin-bottom: 50px;
}
/* v-html 注入的正文无 scoped 属性,用 deep 命中内部的 pre */
.mail-body-content >>> .mail-plain-pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 14px;
}
/* 附件卡片 */
.attachment-section {
border-top: 1px solid #eee;
@@ -1410,9 +635,7 @@ 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;
@@ -1421,17 +644,14 @@ 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: 0;
margin-left: 15px;
font-size: 13px;
}
.attachment-section {

View File

@@ -90,7 +90,7 @@ export default {
expert_title: "Prof", // 专家职称 (如 Prof./Dr.)
expert_name: "John Doe", // 专家姓名
expert_field: "Biomedical Engineering", // 专家研究领域
representative_work_title: "Advanced Applications of AI in Medical Imaging.", // 专家代表作标题
representative_work_title: "Advanced Applications of AI in Medical Imaging", // 专家代表作标题
ai_content_analysis: "【AI分析文章一句话总结】", // AI solicitation rationale
ai_advised_topics: "Based on your research expertise, we would particularly welcome submissions on topics such as 【这里是AI针对学者领域给特定约稿主题】, or other closely related areas that align with your work.", // AI suggested directions

View File

@@ -58,9 +58,25 @@
<script>
import { TableUtils } from '@/common/js/TableUtils';
import { mediaUrl } from '@/common/js/commonJS.js';
import {
buildCiteMapFromRefs,
applyCiteLabelsToTableRows
} from '@/common/js/citeTablePreview.js';
export default {
name: 'TablePreviewer',
props: {
/** 与稿面 chanFerForm 一致,用于把单元格内 <mycite> 显示为 [n] */
referenceList: {
type: Array,
default: () => []
},
/** 正文引用首次出现顺序p_refer_id与 GenerateCharts articleCiteIdOrder 一致 */
bodyCiteIdOrder: {
type: Array,
default: () => []
}
},
data() {
return {
visible: false,
@@ -70,23 +86,32 @@ export default {
mediaUrl,
};
},
computed: {
previewCiteMap() {
return buildCiteMapFromRefs(this.referenceList, this.bodyCiteIdOrder);
}
},
methods: {
async open(type, item,isNoBg) {
async open(type, item, isNoBg) {
this.visible = true;
this.type = type;
const hideOddRowBg = isNoBg === true || isNoBg === 'true' || isNoBg === 1 || isNoBg === '1';
if (type === 'table') {
this.loading = true;
setTimeout(() => {
try {
const processed = this.processTableData(item.table);
const processed = this.processTableData(item.table, {
refs: this.referenceList,
citeMap: this.previewCiteMap
});
this.processedItem = Object.freeze({
...item,
table: {
...item.table,
tableHeader: processed.tableHeader,
tableContent: processed.tableContent,
oddRowIds: isNoBg ? [] : processed.oddRowIds
oddRowIds: hideOddRowBg ? [] : processed.oddRowIds
}
});
} catch (err) {
@@ -97,6 +122,7 @@ export default {
}, 50);
} else {
this.processedItem = item;
}
// 数学公式渲染
@@ -105,12 +131,20 @@ export default {
}, 500);
},
processTableData(rawContent) {
processTableData(rawContent, citeCtx) {
try {
const tableList = typeof rawContent === 'string' ? JSON.parse(rawContent) : rawContent;
const { header, content } = TableUtils.splitTable(tableList);
const { rowData, rowIds } = TableUtils.addRowIdToData(content);
return { tableHeader: header, tableContent: rowData, oddRowIds: rowIds };
const refs = citeCtx && citeCtx.refs;
const citeMap = citeCtx && citeCtx.citeMap;
let tableHeader = header;
let tableContent = rowData;
if (Array.isArray(refs) && refs.length > 0 && citeMap && typeof citeMap === 'object') {
tableHeader = applyCiteLabelsToTableRows(header, refs, citeMap);
tableContent = applyCiteLabelsToTableRows(rowData, refs, citeMap);
}
return { tableHeader, tableContent, oddRowIds: rowIds };
} catch (e) {
return { tableHeader: [], tableContent: [], oddRowIds: [] };
}
@@ -238,6 +272,16 @@ export default {
background: rgb(250, 231, 232) !important;
}
/* 与稿面 mycite 引用样式一致,弹窗内可见 [n] */
.table_Box ::v-deep mycite {
display: inline;
vertical-align: baseline;
color: rgb(0, 130, 170) !important;
cursor: inherit;
text-decoration: none;
background-color: rgba(0, 130, 170, 0.08);
}
.table-fade-enter-active, .table-fade-leave-active { transition: opacity 0.3s ease; }
.table-fade-enter, .table-fade-leave-to { opacity: 0; }
</style>

View File

@@ -10,13 +10,19 @@
ref="tinymceChild1"
:wordStyle="wordStyle"
:isAutomaticUpdate="isAutomaticUpdate"
:showRefButton="showRefButton"
@getContent="getContent"
@openLatexEditor="openLatexEditor"
@openRefSelector="openRefSelector"
@updateChange="updateChange"
@input="onTinymceInput"
:value="value"
:chanFerForm="chanFerForm"
:bodyCiteIdOrder="bodyCiteIdOrder"
:tableLinkCiteMaxMap="tableLinkCiteMaxMap"
:typesettingType="typesettingType"
class="paste-area text-container"
:toolbar="!isAutomaticUpdate?['bold italic |customBlue removeBlue|LateX| myuppercase myuppercasea Line MoreSymbols|subscript superscript|clearButton|searchreplace']:['bold italic |customBlue removeBlue| myuppercase myuppercasea Line MoreSymbols|subscript superscript|clearButton|searchreplace']"
:toolbar="toolbarConfig"
style="
/* white-space: pre-line; */
line-height: 12px;
@@ -36,10 +42,40 @@
<script>
import Tinymce from '@/components/page/components/Tinymce';
export default {
props: ['value','isAutomaticUpdate','height','id'],
props: {
value: {},
isAutomaticUpdate: {},
height: {},
id: {},
chanFerForm: {},
/** 全文 mycite 顺序,与稿面 word.vue 的 bodyCiteIdOrder 一致,用于弹窗内 [1][2] 与参考文献重排 */
bodyCiteIdOrder: {
type: Array,
default: () => []
},
/** mytable(data-id) 对应表格内已出现引用的最大全局序号,用于正文中 “See Table X. And see [2,3]” 的偏移映射 */
tableLinkCiteMaxMap: {
type: Object,
default: () => ({})
},
/** false标题等场景不显示 Ref */
showRefButton: {
type: Boolean,
default: true
}
},
components: {
Tinymce
},
computed: {
toolbarConfig() {
const refBtn = this.showRefButton ? ' insertRef' : '';
if (!this.isAutomaticUpdate) {
return [`bold italic |customBlue removeBlue|LateX${refBtn}| myuppercase myuppercasea Line MoreSymbols|subscript superscript|clearButton|searchreplace`];
}
return [`bold italic |customBlue removeBlue${refBtn}| myuppercase myuppercasea Line MoreSymbols|subscript superscript|clearButton|searchreplace`];
}
},
watch: {
lineStyle() {}
},
@@ -85,8 +121,40 @@ export default {
this.$refs.tinymceChild1.getContent(type);
},
getContent(type, content) {
this.$emit('getContent', type, content);
},
openRefSelector(data) {
this.$emit('openRefSelector', data);
},
onTinymceInput(html) {
this.$emit('editorInput', html);
},
insertAutocite(refId) {
this.$refs.tinymceChild1.insertAutocite(refId);
},
removeAutocite() {
this.$refs.tinymceChild1.removeAutocite();
},
/** 从当前 mycite 的 data-id 中移除指定 id无剩余则删标签未选 id 时删整段 */
stripAutociteIds(ids) {
if (this.$refs.tinymceChild1 && typeof this.$refs.tinymceChild1.stripAutociteIds === 'function') {
this.$refs.tinymceChild1.stripAutociteIds(ids);
}
},
/** 参考文献异步到达后刷新编辑器内 mycite 数字(供父组件在打开弹窗 / fetch 完成后调用) */
refreshAutociteDisplay() {
const t = this.$refs.tinymceChild1;
if (t && typeof t.renderAutociteInEditor === 'function' && t.editorInstance) {
t.renderAutociteInEditor(t.editorInstance);
}
},
/** 将正文中的 [1]、[13] 等按全局序号匹配为 <mycite> */
convertBracketRefsToAutocite() {
const t = this.$refs.tinymceChild1;
if (t && typeof t.convertPlainBracketCitesToAutocite === 'function') {
return t.convertPlainBracketCitesToAutocite();
}
return { replaced: 0 };
}
}
};

View File

@@ -2,11 +2,17 @@
<div>
<tinymce
type="table"
:use-table-local-citation-index="true"
:articleId="articleId"
ref="tinymceChild1"
:wordStyle="wordStyle"
:show-ref-button="showRefButton"
:chanFerForm="chanFerForm"
:bodyCiteIdOrder="bodyCiteIdOrder"
@getContent="getContent"
@openLatexEditor="openLatexEditor"
@openRefSelector="$emit('openRefSelector', $event)"
@input="onTableEditorInput"
:height="calcDynamicWidth()"
:value="updatedHtml"
:typesettingType="typesettingType"
@@ -22,7 +28,24 @@
<script>
import Tinymce from '@/components/page/components/Tinymce';
export default {
props: ['lineStyle', 'articleId'],
props: {
lineStyle: {},
articleId: {},
/** 与稿面/段落编辑一致,供 Ref 插入;未传时 TinyMCE 内为空会提示「参考文献尚未加载」 */
chanFerForm: {
type: Array,
default: () => []
},
bodyCiteIdOrder: {
type: Array,
default: () => []
},
/** 与 content.vue 一致:为 true 时显示 Reference、Auto-link References稿面表格编辑需文献角标时保持 true */
showRefButton: {
type: Boolean,
default: true
}
},
components: {
Tinymce
},
@@ -91,6 +114,9 @@ export default {
}
},
methods: {
onTableEditorInput(html) {
this.$emit('editorInput', html);
},
openLatexEditor(data) {
this.$emit('openLatexEditor', data);
},
@@ -121,6 +147,22 @@ export default {
} else {
this.$emit('getContent', type, { html_data: '', table: [] });
}
},
insertAutocite(refIds) {
if (this.$refs.tinymceChild1 && typeof this.$refs.tinymceChild1.insertAutocite === 'function') {
this.$refs.tinymceChild1.insertAutocite(refIds);
}
},
stripAutociteIds(ids) {
if (this.$refs.tinymceChild1 && typeof this.$refs.tinymceChild1.stripAutociteIds === 'function') {
this.$refs.tinymceChild1.stripAutociteIds(ids);
}
},
refreshAutociteDisplay() {
const t = this.$refs.tinymceChild1;
if (t && typeof t.renderAutociteInEditor === 'function' && t.editorInstance) {
t.renderAutociteInEditor(t.editorInstance);
}
}
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -252,10 +252,11 @@
</ul>
</div>
<DynamicTable
ref="myTableModal"
/>
<DynamicTable
ref="myTableModal"
:reference-list="chanFerForm || []"
:body-cite-id-order="bodyCiteIdOrder || []"
/>
</div>
</template>
@@ -264,7 +265,18 @@
import { mediaUrl } from '@/common/js/commonJS.js'; // 引入通用逻辑
import DynamicTable from './DynamicTable.vue';
export default {
props: ['articleId', 'imgWidth', 'imgHeight', 'scale', 'isEdit', 'isShowEdit', 'urlList', 'content'],
props: [
'articleId',
'imgWidth',
'imgHeight',
'scale',
'isEdit',
'isShowEdit',
'urlList',
'content',
'chanFerForm',
'bodyCiteIdOrder'
],
data() {
return {
identity: localStorage.getItem('U_role'),

View File

@@ -464,10 +464,11 @@
</li>
</ul>
</div>
<DynamicTable
ref="myTableModal"
/>
<DynamicTable
ref="myTableModal"
:reference-list="chanFerForm || []"
:body-cite-id-order="bodyCiteIdOrder || []"
/>
</div>
</template>
@@ -476,7 +477,20 @@ import DynamicTable from './DynamicTable.vue';
import { mediaUrl } from '@/common/js/commonJS.js'; // 引入通用逻辑
export default {
props: ['articleId', 'imgWidth', 'imgHeight', 'scale', 'isEdit', 'isShowEdit', 'urlList', 'content'],
props: [
'articleId',
'imgWidth',
'imgHeight',
'scale',
'isEdit',
'isShowEdit',
'urlList',
'content',
/** 参考文献列表,供表格预览把 mycite 显示为 [n] */
'chanFerForm',
/** 正文引用顺序,与稿面 articleCiteIdOrder 一致 */
'bodyCiteIdOrder'
],
data() {
return {
isShowComment: false,

View File

@@ -239,18 +239,47 @@ export default {
openAddDialog() { this.addDialogVisible = true; },
resetAddForm() { this.addForm = { field: '', runNow: false }; this.addLoading = false; },
async submitAddKeyword() {
if (!this.addForm.field.trim()) return this.$message.warning(this.$t('crawlTask.enterKeyword'));
const raw = String(this.addForm.field || '').trim();
if (!raw) return this.$message.warning(this.$t('crawlTask.enterKeyword'));
// 支持一次录入多个:按换行/逗号/分号拆分,去重
const fields = raw
.split(/[\r\n,;]+/g)
.map((s) => s.trim())
.filter(Boolean);
const uniqFields = Array.from(new Set(fields));
if (uniqFields.length === 0) return this.$message.warning(this.$t('crawlTask.enterKeyword'));
this.addLoading = true;
try {
const res = await this.$api.post('api/expert_manage/addFetchField', { field: this.addForm.field });
if (res.code === 0) {
this.$message.success(this.$t('crawlTask.addKeywordSuccess'));
if (this.addForm.runNow) {
await this.$api.post('api/expert_finder/fetchOneField', { field: this.addForm.field });
const ok = [];
const fail = [];
for (const field of uniqFields) {
try {
const res = await this.$api.post('api/expert_manage/addFetchField', { field });
if (res && res.code === 0) {
ok.push(field);
if (this.addForm.runNow) {
// 不阻塞整体添加run once 失败也记录,不影响已添加成功
try {
await this.$api.post('api/expert_finder/fetchOneField', { field });
} catch (e) {
/* ignore */
}
}
} else {
fail.push({ field, msg: (res && res.msg) || this.$t('crawlTask.operationFail') });
}
} catch (e) {
fail.push({ field, msg: this.$t('crawlTask.operationFail') });
}
this.addDialogVisible = false;
this.fetchList();
}
if (ok.length > 0) {
this.$message.success(`${this.$t('crawlTask.addKeywordSuccess')} (${ok.length}/${uniqFields.length})`);
}
if (fail.length > 0) {
this.$message.warning(`Failed: ${fail.map((x) => x.field).join(', ')}`);
}
this.addDialogVisible = false;
this.fetchList();
} finally { this.addLoading = false; }
}
}

View File

@@ -71,6 +71,7 @@
Special note: If the number of authors is 6 or fewer, all authors should be listed.
</div>
<el-button
v-if="canDelete"
@click="StepBNext(1)"
type="primary"
plain
@@ -110,7 +111,7 @@
<span @click="handleContainerClick" style="margin-left: 5px" v-html="getRepeatRefHtml()"> </span>
</div>
<div class="topBtnBox btns" v-if="chanFerForm.length > 0 && role == 'editor'">
<div class="topBtnBox btns" v-if="chanFerForm.length > 0 && role == 'editor' && canDelete">
<el-button type="primary" plain @click="selectAllRef">Select all</el-button>
<el-button type="success" plain @click="toggleSelection">Select none</el-button>
<el-button type="danger" plain @click="deleteSomeRefs" :disabled="multipleSelection.length > 0 ? false : true"
@@ -154,11 +155,10 @@
class="status ok"
:class="scope.row.refer_type == 'journal' ? getJournalDateno(scope.row.dateno, 'status') : ''"
v-if="
(
(scope.row.refer_type == 'journal' && scope.row.doilink != '' && scope.row.cs == 1) ||
(scope.row.refer_type == 'book' && scope.row.isbn != '' && scope.row.cs == 1)
) && scope.row.retract == 0
"
((scope.row.refer_type == 'journal' && scope.row.doilink != '' && scope.row.cs == 1) ||
(scope.row.refer_type == 'book' && scope.row.isbn != '' && scope.row.cs == 1)) &&
scope.row.retract == 0
"
>
<i class="el-icon-circle-check"></i>
</span>
@@ -173,7 +173,9 @@
<!-- journal 形式 -->
<div style="text-align: left" v-if="scope.row.refer_type == 'journal'" class="reference-item">
<p>
{{ scope.row.author }}&nbsp;<span v-html="formatTitle(scope.row.title)"></span>. &nbsp;<em>{{ scope.row.joura }}</em
{{ scope.row.author }}&nbsp;<span v-html="formatTitle(scope.row.title)"></span>. &nbsp;<em>{{
scope.row.joura
}}</em
>.&nbsp;<span :class="getJournalDateno(scope.row.dateno, 'title')">{{ scope.row.dateno }}</span
>.<br />
</p>
@@ -181,13 +183,16 @@
</div>
<!-- book 形式 -->
<div style="text-align: left" v-if="scope.row.refer_type == 'book'" class="reference-item">
<p>{{ scope.row.author }}&nbsp;<span v-html="formatTitle(scope.row.title)"></span>.&nbsp;{{ scope.row.dateno }}.&nbsp;<br /></p>
<p>
{{ scope.row.author }}&nbsp;<span v-html="formatTitle(scope.row.title)"></span>.&nbsp;{{
scope.row.dateno
}}.&nbsp;<br />
</p>
<a class="doiLink" :href="scope.row.isbn" target="_blank">{{ scope.row.isbn }}</a>
</div>
<!-- other 形式 -->
<p class="wrongLine reference-item" style="text-align: left" v-if="scope.row.refer_type == 'other'">
<span v-html="formatTitle(scope.row.refer_frag)"></span>
</p>
</template>
</el-table-column>
@@ -242,7 +247,7 @@
</div>
</el-table-column>
</el-table>
<div class="bottomBtnBox btns" v-if="chanFerForm.length > 0 && role == 'editor'">
<div class="bottomBtnBox btns" v-if="chanFerForm.length > 0 && role == 'editor'&& canDelete">
<el-button type="primary" plain @click="selectAllRef">Select all</el-button>
<el-button type="success" plain @click="toggleSelection">Select none</el-button>
<el-button type="danger" plain @click="deleteSomeRefs" :disabled="multipleSelection.length > 0 ? false : true"
@@ -546,6 +551,7 @@ export default {
},
addLoading: false,
editboxVisible: false,
canDelete: true,
multipleSelection: [] // 多选
};
},
@@ -572,18 +578,19 @@ export default {
}
},
methods: {
formatTitle(title) {
if (!title) return '';
// 使用正则匹配,'gi' 表示全局匹配且不区分大小写
// \b 确保是完整单词匹配,防止误伤含有这些字母的其他单词
const reg = /\b(Retracted|Retraction)\b/gi;
return title.replace(reg, (match) => {
return `<span style="color: red; font-weight: bold;">${match}</span>`;
});
}
,
getCurrentRoute() {
this.canDelete = !['/articleListEditor_B1'].includes(this.$route.path);
},
formatTitle(title) {
if (!title) return '';
// 使用正则匹配,'gi' 表示全局匹配且不区分大小写
// \b 确保是完整单词匹配,防止误伤含有这些字母的其他单词
const reg = /\b(Retracted|Retraction)\b/gi;
return title.replace(reg, (match) => {
return `<span style="color: red; font-weight: bold;">${match}</span>`;
});
},
getJournalDateno(dateno, type) {
if (dateno && typeof dateno === 'string') {
const hasInvalidColon = !dateno.includes(':') || (dateno.includes(':') && dateno.split(':').pop().trim() === '');
@@ -682,7 +689,7 @@ export default {
.then((res) => {
if (res.status == 1) {
return res.data;
}
}
throw res.msg;
})
.catch((err) => {
@@ -697,6 +704,7 @@ export default {
return {};
},
init(e) {
this.getCurrentRoute();
this.chanFerForm = e;
this.bijiao();
////console.log('更新更新')

File diff suppressed because it is too large Load Diff

View File

@@ -109,14 +109,7 @@
<div class="mail-content-panel" v-if="selectedAccount" v-loading="detailLoading">
<template v-if="activeMailId && !detailLoading">
<div class="mail-page">
<mail-detail
v-if="detailMail && String(detailMail.inbox_id || '') !== ''"
:mailData="detailMail"
:journal-id="mailDetailJournalId"
:journal-title="mailDetailJournalTitle"
:j-email-id="mailDetailJEmailId"
@close="closeDetail"
/>
<mail-detail v-if="detailMail" :mailData="detailMail" @close="closeDetail" />
</div>
</template>
@@ -175,7 +168,6 @@ const API = {
getAllJournal: 'api/Article/getJournal'
};
import MailDetail from '../../components/page/components/email/MailDetail.vue';
import { normalizeEmailHtmlForInlineDisplay } from '@/utils/emailHtmlView';
export default {
data() {
return {
@@ -223,39 +215,6 @@ 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() {
@@ -334,14 +293,8 @@ import { normalizeEmailHtmlForInlineDisplay } from '@/utils/emailHtmlView';
this.$api
.post(API.getOneEmail, { j_email_id: jEmailId })
.then((res) => {
const raw = res && res.data != null ? res.data : null;
const email =
raw && typeof raw === 'object' && raw.email && typeof raw.email === 'object'
? raw.email
: raw && typeof raw === 'object' && (raw.j_email_id != null || raw.smtp_user)
? raw
: null;
if (res && Number(res.code) === 0 && email) {
const email = res && res.data ? res.data.email : null;
if (res && res.code === 0 && email) {
this.selectedAccount = email;
this.fetchData();
this.startInboxSse();
@@ -536,103 +489,28 @@ fetchLatestSingleMail(jEmailId, journalId) {
this.currentFolder = f;
this.activeMailId = null;
},
/** 接口 code 可能是数字 0 或字符串 "0" */
isEmailApiSuccess(res) {
if (!res || res.code === undefined || res.code === null) return false;
return Number(res.code) === 0;
},
escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
},
/** 解析 getEmailDetail 返回体,统一 content 字段;失败时用列表行兜底 */
buildDetailMailFromResponse(res, item, inboxId) {
const ok = this.isEmailApiSuccess(res);
let d = res && res.data != null ? res.data : null;
if (d && typeof d === 'string') {
try {
d = JSON.parse(d);
} catch (e) {
d = null;
}
}
if (Array.isArray(d) && d.length) {
d = d[0];
}
if (ok && d && typeof d === 'object' && d.data && typeof d.data === 'object' && !d.content_html && !d.content_text) {
d = { ...d, ...d.data };
}
if (ok && (!d || typeof d !== 'object') && res && (res.content_html != null || res.content_text != null || res.subject != null)) {
d = { ...res };
delete d.code;
delete d.msg;
if (Object.prototype.hasOwnProperty.call(d, 'data')) delete d.data;
}
const inboxKey = String(inboxId);
const listSnippet = (item && item.content) || '';
if (!ok || !d || typeof d !== 'object') {
return {
...item,
subject: (item && item.subject) || '',
from_name: (item && item.from_name) || '',
from_email: (item && item.email) || '',
email_date: item && item.email_date,
content_html: listSnippet,
inbox_id: inboxKey,
attachments: [],
has_attachment: (item && item.has_attachment) || 0
};
}
const html = d.content_html || d.body_html || d.html || d.body || '';
const text = d.content_text || '';
let content_html =
html ||
(text ? `<pre class="mail-plain-pre">${this.escapeHtml(text)}</pre>` : '') ||
listSnippet;
if (content_html && typeof content_html === 'string' && /^<!DOCTYPE|^<\s*html[\s>]/i.test(content_html.trim())) {
const normalized = normalizeEmailHtmlForInlineDisplay(content_html);
if (normalized && normalized.trim()) content_html = normalized;
}
return {
...item,
...d,
content_html,
inbox_id: inboxKey,
attachments: []
};
},
selectMail(item, index) {
selectMail(item,index) {
this.activeMailId = item.id;
this.detailLoading = true;
this.detailMail = {};
const inboxId = item.inbox_id || item.id;
this.$api
.post(API.getEmailDetail, { inbox_id: inboxId })
.then((res) => {
try {
const merged = this.buildDetailMailFromResponse(res, item, inboxId);
this.detailMail = merged;
if (item) item.state = 0;
const row = this.displayList[index];
if (row && merged && merged.is_read !== undefined && merged.is_read !== null) {
this.$set(row, 'is_read', merged.is_read);
}
if (Number(merged.has_attachment) === 1) {
const d = res && res.data;
if (res && res.code === 0 && d) {
this.detailMail = { ...d, inbox_id: String(inboxId), attachments: [] };
item.state = 0;
this.displayList[index].is_read = d.is_read;
if (Number(d.has_attachment) === 1) {
this.fetchAttachments(String(inboxId));
return;
} else {
this.detailLoading = false;
}
} catch (e) {
console.error(e);
this.detailMail = this.buildDetailMailFromResponse(null, item, inboxId);
} else {
this.detailLoading = false;
}
this.detailLoading = false;
})
.catch(() => {
this.detailMail = this.buildDetailMailFromResponse(null, item, inboxId);
this.detailLoading = false;
});
},

View File

@@ -279,11 +279,14 @@
journal_id: this.query.journal_id || (this.journalList[0] ? this.journalList[0].journal_id : null),
account: '',
password: '',
// password: '123456qwe..%%%',
smtp_from_name: '',
smtp_host: 'mail.tmrjournals.co.nz',
// smtp_host: 'smtp.mxhichina.com',
smtp_port: '465',
smtp_encryption: 'ssl',
imap_host: 'mail.tmrjournals.co.nz',
// imap_host: 'imap.qiye.aliyun.com',
imap_port: '993',
};
this.dialogVisible = true;

View File

@@ -50,9 +50,6 @@
</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>
@@ -139,36 +136,6 @@
<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>
@@ -178,8 +145,7 @@ const API = {
listStyles: 'api/mail_template/listStyles',
getAllJournal: 'api/Article/getJournal',
deleteTemplate: 'api/mail_template/deleteTemplate',
deleteStyle: 'api/mail_template/deleteStyle',
saveTemplate: 'api/mail_template/saveTemplate'
deleteStyle: 'api/mail_template/deleteStyle'
};
// 仅在当前 SPA 会话内记忆筛选(刷新页面即重置)
const mailboxMouldSessionMemory = {
@@ -211,13 +177,7 @@ export default {
// --- 共用预览 ---
previewVisible: false,
previewContent: '',
/** 邮件模板 JSON 批量导入(期刊 ID 单独输入,与每条合并后调 saveTemplate */
batchTplImportVisible: false,
batchTplImportText: '',
batchTplImportJournalId: '',
batchTplImporting: false
previewContent: ''
};
},
created() {
@@ -321,147 +281,6 @@ 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);
@@ -600,39 +419,4 @@ 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

@@ -96,13 +96,13 @@
<div class="body-editor-container">
<div class="subject-label" style="margin-bottom: 10px;">{{ $t('mailboxMouldDetail.emailBody') }}:</div>
<TmrEmailEditor
<!-- <TmrEmailEditor
v-model="form.body"
:journalList="journalList"
:journalId="form.journalId"
:language="form.lang"
placeholder=""
/>
/> -->
<CkeditorMail v-model="form.body" />
</div>
</el-card>

View File

@@ -124,7 +124,7 @@ export default {
},
// 5----重新获取加载参考文献
changeRefer(val) {
console.log('重新获取参考文献')
this.$api
.post('api/Production/getReferList', {
'p_article_id': this.p_article_id

22
src/utils/autociteHtml.js Normal file
View File

@@ -0,0 +1,22 @@
/**
* 从 HTML 字符串中按出现顺序收集引用标签的 data-idmycite / autocite 与稿面、TinyMCE、历史库一致
* 支持逗号多 id、属性任意顺序、单/双引号
*/
export function extractAutociteIdsFromHtmlString(raw) {
if (!raw || typeof raw !== 'string') return [];
const ids = [];
const tagRe = /<(mycite|autocite)\b[^>]*>/gi;
let m;
while ((m = tagRe.exec(raw)) !== null) {
const tag = m[0];
const dbl = tag.match(/\bdata-id\s*=\s*"([^"]*)"/i);
const sgl = tag.match(/\bdata-id\s*=\s*'([^']*)'/i);
const val = dbl ? dbl[1] : sgl ? sgl[1] : '';
if (!val) continue;
val.split(',').forEach((part) => {
const id = part.trim();
if (id && !ids.includes(id)) ids.push(id);
});
}
return ids;
}

View File

@@ -0,0 +1,161 @@
/**
* Main_List 全文首次出现顺序(与 Edit Content / 稿面 / 底部参考文献列表共用)。
* 正文中 <mytable> 占位处需先插入对应表格段在 Main_List 中的表题/表体/表注引用,再计其后的 mycite。
*/
import { extractAutociteIdsFromHtmlString } from '@/utils/autociteHtml.js';
export function collectCiteStringsDeep(source, out, depth = 0) {
if (!source || depth > 6) return;
if (typeof source === 'string') {
const s = source.trim();
if (!s) return;
const maybeCite = /<(?:mycite|autocite)\b/i.test(s) || /\[[\d\s,\-–—]+\]/.test(s);
if (maybeCite) out.push(s);
return;
}
if (Array.isArray(source)) {
source.forEach((item) => collectCiteStringsDeep(item, out, depth + 1));
return;
}
if (typeof source === 'object') {
Object.keys(source).forEach((k) => {
const v = source[k];
collectCiteStringsDeep(v, out, depth + 1);
});
}
}
export function collectCiteHtmlSourcesForMainListItem(p, draftAmId, draftHtml) {
if (draftAmId != null && p && p.am_id == draftAmId && draftHtml != null) {
return [draftHtml];
}
const out = [];
const typ = p != null && p.type != null ? Number(p.type) : NaN;
if (typ === 2 && p.table) {
const t = p.table;
if (typeof t.title === 'string' && t.title.trim()) out.push(t.title);
if (typeof t.html_data === 'string' && t.html_data.trim()) {
out.push(t.html_data);
} else {
const pushRows = (rows) => {
if (!Array.isArray(rows)) return;
rows.forEach((row) => {
if (!Array.isArray(row)) return;
row.forEach((cell) => {
if (cell && typeof cell.text === 'string' && cell.text) out.push(cell.text);
});
});
};
pushRows(t.tableHeader);
pushRows(t.tableContent);
}
if (typeof t.note === 'string' && t.note.trim()) out.push(t.note);
collectCiteStringsDeep(t, out);
return out;
}
if (typ === 1 && p.image) {
const img = p.image;
if (typeof img.title === 'string' && img.title.trim()) out.push(img.title);
if (typeof img.note === 'string' && img.note.trim()) out.push(img.note);
if (out.length === 0) {
if (p && typeof p.content === 'string' && p.content.trim()) out.push(p.content);
if (p && typeof p.text === 'string' && p.text.trim()) out.push(p.text);
}
return out;
}
if (p && typeof p.text === 'string' && p.text.trim()) out.push(p.text);
if (p && typeof p.content === 'string' && p.content.trim()) out.push(p.content);
return out;
}
export function findMainListTableByLinkId(mainList, rawId) {
if (rawId == null || String(rawId).trim() === '') return null;
const sid = String(rawId).trim();
const list = Array.isArray(mainList) ? mainList : [];
return (
list.find((p) => {
if (!p || Number(p.type) !== 2) return false;
if (String(p.amt_id) === sid) return true;
if (String(p.am_id) === sid) return true;
if (p.p_main_table_id != null && String(p.p_main_table_id) === sid) return true;
if (p.table && p.table.amt_id != null && String(p.table.amt_id) === sid) return true;
if (p.table && p.table.am_id != null && String(p.table.am_id) === sid) return true;
if (p.table && p.table.p_main_table_id != null && String(p.table.p_main_table_id) === sid)
return true;
if (p.table && p.table.table_id != null && String(p.table.table_id) === sid) return true;
return false;
}) || null
);
}
/**
* @param {object} [options]
* @param {number|string|null} [options.tableDraftAmId] 正在编辑的表格段 am_idEdit Table 抽屉)
* @param {string|null} [options.tableDraftHtml] 与该表对应的 Title+表体+Note 合并稿;正文里 mytable 展开到该表时用此稿替代 Main_List 已存内容,使「全文 mytable 之前」的序号与抽屉内一致
*/
export function extractAutociteOrderFromDraftHtmlWithInlineTables(html, mainList, options = {}) {
const order = [];
const pushUnique = (id) => {
if (id && !order.includes(id)) order.push(id);
};
const pushFromRaw = (raw) => {
extractAutociteIdsFromHtmlString(raw).forEach((id) => pushUnique(id));
};
if (!html || typeof html !== 'string') return order;
const re = /<mytable\b[^>]*>[\s\S]*?<\/mytable>/gi;
let last = 0;
let m;
while ((m = re.exec(html)) !== null) {
const before = html.slice(last, m.index);
if (before) pushFromRaw(before);
const openEnd = html.indexOf('>', m.index) + 1;
const openTag = html.slice(m.index, openEnd);
const dm = openTag.match(/\bdata-id\s*=\s*["']([^"']*)["']/i);
const tid = dm ? String(dm[1]).trim() : '';
const tableP = findMainListTableByLinkId(mainList, tid);
if (tableP) {
const useDraft =
options.tableDraftAmId != null &&
options.tableDraftHtml != null &&
String(tableP.am_id) === String(options.tableDraftAmId);
const sources = useDraft
? [options.tableDraftHtml]
: collectCiteHtmlSourcesForMainListItem(tableP, null, null);
sources.forEach((raw) => pushFromRaw(raw));
}
last = m.index + m[0].length;
}
const after = html.slice(last);
if (after) pushFromRaw(after);
return order;
}
export function extractAutociteOrderFromMainList(mainList, draftAmId, draftHtml, options = {}) {
const list = Array.isArray(mainList) ? mainList : [];
const order = [];
list.forEach((p) => {
if (draftAmId != null && p && p.am_id == draftAmId && draftHtml != null) {
extractAutociteOrderFromDraftHtmlWithInlineTables(draftHtml, list, options).forEach((id) => {
if (!order.includes(id)) order.push(id);
});
return;
}
const candidates = collectCiteHtmlSourcesForMainListItem(p, draftAmId, draftHtml);
candidates.forEach((raw) => {
if (typeof raw === 'string' && /<mytable\b/i.test(raw)) {
extractAutociteOrderFromDraftHtmlWithInlineTables(raw, list, options).forEach((id) => {
if (!order.includes(id)) order.push(id);
});
} else {
extractAutociteIdsFromHtmlString(raw).forEach((id) => {
if (!order.includes(id)) order.push(id);
});
}
});
});
return order;
}

View File

@@ -1,26 +0,0 @@
/**
* 将完整 HTML 邮件转为适合 div[v-html] 的片段:
* 取 body.innerHTML并前置 head 内 <style>,避免版式与改版前不一致。
*/
export function normalizeEmailHtmlForInlineDisplay(html) {
if (!html || typeof html !== 'string') return '';
const t = html.trim();
if (!/^<!DOCTYPE|^<\s*html[\s>]/i.test(t)) return html;
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const body = doc && doc.body;
if (!body) return html;
const inner = body.innerHTML;
if (!inner || !inner.trim()) return html;
let headInject = '';
const head = doc.head;
if (head) {
head.querySelectorAll('style').forEach((node) => {
headInject += node.outerHTML;
});
}
return headInject + inner;
} catch (e) {
return html;
}
}

View File

@@ -1,15 +0,0 @@
/**
* 基于 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

@@ -1,381 +0,0 @@
/**
* 邮件 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 };
}
}