审稿人证书生成

This commit is contained in:
2026-07-10 09:12:09 +08:00
parent 262e634f85
commit 3448ffe961
29 changed files with 7825 additions and 492 deletions

9
package-lock.json generated
View File

@@ -2178,13 +2178,10 @@
"dev": true
},
"node_modules/@babel/runtime": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz",
"integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==",
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}

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: '/', //正式
});

View File

@@ -7,6 +7,7 @@ import { MathfieldElement } from 'mathlive';
import 'mathlive/dist/mathlive-static.css';
import 'mathlive/dist/mathlive-fonts.css';
import { importWordDocumentWithMath as parseWordDocumentWithMath, parseHtmlToLatex as convertHtmlToLatex, postProcessImportedWordHtml, parseImportedHtmlToContentRows, mergeAdjacentBlueTags, normalizeSpacesAroundBlueTags } from '@/utils/wordMathImport';
import { wrapReferenceHighlightsInBlueHtml } from '@/utils/manuscriptMediaReferences';
import api from '../../api/index.js';
import Common from '@/components/common/common'
import Tiff from 'tiff.js';
@@ -852,32 +853,8 @@ export default {
// 首字母大写
str = capitalizeFirstLetter(str);
// 添加蓝色标签
// 1. 修改正则,只匹配不在 <blue> 标签内的 [数字]
// 这里的思路是:匹配 [内容],但通过逻辑过滤掉已经被包裹的情况
const regex = /\[(\d+(?:\d+)?(?:, ?\d+(?:\d+)?)*)\]/g;
// 注意:不要在最上面执行 str.replace(/<blue>/g, ''),否则之前的标记就全白做了
str = str.replace(regex, function (match, content, offset, fullString) {
// 【关键判断】:检查匹配位置的前后,是否已经存在 <blue> 和 </blue>
const prefix = fullString.substring(offset - 6, offset); // <blue> 是 6 位
const suffix = fullString.substring(offset + match.length, offset + match.length + 7); // </blue> 是 7 位
if (prefix === '<blue>' && suffix === '</blue>') {
return match; // 如果已经有标签了,原样返回,不重复标记
}
// 判断逻辑:纯数字、逗号空格、连字符
if (/^\d+$/.test(content) || /, ?/.test(content) || //.test(content)) {
return `<blue>${match}</blue>`;
}
return match;
});
// 添加蓝色标签:参考文献标号 + Figure/Table 引用
str = wrapReferenceHighlightsInBlueHtml(str);
return str;
@@ -1581,6 +1558,7 @@ str = str.replace(regex, function (match, content, offset, fullString) {
text = text.replace(/<[^\/>]+>\s*<\/[^>]+>/gi, (match) => (match.trim() === '' ? '' : match));
text = mergeAdjacentBlueTags(text);
text = normalizeSpacesAroundBlueTags(text);
text = wrapReferenceHighlightsInBlueHtml(text);
parsedData.push(text.trim() === '' ? '' : text);
});

View File

@@ -62,7 +62,17 @@
<template v-else>
<el-menu-item :index="item.index" :key="item.index">
<i :class="item.icon"></i>
<span slot="title"> {{ item.title }}</span>
<template slot="title">
<el-badge
v-if="item.index === 'journalFeeApproval'"
is-dot
:hidden="applyBadgeFeeApproval <= 0"
class="sidebar-menu-fee-approval-badge"
>
<span>{{ item.title }}</span>
</el-badge>
<span v-else>{{ item.title }}</span>
</template>
</el-menu-item>
</template>
</template>
@@ -237,6 +247,7 @@ export default {
menuList: [],
/** 青年编委申请红点Young Scientist 父级 + Apply 子项,数据来自 getYboardApplys */
applyBadgeYouth: 0,
applyBadgeFeeApproval: 0,
items: [],
// 作者
author_items: [
@@ -333,7 +344,6 @@ export default {
title: this.$t('menu.userManSys6')
},
{
//论文出版监督
icon: 'el-icon-lx-copy',
index: 'JournalCitationAnalysis',
title: this.$t('menu.JournalCitationAnalysis')
@@ -773,74 +783,26 @@ export default {
//超级管理员
this.items = this.admin_items;
}
if (this.isFeeApprovalAccount()) {
this.items.push({
icon: 'el-icon-money',
index: 'journalFeeApproval',
title: this.$t('menu.journalFeeApproval')
});
this.fetchFeeApprovalBadge();
}
if (this.isJiajianAccount()) {
if (!this.user_cap.includes('superadmin')) {
this.injectChiefInspectorMenu();
}
}
if (this.user_cap.includes('superadmin')) {
var superadminData = [];
this.items.splice(
3,
0,
{
icon: 'el-icon-s-platform',
index: '13',
title: this.$t('sidebar.chiefInspector'),
subs: [
{
index: 'Classificationmanagement',
title: this.$t('sidebar.journalManagement'),
subs: [
{
index: 'JournalManagementAll',
title: this.$t('sidebar.journalList')
},
{
index: 'GroupClassification',
title: this.$t('sidebar.GroupClassification')
}
]
},
{
index: 'Academicresourcesupervise',
title: this.$t('menu.Academicresourcesupervise'),
subs: [
{
index: 'editorialBoard',
title: this.$t('sidebar.editorialBoard1')
},
{
index: 'superYoungScientistManagement',
title: this.$t('sidebar.editorialBoard2')
},
{
index: 'superJournalsManagement',
title: this.$t('sidebar.editorialBoard3')
}
]
},
{
index: 'publicationsupervise',
title: this.$t('menu.publicationsupervise'),
subs: [
{
index: 'allPaperSubmitanalysis',
// index: 'superYoungScientistManagement',
title: this.$t('menu.papersubmit')
},
{
index: '18',
title: this.$t('menu.ArticlePublication')
}
]
},
{
index: 'allJournalCitationAnalysis',
title: this.$t('menu.JournalCitationAnalysis')
}
]
}
this.getChiefInspectorMenuItem()
);
this.items = [
@@ -868,13 +830,88 @@ export default {
bus.$on('apply-badge-refresh', () => {
this.fetchApplyBadgeSummary();
});
bus.$on('fee-approval-badge-refresh', () => {
this.fetchFeeApprovalBadge();
});
},
beforeDestroy() {
bus.$off('apply-badge-refresh');
bus.$off('fee-approval-badge-refresh');
},
methods: {
// 获取数据
getDate() {},
isFeeApprovalAccount() {
return String(localStorage.getItem('U_id') || '') === '24950';
},
isJiajianAccount() {
const name = String(localStorage.getItem('U_name') || '').toLowerCase();
const uid = String(localStorage.getItem('U_id') || '');
return name === 'jiajian' || uid === '29578';
},
getChiefInspectorMenuItem() {
return {
icon: 'el-icon-s-platform',
index: '13',
title: this.$t('sidebar.chiefInspector'),
subs: [
{
index: 'Classificationmanagement',
title: this.$t('sidebar.journalManagement'),
subs: [
{
index: 'JournalManagementAll',
title: this.$t('sidebar.journalList')
},
{
index: 'GroupClassification',
title: this.$t('sidebar.GroupClassification')
}
]
},
{
index: 'Academicresourcesupervise',
title: this.$t('menu.Academicresourcesupervise'),
subs: [
{
index: 'editorialBoard',
title: this.$t('sidebar.editorialBoard1')
},
{
index: 'superYoungScientistManagement',
title: this.$t('sidebar.editorialBoard2')
},
{
index: 'superJournalsManagement',
title: this.$t('sidebar.editorialBoard3')
}
]
},
{
index: 'publicationsupervise',
title: this.$t('menu.publicationsupervise'),
subs: [
{
index: 'allPaperSubmitanalysis',
title: this.$t('menu.papersubmit')
},
{
index: '18',
title: this.$t('menu.ArticlePublication')
}
]
},
{
index: 'allJournalCitationAnalysis',
title: this.$t('menu.JournalCitationAnalysis')
}
]
};
},
injectChiefInspectorMenu() {
if (this.items.some((item) => item.index === '13')) return;
this.items.splice(3, 0, this.getChiefInspectorMenuItem());
},
fetchApplyBadgeSummary() {
if (String(this.userrole) !== '1') return;
const editorId = localStorage.getItem('U_id');
@@ -891,6 +928,127 @@ export default {
.catch(() => {
this.applyBadgeYouth = 0;
});
},
fetchFeeApprovalBadge() {
if (!this.isFeeApprovalAccount()) return;
this.$api
.post('api/Order/getApplyCount', {})
.then((res) => {
if (res && res.code === 0) {
const pendingCount = this.extractFeeApplyPendingCount(res.data);
this.applyBadgeFeeApproval = pendingCount > 0 ? 1 : 0;
if (pendingCount > 0) {
this.showFeeApprovalNotifyOnce(pendingCount);
}
} else {
this.applyBadgeFeeApproval = 0;
}
})
.catch(() => {
this.applyBadgeFeeApproval = 0;
});
},
showFeeApprovalNotifyOnce(pendingCount) {
const uid = String(localStorage.getItem('U_id') || '');
const storageKey = 'feeApprovalNotifyShown_' + uid;
if (!uid || sessionStorage.getItem(storageKey)) return;
if (this.$route.path === '/journalFeeApproval') {
sessionStorage.setItem(storageKey, '1');
return;
}
sessionStorage.setItem(storageKey, '1');
const h = this.$createElement;
this.$notify({
title: this.$t('journalFeeApproval.notifyTitle'),
message: h(
'div',
{
style: {
width: '260px',
cursor: 'pointer'
},
on: {
click: () => {
this.$router.push('/journalFeeApproval');
}
}
},
[
h(
'p',
{
style: {
color: '#006699',
margin: '0 0 6px',
lineHeight: '1.5'
}
},
this.$t('journalFeeApproval.notifyMessage', { count: pendingCount })
),
h(
'p',
{
style: {
color: '#909399',
margin: 0,
fontSize: '12px',
lineHeight: '1.5'
}
},
this.$t('journalFeeApproval.notifyHint')
)
]
),
type: 'warning',
position: 'bottom-right',
duration: 3000,
onClick: () => {
this.$router.push('/journalFeeApproval');
}
});
},
extractFeeApplyList(data) {
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
return data.list || data.applies || data.applys || data.items || data.data || [];
},
extractFeeApplyPendingCount(data) {
if (!data) return 0;
if (Array.isArray(data)) {
const pendingItem = data.find(function (item) {
if (!item || typeof item !== 'object') return false;
const state = item.state !== undefined ? item.state : item.status;
return Number(state) === 0;
});
if (pendingItem) {
return Number(pendingItem.count != null ? pendingItem.count : pendingItem.num != null ? pendingItem.num : pendingItem.total) || 0;
}
return 0;
}
if (typeof data === 'object') {
return (
Number(
data.pending != null
? data.pending
: data.wait != null
? data.wait
: data.pending_count != null
? data.pending_count
: data.state0 != null
? data.state0
: data['0'] != null
? data['0']
: 0
) || 0
);
}
return 0;
},
isFeeApplyPending(item) {
if (!item || typeof item !== 'object') return false;
const status = item.status !== undefined ? item.status : item.state;
if (status === undefined || status === null || status === '') return true;
return status === 0 || status === '0' || status === 'pending' || status === 'wait' || status === 'waiting';
}
}
};
@@ -999,4 +1157,16 @@ export default {
color: inherit;
vertical-align: middle;
}
.sidebar-menu-fee-approval-badge {
display: inline-block;
vertical-align: middle;
}
.sidebar-menu-fee-approval-badge ::v-deep .el-badge__content.is-dot {
top: 50%;
right: -8px;
border: 0;
background-color: #f56c6c;
}
</style>

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

@@ -80,6 +80,7 @@ const en = {
PaperSubmitanalysis: 'Paper Submit Analysis',
ArticlePublicationanalysis: 'Article Publication Analysis',
journalArticleCount: 'Article Count',
journalFeeApproval: 'Journal Fee Approval',
Promotionsystem: 'Promotion System',
Userdatabase: 'User Database',
analysis: 'Article Analysis',
@@ -1211,6 +1212,112 @@ const en = {
citeRelevanceCopyGroup: 'Copy paragraph',
citeRelevanceDownloadHtml: 'Download HTML',
citeRelevanceDownloadHtmlSuccess: 'HTML downloaded',
citeRelevanceWithUploadedRefs: 'Group export will use {n} uploaded reference(s)',
citeRelevanceDownloadHtmlSuccessWithUploaded: 'HTML downloaded (based on {n} uploaded reference(s))',
refAnnotationReportTip: 'Download annotated HTML relevance report (run AI check first)',
refAnnotationReportShort: 'Report',
exportRelevanceWordTip: 'Export Word with citation relevance comments (requires AI audit; yellow/red items become comments)',
exportRelevanceWordShort: 'Rel. Word',
exportRelevanceWordSuccess: 'Word with relevance comments downloaded',
exportRelevanceWordFail: 'Failed to export Word with relevance comments',
mediaRefCheckWarn: 'Media reference check: body cites missing {list}',
mediaRefCheckMore: ' ({n} total)',
wordRefsPreviewShort: 'Word refs',
wordRefsPreviewTitle: 'Original Word · References',
wordRefsPreviewTotal: 'Total: {count} references',
wordRefsPreviewClose: 'Close',
wordRefsPreviewCopy: 'Copy plain text',
wordRefsPreviewCopySuccess: 'References copied',
wordRefsPreviewCopyFail: 'Copy failed',
wordRefsPreviewNoArticle: 'Missing article ID — cannot load Word manuscript',
wordRefsPreviewNoManuscript: 'Manuscript Word file not found (gridData / getFilesForArticle)',
wordRefsPreviewEmpty: 'No References section found in the original Word manuscript',
wordRefsPreviewFail: 'Failed to load Word references',
wordRefBatchUploadShort: 'Upload proofread Word',
wordRefBatchUploadTip: 'Upload proofread Word references, map fields by index, and batch save',
wordRefBatchDialogTitle: 'Word references · field mapping',
wordRefBatchApply: 'Apply',
wordRefBatchStatus: 'Status',
wordRefBatchType: 'Type',
wordRefBatchAuthor: 'Author',
wordRefBatchTitle: 'Title',
wordRefBatchJournal: 'Journal',
wordRefBatchDateno: 'Publication',
wordRefBatchDoi: 'DOI/URL',
wordRefBatchExisting: 'Current in system',
wordRefBatchMatched: 'Matched',
wordRefBatchMissing: 'Missing in Word',
wordRefBatchExtra: 'No system row',
wordRefBatchSelectAll: 'Select all',
wordRefBatchSelectNone: 'Select none',
wordRefBatchReparse: 'Re-parse fields (server)',
wordRefBatchReparseSuccess: 'Fields re-parsed',
wordRefBatchReparseFail: 'Field parsing failed',
wordRefBatchSave: 'Save selected',
wordRefBatchSaveSuccess: 'Saved {ok}, failed {fail}',
wordRefBatchSaveFail: 'Batch save failed',
wordRefBatchNothingSelected: 'Select at least one reference to save',
wordRefBatchParseFail: 'Failed to parse Word references',
wordRefBatchCountMismatch: 'Count mismatch: system {existing}, Word {uploaded}. Please verify before saving.',
wordRefBatchClickToEdit: 'Click text to edit',
wordRefBatchFieldEmpty: 'Click to fill',
wordRefBatchShowExisting: 'Compare',
wordRefBatchHideExisting: 'Hide',
wordRefBatchParseFailed: 'Incomplete parsing — please fill in each field',
wordRefBatchWordOriginal: 'Word original',
refAnnotationReportSuccess: 'Annotation report HTML downloaded',
refAnnotationReportFail: 'Failed to download annotation report',
refAnnotationReportNoArticle: 'Missing article ID — cannot load AI check data',
refAnnotationReportNoData: 'No AI relevance results yet — run the check on Reference Conversion first',
refAnnotationReportTitleSuffix: 'Review report',
refAnnotationReportManuscriptPreview: 'Manuscript preview: {id}',
refAnnotationReportRefMatchPrefix: 'Reference source: ',
refAnnotationReportRefLibraryPrefix: 'Reference library: ',
refAnnotationReportRefTotal: '{n} entries',
refAnnotationReportAnalysisTitle: 'Reference analysis',
refAnnotationReportStatTotal: 'Reviewed',
refAnnotationReportStatDirect: 'Direct match',
refAnnotationReportStatStrong: 'Strong match',
refAnnotationReportStatWeak: 'Weak match',
refAnnotationReportStatNone: 'Not relevant',
refAnnotationReportLocateTip: 'Locate and highlight the paragraph on the left',
refAnnotationReportLocatePara: 'Paragraph {n} on the left (click to locate)',
refAnnotationReportParaRefsPrefix: 'Citations in paragraph: ',
refAnnotationReportVerdictLabel: 'Verdict: ',
refAnnotationReportReasonLabel: 'Reason: ',
refAnnotationReportAuthorCommentLabel: 'Editor comment: ',
refAnnotationReportNoReason: 'No details',
refAnnotationReportConclusionLabel: 'Conclusion: ',
refAnnotationReportConclusionKeep: 'Keep',
refAnnotationReportConclusionRevise: 'Revise suggested',
refAnnotationReportNoConclusion: 'No conclusion',
refAnnotationReportScoreLabel: 'Relevance: {score}%',
refAnnotationReportCiteLocLabel: 'Citation {n}',
refAnnotationReportCiteParaSuffix: ' · Para {para}',
refAnnotationReportParaSectionTitle: 'By paragraph',
refAnnotationReportRefConclusionSectionTitle: 'References · all review conclusions',
refAnnotationReportRefCiteTotal: '{n} in-text citation(s)',
refAnnotationReportSummaryTitle: 'AI summary: ',
refAnnotationReportSummaryPrefix: 'The following references are weakly related; please verify: ',
refAnnotationReportSummaryRefPrefix: 'Ref. ',
refAnnotationReportWarnBadge: 'Weak / irrelevant citations found',
refAnnotationReportIssueNavTitle: 'Paragraphs needing attention',
refAnnotationReportIssueNavHint: 'Click a paragraph number to jump to review notes',
refAnnotationReportIssueCountLabel: '{n} revise suggested',
refAnnotationReportJumpToReview: 'View notes →',
refAnnotationReportJumpToReviewTip: 'Jump to review notes for this paragraph',
refAnnotationReportLocateSource: '↩ Locate source',
refAnnotationReportParaBadge: 'Paragraph {n}',
refAnnotationReportParaOkLabel: 'Citations OK',
refAnnotationReportLegendIssue: 'Red border: paragraph has citations to revise',
refAnnotationReportLegendOk: 'Normal paragraph: citations checked and OK',
refAnnotationReportLegendCite: 'Click citation no. or “View notes” to jump to the right panel',
refAnnotationReportNoData: 'No AI relevance results yet. Run the check on Reference Conversion first.',
refAnnotationReportDefaultTitle: 'manuscript',
refAnnotationReportLevelDirect: 'Direct match',
refAnnotationReportLevelStrong: 'Strong match',
refAnnotationReportLevelWeak: 'Weak match',
refAnnotationReportLevelNone: 'Not relevant',
refHtmlCopy: 'Copy references',
refHtmlCopyShort: 'Copy',
refHtmlCopySuccess: 'References copied (numbering, italic journal, blue DOI, Available at line break)',
@@ -1251,6 +1358,10 @@ const en = {
refHtmlSaveTip: 'Tip: save the HTML after editing the full block, or copy the content and use "Paste references" on the typesetting page.',
refHtmlDownloadEdited: 'Download edited HTML',
refHtmlDownloadFileName: 'references-edited.html',
refHtmlCopyBracket: 'Copy [n] format',
refHtmlCopyBracketSuccess: 'Copied to clipboard ([n] numbering, line break after Available at:)',
refHtmlCopyBracketEmpty: 'Nothing to copy in the editor',
refHtmlCopyBracketFail: 'Copy failed — please select and copy manually',
exportImg: 'Export PNG',
PaperRotation: 'Paper Rotation',
removeAnnotations: 'Are you sure you want to delete this Annotation?',
@@ -1345,6 +1456,7 @@ const en = {
youhui: 'Final price',
discountprice: 'Final price after discount',
youhuiremark: 'Discount description',
applyPrice: 'Apply',
submitOrder: 'Make a payment',
state0: 'Pending payment',
state1: 'Payment successfully',
@@ -1358,6 +1470,71 @@ const en = {
Paymentstatus: 'Payment status',
time: 'Payment time',
},
journalFeeApproval: {
title: 'Journal Fee Approval',
pendingList: 'Pending Price Change Requests',
pageDesc: 'Review APC change requests submitted by editors. Search by article title or SN, verify the original fee, requested fee, and remark, then approve or reject.',
searchLabel: 'Search',
colArticleInfo: 'Article Information',
colPriceChange: 'Price Change Details',
colApproval: 'Approval',
historyTitle: 'Application History',
no: 'No.',
articleId: 'Article ID',
journal: 'Journal',
articleTitle: 'Article Title',
sn: 'SN',
originalFee: 'Original APC (USD)',
applyFee: 'Requested APC (USD)',
remark: 'Change Remark',
status: 'Status',
operator: 'Applicant',
applyTime: 'Applied At',
action: 'Approval',
accept: 'Approve',
reject: 'Reject',
cancel: 'Cancel',
tip: 'Tip',
acceptConfirm: 'Approve this price change request?',
rejectTitle: 'Reject Application',
rejectRemark: 'Reject Remark',
rejectRemarkPlaceholder: 'Please enter the reason for rejection',
rejectRemarkRequired: 'Reject remark is required',
noPending: 'No pending applications',
noAccepted: 'No approved applications',
noRejected: 'No rejected applications',
tabPending: 'Pending',
tabAccepted: 'Approved',
tabRejected: 'Rejected',
noHistory: 'No application history',
viewHistory: 'Application History',
refresh: 'Refresh',
search: 'Search',
searchPlaceholder: 'Enter article title or SN',
noSearchResult: 'No matching pending applications',
currentArticle: 'Current article',
selectArticleHint: 'Click a row in the pending list, or enter an article ID to view history',
articleIdPlaceholder: 'Article ID',
searchHistory: 'Search',
articleIdRequired: 'Please enter an article ID',
missingArticleId: 'Missing article_id on this record',
historyLoadFailed: 'Failed to load application history',
feeChange: 'Fee Change',
loadFailed: 'Failed to load data',
actionFailed: 'Operation failed',
acceptSuccess: 'Application approved',
rejectSuccess: 'Application rejected',
noPermission: 'You do not have permission to access this page',
statusApply: 'Submitted',
statusAccept: 'Approved',
statusReject: 'Rejected',
statusPending: 'Pending',
statusOther: 'Other',
viewArticleDetail: 'Click to view article details',
notifyTitle: 'Journal Fee Approval',
notifyMessage: 'You have {count} pending price change request(s)',
notifyHint: 'Click to open the approval page',
},
PreAccept: {
successInfo: 'Congratulations! Your manuscript has entered into <b>Pre-accept</b> status. Now please check and complete the necessary information of your manuscript for final publication.',
step1: 'Article Processing Charge',
@@ -1941,6 +2118,16 @@ const en = {
briefNeedDiff: 'Clarify',
briefDelete: 'Drop',
briefYearMismatch: 'Year?'
},
reviewHistory: {
certificateTitle: 'Certificate',
exportCertificateImage: 'Download Image',
generateCertificatePdf: 'Generate PDF',
exportCertificateImageSuccess: 'Certificate image exported',
exportCertificateImageFail: 'Failed to export certificate image',
generateCertificatePdfSuccess: 'Certificate PDF generated',
generateCertificatePdfFail: 'Failed to generate certificate PDF',
noCertificate: 'No certificate'
}

View File

@@ -79,6 +79,7 @@ const zh = {
ArticlePublicationanalysis: '文章发表分析',
journalArticleCount: '文章计数',
journalFeeApproval: '期刊收费审批',
Promotionsystem: '推广管理',
Userdatabase: '用户数据库',
analysis: '文章分析',
@@ -1197,6 +1198,112 @@ const zh = {
citeRelevanceCopyGroup: '复制本段',
citeRelevanceDownloadHtml: '下载 HTML',
citeRelevanceDownloadHtmlSuccess: 'HTML 已下载',
citeRelevanceWithUploadedRefs: '将使用已上传的 {n} 条参考文献进行分组导出',
citeRelevanceDownloadHtmlSuccessWithUploaded: 'HTML 已下载(基于已上传的 {n} 条参考文献)',
refAnnotationReportTip: '下载带批注的 HTML 相关性分析报告(需先完成 AI 检测)',
refAnnotationReportShort: '批注报告',
exportRelevanceWordTip: '导出带引文相关性批注的 Word需先完成 AI 检测;黄/红项原因写入批注)',
exportRelevanceWordShort: '相关性Word',
exportRelevanceWordSuccess: '带相关性批注的 Word 已下载',
exportRelevanceWordFail: '带相关性批注的 Word 导出失败',
mediaRefCheckWarn: '图表引用校对:正文引用了未排版的 {list}',
mediaRefCheckMore: ' 等共 {n} 处',
wordRefsPreviewShort: 'Word 参考文献',
wordRefsPreviewTitle: '原 Word 稿件 · 参考文献',
wordRefsPreviewTotal: '共 {count} 条参考文献',
wordRefsPreviewClose: '关闭',
wordRefsPreviewCopy: '复制纯文本',
wordRefsPreviewCopySuccess: '参考文献内容已复制',
wordRefsPreviewCopyFail: '复制失败',
wordRefsPreviewNoArticle: '缺少稿件 ID无法读取 Word 正文',
wordRefsPreviewNoManuscript: '未找到稿件 Word 文件gridData / getFilesForArticle',
wordRefsPreviewEmpty: '未在原 Word 稿件中识别到 References / 参考文献 段落',
wordRefsPreviewFail: '读取 Word 参考文献失败',
wordRefBatchUploadShort: '上传校对 Word',
wordRefBatchUploadTip: '上传校对改好的 Word 参考文献,按序号拆解字段并批量保存到系统',
wordRefBatchDialogTitle: 'Word 参考文献 · 字段对照',
wordRefBatchApply: '应用',
wordRefBatchStatus: '状态',
wordRefBatchType: '类型',
wordRefBatchAuthor: '作者',
wordRefBatchTitle: '标题',
wordRefBatchJournal: '期刊',
wordRefBatchDateno: '出版信息',
wordRefBatchDoi: 'DOI/链接',
wordRefBatchExisting: '系统现有',
wordRefBatchMatched: '已对应',
wordRefBatchMissing: 'Word 缺失',
wordRefBatchExtra: '系统无此项',
wordRefBatchSelectAll: '全选',
wordRefBatchSelectNone: '全不选',
wordRefBatchReparse: '服务端精拆字段',
wordRefBatchReparseSuccess: '字段已重新拆解',
wordRefBatchReparseFail: '字段拆解失败',
wordRefBatchSave: '保存选中项',
wordRefBatchSaveSuccess: '已保存 {ok} 条,失败 {fail} 条',
wordRefBatchSaveFail: '批量保存失败',
wordRefBatchNothingSelected: '请至少勾选一条可保存的参考文献',
wordRefBatchParseFail: 'Word 参考文献解析失败',
wordRefBatchCountMismatch: '条数不一致:系统 {existing} 条Word {uploaded} 条,请核对后再保存',
wordRefBatchClickToEdit: '点击文字即可修改',
wordRefBatchFieldEmpty: '点击填写',
wordRefBatchShowExisting: '对比系统',
wordRefBatchHideExisting: '收起',
wordRefBatchParseFailed: '拆解不完整,请逐项填写',
wordRefBatchWordOriginal: 'Word 原文',
refAnnotationReportSuccess: '批注分析报告 HTML 已下载',
refAnnotationReportFail: '批注分析报告下载失败',
refAnnotationReportNoArticle: '缺少稿件 ID无法获取 AI 检测数据',
refAnnotationReportNoData: '暂无 AI 相关性检测结果,请先在参考文献转换页完成检测',
refAnnotationReportTitleSuffix: '审稿报告',
refAnnotationReportManuscriptPreview: 'word 原稿预览:{id}',
refAnnotationReportRefMatchPrefix: '文献匹配依据:',
refAnnotationReportRefLibraryPrefix: '匹配文献库:',
refAnnotationReportRefTotal: '共 {n} 条',
refAnnotationReportAnalysisTitle: '文献深度分析',
refAnnotationReportStatTotal: '已审文献数',
refAnnotationReportStatDirect: '直接相关',
refAnnotationReportStatStrong: '强相关',
refAnnotationReportStatWeak: '弱相关',
refAnnotationReportStatNone: '不相关',
refAnnotationReportLocateTip: '点击定位并在左侧高亮原文段落',
refAnnotationReportLocatePara: '对应左侧段落 {n} (点击定位)',
refAnnotationReportParaRefsPrefix: '本段引用:',
refAnnotationReportVerdictLabel: '判定:',
refAnnotationReportReasonLabel: '理由:',
refAnnotationReportAuthorCommentLabel: '编辑批注:',
refAnnotationReportNoReason: '暂无说明',
refAnnotationReportConclusionLabel: '校对结论:',
refAnnotationReportConclusionKeep: '可保留',
refAnnotationReportConclusionRevise: '建议修改',
refAnnotationReportNoConclusion: '暂无结论',
refAnnotationReportScoreLabel: '相关性:{score}%',
refAnnotationReportCiteLocLabel: '引用处 {n}',
refAnnotationReportCiteParaSuffix: ' · 段落 {para}',
refAnnotationReportParaSectionTitle: '按正文段落',
refAnnotationReportRefConclusionSectionTitle: '参考文献 · 全部校对结论',
refAnnotationReportRefCiteTotal: '共 {n} 处引用',
refAnnotationReportSummaryTitle: 'AI批注总结',
refAnnotationReportSummaryPrefix: '以下文献相关性较弱,建议核实:',
refAnnotationReportSummaryRefPrefix: '文献',
refAnnotationReportWarnBadge: '发现弱/不相关文献',
refAnnotationReportIssueNavTitle: '需关注段落',
refAnnotationReportIssueNavHint: '点击段落号快速跳转到右侧批注意见',
refAnnotationReportIssueCountLabel: '{n} 处建议修改',
refAnnotationReportJumpToReview: '查看批注 →',
refAnnotationReportJumpToReviewTip: '跳转到右侧该段批注意见',
refAnnotationReportLocateSource: '↩ 定位原文',
refAnnotationReportParaBadge: '段落 {n}',
refAnnotationReportParaOkLabel: '引用正常',
refAnnotationReportLegendIssue: '红色边框:该段有建议修改的引用',
refAnnotationReportLegendOk: '正常段落:引用已检测且可保留',
refAnnotationReportLegendCite: '点击引用编号或「查看批注」跳转右侧意见',
refAnnotationReportNoData: '暂无 AI 相关性检测结果,请先在参考文献转换页完成检测。',
refAnnotationReportDefaultTitle: 'manuscript',
refAnnotationReportLevelDirect: '直接相关',
refAnnotationReportLevelStrong: '强相关',
refAnnotationReportLevelWeak: '弱相关',
refAnnotationReportLevelNone: '不相关',
refHtmlCopy: '复制参考文献',
refHtmlCopyShort: '复制',
refHtmlCopySuccess: '参考文献已复制(含编号、期刊斜体、蓝色 DOI、Available at 换行)',
@@ -1237,6 +1344,10 @@ const zh = {
refHtmlSaveTip: '提示:整段编辑后点击上方按钮保存 HTML也可复制内容后在排版页「粘贴参考文献」导出 Word。',
refHtmlDownloadEdited: '下载编辑后的 HTML',
refHtmlDownloadFileName: 'references-edited.html',
refHtmlCopyBracket: '复制 [编号] 格式',
refHtmlCopyBracketSuccess: '已复制到剪贴板([n] 编号Available at: 后换行)',
refHtmlCopyBracketEmpty: '编辑框中没有可复制的内容',
refHtmlCopyBracketFail: '复制失败,请手动选择复制',
exportImg: '导出 图片',
PaperRotation: '纸张方向',
removeAnnotations: '确定要删除这条批注吗?',
@@ -1329,6 +1440,7 @@ const zh = {
youhui: '折扣价',
discountprice: '折扣价',
youhuiremark: '折扣说明',
applyPrice: '申请',
submitOrder: '付款',
state0: '待付款',
state1: '已缴费',
@@ -1342,6 +1454,71 @@ const zh = {
Paymentstatus: '缴费状态',
time: '缴费时间',
},
journalFeeApproval: {
title: '期刊收费审批',
pendingList: '待审批改价申请',
pageDesc: '以下为编辑提交的稿件处理费改价申请。您可按文章标题或 SN 号搜索,核对原金额、申请金额及改价说明后,选择同意或驳回。',
searchLabel: '搜索',
colArticleInfo: '文章信息',
colPriceChange: '改价详情',
colApproval: '审批操作',
historyTitle: '申请历史',
no: '序号',
articleId: '文章 ID',
journal: '期刊名称',
articleTitle: '文章标题',
sn: 'SN 号',
originalFee: '原处理费 (USD)',
applyFee: '申请改价 (USD)',
remark: '改价说明',
status: '审批状态',
operator: '申请人',
applyTime: '申请时间',
action: '审批操作',
accept: '同意',
reject: '驳回',
cancel: '取消',
tip: '提示',
acceptConfirm: '确认同意该改价申请?',
rejectTitle: '驳回申请',
rejectRemark: '驳回备注',
rejectRemarkPlaceholder: '请填写驳回原因',
rejectRemarkRequired: '请填写驳回备注',
noPending: '暂无待审批申请',
noAccepted: '暂无已通过申请',
noRejected: '暂无已驳回申请',
tabPending: '待审批',
tabAccepted: '已通过',
tabRejected: '已驳回',
noHistory: '暂无申请历史',
viewHistory: '申请历史',
refresh: '刷新',
search: '搜索',
searchPlaceholder: '输入文章标题或 SN 号',
noSearchResult: '未找到匹配的待审批申请',
currentArticle: '当前文章',
selectArticleHint: '点击待审批列表中的文章,或输入文章 ID 查询申请历史',
articleIdPlaceholder: '文章 ID',
searchHistory: '查询历史',
articleIdRequired: '请输入文章 ID',
missingArticleId: '该记录缺少 article_id无法查询历史',
historyLoadFailed: '加载申请历史失败',
feeChange: '金额变更',
loadFailed: '加载失败,请稍后重试',
actionFailed: '操作失败,请稍后重试',
acceptSuccess: '已同意该申请',
rejectSuccess: '已驳回该申请',
noPermission: '无权限访问该页面',
statusApply: '提交申请',
statusAccept: '审批通过',
statusReject: '审批驳回',
statusPending: '待审批',
statusOther: '其他记录',
viewArticleDetail: '点击查看文章详情',
notifyTitle: '期刊收费审批',
notifyMessage: '您有 {count} 条待审批改价申请',
notifyHint: '点击前往审批页面处理',
},
PreAccept: {
successInfo: 'Congratulations! Your manuscript has entered into <b>Pre-accept</b> status. Now please check and complete the necessary information of your manuscript for final publication.',
step1: '文章处理费用',
@@ -1919,6 +2096,16 @@ const zh = {
briefNeedDiff: '需说明差异',
briefDelete: '删除',
briefYearMismatch: '年份不符'
},
reviewHistory: {
certificateTitle: '证书',
exportCertificateImage: '下载图片',
generateCertificatePdf: '生成 PDF',
exportCertificateImageSuccess: '证书图片已导出',
exportCertificateImageFail: '证书图片导出失败',
generateCertificatePdfSuccess: '证书 PDF 已生成',
generateCertificatePdfFail: '证书 PDF 生成失败',
noCertificate: '暂无证书'
}

View File

@@ -115,6 +115,64 @@
</el-table-column>
</el-table>
</div>
<div
v-if="priceApplyHistory.length > 0"
class="price-apply-history"
v-loading="priceApplyHistoryLoading"
>
<h5 class="price-apply-history__title">{{ $t('journalFeeApproval.historyTitle') }}</h5>
<div class="price-apply-history__list">
<div
v-for="(item, index) in priceApplyHistory"
:key="index"
class="price-apply-history__item"
>
<div class="price-apply-history__track">
<i
class="price-apply-history__dot"
:class="'is-' + applyTimelineType(item.statusText)"
></i>
<i
v-if="index < priceApplyHistory.length - 1"
class="price-apply-history__tail"
></i>
</div>
<div class="price-apply-history__row">
<el-tag size="mini" :type="applyTagType(item.statusText)" class="price-apply-history__tag">
{{ item.statusText }}
</el-tag>
<span v-if="item.applyTime" class="price-apply-history__time">
{{ formatApplyDateTime(item.applyTime) }}
</span>
<span class="price-apply-history__text">
{{ $t('pendingPayment.total') }}: {{ formatApplyFee(item.originalFee) }} USD
</span>
<span class="price-apply-history__sep">|</span>
<span class="price-apply-history__text">
{{ $t('pendingPayment.discountprice') }}: {{ formatApplyFee(item.applyFee) }} USD
</span>
<template v-if="item.remark">
<span class="price-apply-history__sep">|</span>
<span class="price-apply-history__text">
{{ $t('pendingPayment.youhuiremark') }}: {{ item.remark }}
</span>
</template>
<template v-if="item.rejectRemark">
<span class="price-apply-history__sep">|</span>
<span class="price-apply-history__text">
{{ $t('journalFeeApproval.rejectRemark') }}: {{ item.rejectRemark }}
</span>
</template>
<template v-if="item.operator && item.operator !== '-'">
<span class="price-apply-history__sep">|</span>
<span class="price-apply-history__text">
{{ $t('journalFeeApproval.operator') }}: {{ item.operator }}
</span>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -256,7 +314,7 @@
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="finalFeeVisible = false">Cancel</el-button>
<el-button type="primary" @click="saveFinalFee">Save</el-button>
<el-button type="primary" @click="saveFinalFee">{{ $t('pendingPayment.applyPrice') }}</el-button>
</span>
</el-dialog>
</div>
@@ -281,6 +339,8 @@ export default {
tableData: [],
talkMsgs: [],
finalFeeVisible: false,
priceApplyHistoryLoading: false,
priceApplyHistory: [],
FeeVisible: false,
communVisible: false,
msgform: {
@@ -581,6 +641,7 @@ export default {
created() {
this.opMedical = this.$commonJS.opMedicalList();
this.getPreacceptPayment();
this.fetchArticlePriceApplyHistory();
this.getHight();
window.addEventListener('resize', this.getHight);
// this.getData();
@@ -591,6 +652,7 @@ export default {
activated() {
this.opMedical = this.$commonJS.opMedicalList();
this.getPreacceptPayment();
this.fetchArticlePriceApplyHistory();
this.getHight();
window.addEventListener('resize', this.getHight);
// this.getData();
@@ -614,11 +676,10 @@ export default {
background: 'rgba(0, 0, 0, 0.7)'
});
this.$api
.post('api/Order/changePrice', {
.post('api/Order/applyPrice', {
article_id: this.$route.query.id,
editor_id: localStorage.getItem('U_id'),
fee: Number(this.finalFeeData.fee).toFixed(2),
fee_remark: this.finalFeeData.fee_remark
remark: this.finalFeeData.fee_remark
})
.then((res) => {
load.close();
@@ -626,6 +687,7 @@ export default {
if (res.code == 0) {
this.finalFeeVisible = false;
this.getPreacceptPayment();
this.fetchArticlePriceApplyHistory();
} else {
this.$message.error(res.msg);
}
@@ -646,6 +708,106 @@ export default {
fee_remark: this.article_pay_info.fee_remark
};
},
fetchArticlePriceApplyHistory() {
const articleId = this.$route.query.id;
if (!articleId) return;
this.priceApplyHistoryLoading = true;
this.$api
.post('api/Order/getArticlePriceApplyList', {
article_id: articleId
})
.then((res) => {
this.priceApplyHistoryLoading = false;
if (res.code == 0) {
this.priceApplyHistory = this.normalizePriceApplyHistory(res.data);
} else {
this.priceApplyHistory = [];
}
})
.catch(() => {
this.priceApplyHistoryLoading = false;
this.priceApplyHistory = [];
});
},
extractPriceApplyList(data) {
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
return data.list || data.applies || data.applys || data.items || data.logs || data.data || [];
},
pickApplyValue() {
for (let i = 0; i < arguments.length; i++) {
const value = arguments[i];
if (value !== undefined && value !== null) {
return value;
}
}
return '';
},
normalizePriceApplyHistory(data) {
const rawList = this.extractPriceApplyList(data);
return rawList
.map((row) => {
if (!row || typeof row !== 'object') return null;
const status = row.status !== undefined ? row.status : row.state;
return {
originalFee: this.pickApplyValue(row.old_fee, row.original_fee, row.origin_fee, row.original_price, row.total, ''),
applyFee: this.pickApplyValue(row.fee, row.apply_fee, row.new_fee, ''),
remark: this.pickApplyValue(row.remark, row.fee_remark, row.apply_remark, ''),
rejectRemark: this.pickApplyValue(row.reject_remark, row.reject_reason, row.audit_remark, ''),
operator: row.operator || row.account || row.editor_name || row.apply_user || row.username || row.realname || '-',
applyTime: row.ctime || row.apply_time || row.create_time || row.add_time || row.update_time || row.time || 0,
statusText: this.getApplyStatusText(status, row)
};
})
.filter(Boolean)
.sort((a, b) => Number(b.applyTime) - Number(a.applyTime));
},
getApplyStatusText(status, row) {
const raw = status !== undefined && status !== null && status !== '' ? status : row.action || row.type || '';
const text = String(raw).toLowerCase();
if (text.includes('reject') || text === '2' || text === '拒绝' || text === '驳回') {
return this.$t('journalFeeApproval.statusReject');
}
if (text.includes('accept') || text.includes('agree') || text === '1' || text === '同意' || text === '通过') {
return this.$t('journalFeeApproval.statusAccept');
}
if (text.includes('pending') || text.includes('wait') || text === '0' || text === '待审' || text === '待审批') {
return this.$t('journalFeeApproval.statusPending');
}
if (text.includes('apply') || text === '申请' || text === '提交') {
return this.$t('journalFeeApproval.statusApply');
}
return raw ? String(raw) : this.$t('journalFeeApproval.statusOther');
},
applyTimelineType(statusText) {
if (statusText === this.$t('journalFeeApproval.statusAccept')) return 'success';
if (statusText === this.$t('journalFeeApproval.statusReject')) return 'danger';
if (statusText === this.$t('journalFeeApproval.statusPending')) return 'warning';
return 'primary';
},
applyTagType(statusText) {
if (statusText === this.$t('journalFeeApproval.statusAccept')) return 'success';
if (statusText === this.$t('journalFeeApproval.statusReject')) return 'danger';
if (statusText === this.$t('journalFeeApproval.statusPending')) return 'warning';
return '';
},
formatApplyFee(value) {
if (value === '' || value === null || value === undefined) return '-';
const num = Number(value);
if (isNaN(num)) return value;
return num.toFixed(2);
},
formatApplyDateTime(timestamp) {
if (!timestamp) return '-';
if (typeof timestamp === 'string' && timestamp.indexOf('-') > 0) {
return timestamp;
}
const ts = String(timestamp).length === 10 ? Number(timestamp) * 1000 : Number(timestamp);
const date = new Date(ts);
if (isNaN(date.getTime())) return '-';
const pad = (n) => (n < 10 ? '0' + n : '' + n);
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
},
formatAmount(amount) {
return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
@@ -2112,6 +2274,99 @@ export default {
.payment_info .price {
font-weight: 700;
}
.price-apply-history {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
}
.price-apply-history__title {
margin: 0 0 12px;
font-size: 15px;
color: #303133;
font-weight: 600;
}
.price-apply-history__list {
padding: 0;
}
.price-apply-history__item {
display: flex;
align-items: flex-start;
}
.price-apply-history__item + .price-apply-history__item {
margin-top: 10px;
}
.price-apply-history__track {
position: relative;
width: 14px;
flex-shrink: 0;
margin-right: 12px;
align-self: stretch;
}
.price-apply-history__dot {
display: block;
width: 10px;
height: 10px;
border-radius: 50%;
margin: 4px auto 0;
background: #409eff;
}
.price-apply-history__dot.is-success {
background: #67c23a;
}
.price-apply-history__dot.is-danger {
background: #f56c6c;
}
.price-apply-history__dot.is-warning {
background: #e6a23c;
}
.price-apply-history__dot.is-primary {
background: #409eff;
}
.price-apply-history__dot.is-info {
background: #909399;
}
.price-apply-history__tail {
position: absolute;
left: 50%;
top: 16px;
bottom: -10px;
width: 2px;
margin-left: -1px;
background: #e4e7ed;
}
.price-apply-history__row {
flex: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
padding-top: 1px;
line-height: 1.6;
}
.price-apply-history__tag {
flex-shrink: 0;
}
.price-apply-history__time {
flex-shrink: 0;
color: #909399;
font-size: 12px;
white-space: nowrap;
}
.price-apply-history__text {
color: #606266;
font-size: 13px;
}
.price-apply-history__sep {
color: #dcdfe6;
font-size: 12px;
line-height: 1;
}
.price-apply-history__empty {
margin: 0;
color: #909399;
font-size: 13px;
}
.payment_info .remark {
/* color: #888; */
}

View File

@@ -770,6 +770,12 @@ export default {
this.$refs.editPublicRefRdit.$forceUpdate();
console.log('editPublicRefRdit');
},
grabReferencesFromWord() {
if (this.$refs.editPublicRefRdit && this.$refs.editPublicRefRdit.handleGrabReferencesFromWord) {
return this.$refs.editPublicRefRdit.handleGrabReferencesFromWord();
}
return Promise.resolve();
},
// 跳转邮件
linkEmail() {
this.$router.push({

View File

@@ -4,7 +4,7 @@
<div class="ms-title">User Register</div>
<el-form class="ms-content" :rules="registerRules" ref="registerForm" :model="registerForm" label-width="140px">
<!-- 用户名 -->
<el-form-item prop="username" class="form-item" label="English Name :">
<el-form-item prop="username" class="form-item" label="Account :">
<el-input size="small" v-model="registerForm.username" auto-complete="off" placeholder="">
<i slot="prefix" class="el-icon-user"></i>
@@ -18,7 +18,7 @@
</el-input>
</el-form-item>
<!-- 姓名 -->
<el-form-item prop="name" class="form-item" label="Real name :">
<el-form-item prop="name" class="form-item" label="English name :">
<el-input size="small" v-model="registerForm.name" auto-complete="off" placeholder="">
<i slot="prefix" class="el-icon-edit-outline"></i>
</el-input>

View File

@@ -1,13 +1,26 @@
<template>
<div style="width: 100%; height: 100%">
<div class="tab_post">
<el-button
v-if="zyModeEnabled"
size="mini"
type="primary"
plain
icon="el-icon-document"
:loading="wordRefsLoading"
:disabled="!gridData || wordRefsLoading"
@click="handleShowWordReferences"
class="tab-post-word-refs-btn"
>
{{ $t('commonTable.wordRefsPreviewShort') }}
</el-button>
<el-button
size="mini"
type="success"
plain
icon="el-icon-tickets"
@click="showdetaileditor(detailMes)"
style="padding: 6px; margin-left: 0px; position: absolute; bottom: 20px; left: 0px"
class="tab-post-detail-ms-btn"
>
Detailed for MS</el-button
>
@@ -1153,6 +1166,25 @@
<el-button type="primary" @click="preArtVisible = false">OK</el-button>
</span> -->
</el-dialog>
<el-dialog
:title="wordRefsDialogTitle"
:visible.sync="wordRefsDialogVisible"
width="860px"
top="6vh"
custom-class="word-refs-preview-dialog"
:close-on-click-modal="false"
append-to-body
>
<div v-if="wordRefsCount > 0" class="word-refs-preview-meta">
{{ $t('commonTable.wordRefsPreviewTotal', { count: wordRefsCount }) }}
</div>
<div v-loading="wordRefsLoading" class="word-refs-preview-body" v-html="wordRefsHtml"></div>
<span slot="footer" class="dialog-footer">
<el-button @click="wordRefsDialogVisible = false">{{ $t('commonTable.wordRefsPreviewClose') }}</el-button>
<el-button type="primary" plain @click="copyWordRefsPlainText">{{ $t('commonTable.wordRefsPreviewCopy') }}</el-button>
</span>
</el-dialog>
</div>
</template>
@@ -1183,9 +1215,15 @@ import {
getAuthorAffiliationPreview
} from '@/utils/productionSubmissionImport';
import { isZyModeEnabled, isZySkipCheckEnabled } from '@/utils/zyMode';
import { fetchWordReferencesSection } from '@/utils/manuscriptWordReferences';
export default {
data() {
return {
wordRefsDialogVisible: false,
wordRefsLoading: false,
wordRefsHtml: '',
wordRefsPlainText: '',
wordRefsCount: 0,
importingSubmission: false,
importAuthorPreviewVisible: false,
importAuthorOrderText: '',
@@ -1759,6 +1797,66 @@ export default {
}
});
},
async handleShowWordReferences() {
if (this.wordRefsLoading) return;
const manuscriptPath = String(this.gridData || '').trim();
if (!manuscriptPath) {
this.$message.warning(this.$t('commonTable.wordRefsPreviewNoManuscript'));
return;
}
this.wordRefsLoading = true;
try {
const extracted = await fetchWordReferencesSection(this.$api, {
manuscriptPath: manuscriptPath,
gridData: manuscriptPath,
mediaUrl: this.mediaUrl,
baseUrl: this.baseUrl,
useGridDataOnly: true
});
if (!extracted.found) {
this.$message.warning(this.$t('commonTable.wordRefsPreviewEmpty'));
return;
}
this.wordRefsHtml = extracted.html;
this.wordRefsPlainText = extracted.plainText || '';
this.wordRefsCount = extracted.count || 0;
this.wordRefsDialogVisible = true;
} catch (err) {
console.error(err);
if (err && err.message === 'NO_MANUSCRIPT_FILE') {
this.$message.warning(this.$t('commonTable.wordRefsPreviewNoManuscript'));
} else {
this.$message.error(this.$t('commonTable.wordRefsPreviewFail'));
}
} finally {
this.wordRefsLoading = false;
}
},
async copyWordRefsPlainText() {
const text = String(this.wordRefsPlainText || '').trim();
if (!text) {
this.$message.warning(this.$t('commonTable.wordRefsPreviewEmpty'));
return;
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
this.$message.success(this.$t('commonTable.wordRefsPreviewCopySuccess'));
} catch (err) {
console.error(err);
this.$message.error(this.$t('commonTable.wordRefsPreviewCopyFail'));
}
},
allLoad() {
let excelList = [
'https://submission.tmrjournals.com/public/manuscirpt/20220831/c7d75d49bf25cf56906d56d07e00a31e.docx',
@@ -1914,7 +2012,12 @@ export default {
type: 'm'
})
.then((res) => {
this.gridData = res.data.files[res.data.files.length - 1].file_url;
const files = res.data && Array.isArray(res.data.files) ? res.data.files : [];
if (files.length > 0) {
this.gridData = files[files.length - 1].file_url || '';
} else {
this.gridData = '';
}
})
.catch((err) => {
console.log(err);
@@ -3599,6 +3702,13 @@ export default {
computed: {
zyModeEnabled() {
return isZyModeEnabled(this.$route);
},
wordRefsDialogTitle() {
const base = this.$t('commonTable.wordRefsPreviewTitle');
if (this.wordRefsCount > 0) {
return base + ' (' + this.wordRefsCount + ')';
}
return base;
}
},
components: {
@@ -3699,6 +3809,73 @@ export default {
z-index: 50;
}
.tab-post-word-refs-btn,
.tab-post-detail-ms-btn {
padding: 6px;
margin-left: 0;
position: absolute;
left: 0;
width: 220px;
}
.tab-post-word-refs-btn {
bottom: 58px;
}
.tab-post-detail-ms-btn {
bottom: 20px;
}
::v-deep .word-refs-preview-dialog .el-dialog__body {
padding-top: 10px;
}
.word-refs-preview-meta {
margin-bottom: 10px;
padding: 8px 12px;
background: #f4f8ff;
border: 1px solid #d9e8ff;
border-radius: 6px;
font-size: 13px;
color: #409eff;
font-weight: 600;
}
.word-refs-preview-body {
max-height: 68vh;
overflow: auto;
padding: 12px 14px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
line-height: 1.65;
font-size: 14px;
color: #303133;
}
.word-refs-preview-body .word-ref-block {
margin-bottom: 8px;
}
.word-refs-preview-body .word-ref-h1 {
font-weight: 700;
text-align: center;
margin: 8px 0 12px;
}
.word-refs-preview-body .word-ref-h2,
.word-refs-preview-body .word-ref-h3 {
font-weight: 700;
}
.word-refs-preview-body .word-ref-p p {
margin: 0 0 8px;
}
.word-refs-preview-body blue {
color: #006699;
}
.tab_post > div {
margin-bottom: 10px;
cursor: pointer;

View File

@@ -114,6 +114,42 @@
</div>
</div>
<div v-if="zyModeEnabled" v-loading="zyStatsLoading" class="zy-monthly-stats-bar">
<div class="zy-monthly-stats-head">
<span class="zy-monthly-stats-title">{{ $t('autoPromotionLogs.monthlyStatsTitle') }}</span>
<el-date-picker
v-model="zyStatsMonth"
type="month"
value-format="yyyy-MM"
size="small"
:placeholder="$t('autoPromotionLogs.monthlyStatsMonthPlaceholder')"
@change="fetchZyMonthlyStats"
/>
</div>
<div class="num-grid zy-monthly-stats-grid">
<div class="num-item">
<div class="label">{{ $t('autoPromotionLogs.monthlyStatsTaskCount') }}</div>
<div class="value neutral">{{ zyMonthlyStats.taskCount }}</div>
</div>
<div class="num-item">
<div class="label">{{ $t('autoPromotionLogs.totalCount') }}</div>
<div class="value primary">{{ zyMonthlyStats.total }}</div>
</div>
<div class="num-item">
<div class="label">{{ $t('autoPromotionLogs.sentCount') }}</div>
<div class="value success">{{ zyMonthlyStats.sent }}</div>
</div>
<div class="num-item">
<div class="label">{{ $t('autoPromotionLogs.failCount') }}</div>
<div class="value danger">{{ zyMonthlyStats.fail }}</div>
</div>
<div class="num-item">
<div class="label">{{ $t('autoPromotionLogs.bounceCount') }}</div>
<div :class="['value', zyMonthlyStats.bounce > 0 ? 'warning' : 'neutral']">{{ zyMonthlyStats.bounce }}</div>
</div>
</div>
</div>
<el-table :data="list" border stripe size="small" class="custom-table exquisite-log-table">
<el-table-column
type="index"
@@ -313,6 +349,7 @@ import CkeditorMail from '@/components/page/components/email/CkeditorMail.vue';
import TemplateSelectorDialog from '@/components/page/components/email/TemplateSelectorDialog.vue';
import PromotionFactoryTaskDialog from '@/components/page/components/autoPromotion/PromotionFactoryTaskDialog.vue';
import PromotionDetailDrawer from '@/components/page/components/autoPromotion/PromotionDetailDrawer.vue';
import { isZyModeEnabled } from '@/utils/zyMode';
// 这里假设你已经定义了 API 地址
const API = {
getAllJournal: 'api/email_client/getPromotionJournalList',
@@ -389,10 +426,25 @@ export default {
run_at: '',
subject: '',
content: ''
},
zyStatsMonth: (function () {
const now = new Date();
return now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
})(),
zyStatsLoading: false,
zyMonthlyStats: {
taskCount: 0,
total: 0,
sent: 0,
fail: 0,
bounce: 0
}
};
},
computed: {
zyModeEnabled() {
return isZyModeEnabled(this.$route);
},
/** 当前选中工厂任务是否运行中(与下拉 options 中 running 一致) */
headerFactoryTaskRunning() {
const id = String(this.headerPromotionFactoryId || '').trim();
@@ -617,6 +669,7 @@ export default {
if (this.selectedJournalId) {
await this.fetchFactoryTasksForHeader();
await this.fetchList();
await this.fetchZyMonthlyStats();
}
} finally {
this.loading = false;
@@ -804,6 +857,7 @@ export default {
this.query.pageIndex = 1;
this.replacePromotionFactoryIdInUrl(next);
this.fetchList();
this.fetchZyMonthlyStats();
},
async handleCreateEmailClientTask() {
const pid = String(this.headerPromotionFactoryId || this.routePromotionFactoryId || '').trim();
@@ -1100,35 +1154,7 @@ export default {
const data = (res && res.data) || {};
const rawList = Array.isArray(data.list) ? data.list : [];
this.list = rawList.map((item, idx) => {
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;
return {
id: item.id || item.task_id || `task_${idx + 1}`,
promotion_factory_id: promotionFactoryId != null ? String(promotionFactoryId) : '',
task_id: String(item.task_id != null ? item.task_id : item.id || ''),
task_name: item.task_name || item.name || '',
scene: item.scene || '',
name: item.name || item.expert_name || '',
email: item.email || item.to_email || '',
template_id: String(item.template_id || item.default_template_id || ''),
style_id: String(item.style_id || item.default_style_id || ''),
style_name: item.style_name || item.default_style_name || '',
state: state,
paused: state === '2',
run_at: runAt,
total_count: Number(item.total_count || 0),
sent_count: Number(item.sent_count || 0),
fail_count: Number(item.fail_count || 0),
bounce_count: Number(item.bounce_count || 0),
min_interval: Number(item.min_interval || 0),
max_interval: Number(item.max_interval || 0),
send_start_hour: item.send_start_hour != null ? item.send_start_hour : '-',
send_end_hour: item.send_end_hour != null ? item.send_end_hour : '-'
};
});
this.list = rawList.map((item, idx) => this.mapTaskListItem(item, idx));
this.total = Number(data.total || data.count || this.list.length || 0);
// 兼容后端返回 page/per_page
@@ -1240,6 +1266,113 @@ export default {
handleSearch() {
this.query.pageIndex = 1;
this.fetchList();
this.fetchZyMonthlyStats();
},
extractTaskRunYearMonth(runAt) {
const raw = String(runAt || '').trim();
if (!raw) return '';
const match = raw.match(/^(\d{4})-(\d{2})/);
if (match) {
return match[1] + '-' + match[2];
}
const date = new Date(raw);
if (isNaN(date.getTime())) return '';
return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
},
mapTaskListItem(item, idx) {
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;
return {
id: item.id || item.task_id || `task_${idx + 1}`,
promotion_factory_id: promotionFactoryId != null ? String(promotionFactoryId) : '',
task_id: String(item.task_id != null ? item.task_id : item.id || ''),
task_name: item.task_name || item.name || '',
scene: item.scene || '',
name: item.name || item.expert_name || '',
email: item.email || item.to_email || '',
template_id: String(item.template_id || item.default_template_id || ''),
style_id: String(item.style_id || item.default_style_id || ''),
style_name: item.style_name || item.default_style_name || '',
state: state,
paused: state === '2',
run_at: runAt,
total_count: Number(item.total_count || 0),
sent_count: Number(item.sent_count || 0),
fail_count: Number(item.fail_count || 0),
bounce_count: Number(item.bounce_count || 0),
min_interval: Number(item.min_interval || 0),
max_interval: Number(item.max_interval || 0),
send_start_hour: item.send_start_hour != null ? item.send_start_hour : '-',
send_end_hour: item.send_end_hour != null ? item.send_end_hour : '-'
};
},
async fetchAllTaskRowsForStats() {
const rows = [];
let page = 1;
const perPage = 200;
let total = 0;
do {
const params = {
journal_id: String(this.selectedJournalId || ''),
factory_id: String(this.routePromotionFactoryId || ''),
page: page,
per_page: perPage
};
const res = await this.$api.post(API.list, params);
const data = (res && res.data) || {};
const rawList = Array.isArray(data.list) ? data.list : [];
rows.push.apply(rows, rawList);
total = Number(data.total || data.count || rows.length || 0);
if (!rawList.length || rows.length >= total) {
break;
}
page += 1;
} while (page <= 100);
return rows;
},
async fetchZyMonthlyStats() {
if (!this.zyModeEnabled || !this.selectedJournalId) {
return;
}
const month = String(this.zyStatsMonth || '').trim();
if (!month) {
return;
}
this.zyStatsLoading = true;
try {
const rawRows = await this.fetchAllTaskRowsForStats();
const stats = {
taskCount: 0,
total: 0,
sent: 0,
fail: 0,
bounce: 0
};
rawRows.forEach(function (item, idx) {
const mapped = this.mapTaskListItem(item, idx);
if (this.extractTaskRunYearMonth(mapped.run_at) !== month) {
return;
}
stats.taskCount += 1;
stats.total += mapped.total_count;
stats.sent += mapped.sent_count;
stats.fail += mapped.fail_count;
stats.bounce += mapped.bounce_count;
}, this);
this.zyMonthlyStats = stats;
} catch (e) {
this.zyMonthlyStats = {
taskCount: 0,
total: 0,
sent: 0,
fail: 0,
bounce: 0
};
} finally {
this.zyStatsLoading = false;
}
},
handleStateChange() {
this.query.pageIndex = 1;
@@ -1723,6 +1856,27 @@ export default {
.delete-btn:hover {
color: #be123c !important;
}
.zy-monthly-stats-bar {
margin: 12px 0 14px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.zy-monthly-stats-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.zy-monthly-stats-title {
font-size: 14px;
font-weight: 600;
color: #334155;
}
.zy-monthly-stats-grid {
margin-bottom: 0;
}
.delivery-dashboard {
padding: 0px 0;
}

View File

@@ -140,8 +140,12 @@
v-if="zyModeEnabled"
@click="handleOpenCitationRelevance"
class="zy-toolbar-btn"
:class="{ 'zy-toolbar-btn--ready': uploadedReferencesCount }"
>
<el-tooltip :content="$t('commonTable.citeRelevanceDetect')" placement="bottom">
<el-tooltip
:content="uploadedReferencesCount ? $t('commonTable.citeRelevanceWithUploadedRefs', { n: uploadedReferencesCount }) : $t('commonTable.citeRelevanceDetect')"
placement="bottom"
>
<span class="zy-toolbar-btn-inner">
<i
class="el-icon-connection"
@@ -152,6 +156,25 @@
</span>
</el-tooltip>
</li>
<li
v-if="zyModeEnabled"
@click="handleExportRelevanceWord"
class="zy-toolbar-btn"
>
<el-tooltip
:content="$t('commonTable.exportRelevanceWordTip')"
placement="bottom"
>
<span class="zy-toolbar-btn-inner">
<i
class="el-icon-chat-line-square"
v-if="!exportingRelevanceWord"
></i>
<i class="el-icon-loading" v-else></i>
<span class="zy-toolbar-label">{{ $t('commonTable.exportRelevanceWordShort') }}</span>
</span>
</el-tooltip>
</li>
</ul>
</div>
<li
@@ -1179,7 +1202,11 @@ import { TableUtils } from '@/common/js/TableUtils';
import { debounce, throttle } from '@/common/js/debounce';
import { tableStyle, commonWordStyle } from '@/utils/tinymceStyles';
import LatexDataPanel from './LatexDataPanel.vue';
import { downloadManuscriptWord, fetchManuscriptReferenceList, summarizeManuscriptExportItems } from '@/utils/exportManuscriptWord';
import { downloadManuscriptWord, downloadManuscriptWordWithRelevanceComments, fetchManuscriptReferenceList, summarizeManuscriptExportItems } from '@/utils/exportManuscriptWord';
import {
validateMediaReferencesInWordList,
wrapReferenceHighlightsInBlueHtml
} from '@/utils/manuscriptMediaReferences';
import {
buildReferencesCopyText,
buildReferencesHtmlLabels,
@@ -1187,7 +1214,8 @@ import {
countParsedReferences,
downloadReferencesEditableHtml,
parseReferencesBulkContent,
parseReferencesEditableHtml
parseReferencesEditableHtml,
referencesFromUploadedHtmlItems
} from '@/utils/manuscriptReferenceHtml';
import {
buildCitationReviewQueue,
@@ -1320,6 +1348,7 @@ export default {
scrollPosition: 0,
wordList: [],
exportingManuscriptWord: false,
exportingRelevanceWord: false,
referencesCopyLoading: false,
referencesHtmlLoading: false,
referencesUploadLoading: false,
@@ -1612,6 +1641,12 @@ export default {
this.manuscriptReferences = references || [];
return this.manuscriptReferences;
},
async resolveReferencesForCitationReview() {
if (this.uploadedReferenceHtmlItems && this.uploadedReferenceHtmlItems.length) {
return referencesFromUploadedHtmlItems(this.uploadedReferenceHtmlItems);
}
return this.loadManuscriptReferences(false);
},
async handleCopyReferences() {
if (this.referencesCopyLoading) {
return;
@@ -1810,6 +1845,50 @@ export default {
this.exportingManuscriptWord = false;
}
},
async handleExportRelevanceWord() {
if (this.exportingRelevanceWord) {
return;
}
if (!this.wordList || !this.wordList.length) {
this.$message.warning(this.$t('commonTable.citeRelevanceNoContent'));
return;
}
if (!this.$api) {
this.$message.error(this.$t('commonTable.citeRelevanceLoadFail'));
return;
}
if (!this.pArticleId) {
this.$message.warning(this.$t('commonTable.refAnnotationReportNoArticle'));
return;
}
this.exportingRelevanceWord = true;
try {
const hasUploaded =
this.uploadedReferenceHtmlItems && this.uploadedReferenceHtmlItems.length;
await downloadManuscriptWordWithRelevanceComments(this.wordList, this.mediaUrl, 'manuscript-relevance', {
fetchReferences: !hasUploaded,
referenceHtmlItems: hasUploaded ? this.uploadedReferenceHtmlItems : null,
apiClient: this.$api,
articleId: this.articleId,
pArticleId: this.pArticleId,
referenceRelevanceLabels: buildReferenceAnnotationReportLabels(this.$t.bind(this)),
translate: this.$t.bind(this)
});
this.$message.success(this.$t('commonTable.exportRelevanceWordSuccess'));
} catch (err) {
console.error('[Word export] 相关性批注导出失败', err);
if (err && err.message === 'NO_CONTENT') {
this.$message.warning(this.$t('commonTable.exportManuscriptEmpty') || 'No content to export.');
} else if (err && err.message === 'NO_RELEVANCE_DATA') {
this.$message.warning(this.$t('commonTable.refAnnotationReportNoData'));
} else {
this.$message.error(this.$t('commonTable.exportRelevanceWordFail'));
}
} finally {
this.exportingRelevanceWord = false;
}
},
async handleOpenCitationRelevance() {
if (this.citationRelevanceLoading) {
return;
@@ -1825,9 +1904,10 @@ export default {
this.citationRelevanceLoading = true;
try {
const references = await this.loadManuscriptReferences(false);
const references = await this.resolveReferencesForCitationReview();
const usingUploaded = !!(this.uploadedReferenceHtmlItems && this.uploadedReferenceHtmlItems.length);
const items = buildCitationReviewQueue(this.wordList, this.manuscriptReferences);
const items = buildCitationReviewQueue(this.wordList, references);
if (!items.length) {
this.$message.warning(this.$t('commonTable.citeRelevanceNoCitations'));
return;
@@ -1836,7 +1916,15 @@ export default {
const labels = buildCitationReviewHtmlLabels(this.$t.bind(this));
const fileName = 'citation-relevance' + (this.articleId ? '-' + this.articleId : '') + '.html';
downloadCitationReviewHtml(items, labels, fileName);
this.$message.success(this.$t('commonTable.citeRelevanceDownloadHtmlSuccess'));
if (usingUploaded) {
this.$message.success(
this.$t('commonTable.citeRelevanceDownloadHtmlSuccessWithUploaded', {
n: this.uploadedReferencesCount
})
);
} else {
this.$message.success(this.$t('commonTable.citeRelevanceDownloadHtmlSuccess'));
}
} catch (err) {
console.error(err);
this.$message.error(this.$t('commonTable.citeRelevanceLoadFail'));
@@ -4233,6 +4321,7 @@ export default {
getContent1(type, content) {
content = this.$commonJS.transformHtmlString(content);
content = wrapReferenceHighlightsInBlueHtml(content);
var div = document.createElement('div');
div.innerHTML = content; // 将 HTML 字符串加载到 div 中
@@ -4253,6 +4342,22 @@ export default {
// 获取最终修改后的 HTML
content = div.innerHTML;
const mediaRefIssues = validateMediaReferencesInWordList(this.wordList);
if (mediaRefIssues.length) {
const preview = mediaRefIssues
.slice(0, 3)
.map(function (issue) {
const label = issue.kind === 'figure' ? 'Figure' : 'Table';
return label + ' ' + issue.number;
})
.join('、');
const suffix =
mediaRefIssues.length > 3
? this.$t('commonTable.mediaRefCheckMore', { n: mediaRefIssues.length })
: '';
this.$message.warning(this.$t('commonTable.mediaRefCheckWarn', { list: preview + suffix }));
}
this.$api
.post('api/Proofread/modify', {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,897 @@
<template>
<div class="journal-fee-approval">
<div class="crumbs">
<el-breadcrumb separator="/">
<el-breadcrumb-item>
<i class="el-icon-money"></i> {{ $t('journalFeeApproval.title') }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="container">
<div class="handle-box">
<el-tabs
:value="String(activeState)"
type="card"
class="status-tabs"
@tab-click="handleTabClick"
>
<el-tab-pane
v-for="tab in statusTabs"
:key="tab.state"
:name="String(tab.state)"
>
<span slot="label">{{ tab.label }} ({{ tab.count }})</span>
</el-tab-pane>
</el-tabs>
<el-button type="primary" icon="el-icon-refresh" :loading="loading" class="refresh-btn" @click="refreshAll">
{{ $t('journalFeeApproval.refresh') }}
</el-button>
</div>
<el-table
v-loading="loading"
:data="displayList"
border
stripe
class="table"
header-cell-class-name="table-header"
:empty-text="emptyTipText"
>
<el-table-column type="index" :label="$t('journalFeeApproval.no')" width="55" align="center" />
<el-table-column prop="acceptSn" :label="$t('journalFeeApproval.sn')" width="150" show-overflow-tooltip />
<el-table-column :label="$t('journalFeeApproval.colArticleInfo')" min-width="380">
<template slot-scope="scope">
<p class="tab_tie_col">
<span>{{ $t('journalFeeApproval.journal') }}:</span>
<b>{{ scope.row.journalName }}</b>
</p>
<p class="tab_tie_col article-title">
<span>{{ $t('journalFeeApproval.articleTitle') }}:</span>
<span
v-if="scope.row.articleId"
class="article-title-link"
:title="$t('journalFeeApproval.viewArticleDetail')"
@click="goArticleDetail(scope.row)"
>{{ scope.row.articleTitle }}</span>
<span v-else>{{ scope.row.articleTitle }}</span>
</p>
</template>
</el-table-column>
<el-table-column :label="$t('journalFeeApproval.colPriceChange')" min-width="360">
<template slot-scope="scope">
<p class="tab_tie_col">
<span>{{ $t('journalFeeApproval.originalFee') }}:</span>
<b>{{ formatFee(scope.row.originalFee) }} USD</b>
</p>
<p class="tab_tie_col">
<span>{{ $t('journalFeeApproval.applyFee') }}:</span>
<b class="apply-fee">{{ formatFee(scope.row.applyFee) }} USD</b>
</p>
<p class="tab_tie_col">
<span>{{ $t('journalFeeApproval.remark') }}:</span>{{ scope.row.remark || '-' }}
</p>
</template>
</el-table-column>
<el-table-column :label="$t('journalFeeApproval.applyTime')" width="165" align="center">
<template slot-scope="scope">{{ formatDateTime(scope.row.applyTime) }}</template>
</el-table-column>
<el-table-column v-if="activeState !== 0" :label="$t('journalFeeApproval.status')" width="110" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="statusTagType(scope.row.status)">{{ getStatusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('journalFeeApproval.colApproval')" width="180" align="center" fixed="right">
<template slot-scope="scope">
<div class="approval-box">
<template v-if="activeState === 0">
<el-button type="success" size="mini" plain icon="el-icon-check" @click="handleAccept(scope.row)">
{{ $t('journalFeeApproval.accept') }}
</el-button>
<el-button type="danger" size="mini" plain icon="el-icon-close" @click="openRejectDialog(scope.row)">
{{ $t('journalFeeApproval.reject') }}
</el-button>
</template>
<el-button type="primary" size="mini" plain icon="el-icon-time" @click="openHistoryDialog(scope.row)">
{{ $t('journalFeeApproval.viewHistory') }}
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="total > 0" class="pagination-box">
<el-pagination
background
layout="total, prev, pager, next, sizes"
:current-page.sync="pageIndex"
:page-size.sync="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="fetchList"
@size-change="handlePageSizeChange"
/>
</div>
</div>
<el-dialog
:title="$t('journalFeeApproval.rejectTitle')"
:visible.sync="rejectVisible"
width="560px"
:close-on-click-modal="false"
@closed="resetRejectForm"
>
<el-form ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="140px">
<el-form-item :label="$t('journalFeeApproval.sn')">
<span>{{ rejectForm.acceptSn }}</span>
</el-form-item>
<el-form-item :label="$t('journalFeeApproval.articleTitle')">
<span>{{ rejectForm.articleTitle }}</span>
</el-form-item>
<el-form-item :label="$t('journalFeeApproval.rejectRemark')" prop="remark">
<el-input v-model="rejectForm.remark" type="textarea" :rows="4" :placeholder="$t('journalFeeApproval.rejectRemarkPlaceholder')" />
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="rejectVisible = false">{{ $t('journalFeeApproval.cancel') }}</el-button>
<el-button type="danger" :loading="rejectSubmitting" @click="submitReject">{{ $t('journalFeeApproval.reject') }}</el-button>
</span>
</el-dialog>
<el-dialog
:title="$t('journalFeeApproval.historyTitle')"
:visible.sync="historyVisible"
width="640px"
:close-on-click-modal="false"
@closed="resetHistoryDialog"
>
<div v-loading="historyLoading" class="history-dialog">
<p class="history-dialog__meta">
<span>{{ $t('journalFeeApproval.sn') }}: <b>{{ historyArticle.acceptSn }}</b></span>
</p>
<div class="history-dialog__article">
<span class="history-dialog__article-label">{{ $t('journalFeeApproval.articleTitle') }}:</span>
<p class="history-dialog__title">{{ historyArticle.articleTitle || '-' }}</p>
</div>
<el-timeline v-if="historyList.length > 0">
<el-timeline-item
v-for="(item, index) in historyList"
:key="index"
placement="top"
:type="statusTagType(item.status)"
>
<div class="history-dialog__item">
<p class="history-dialog__meta-line">
<span class="history-dialog__time">{{ formatDateTime(item.applyTime) }}</span>
<el-tag size="mini" :type="statusTagType(item.status)">{{ getStatusLabel(item.status) }}</el-tag>
</p>
<p class="tab_tie_col">
<span>{{ $t('journalFeeApproval.feeChange') }}:</span>
{{ formatFee(item.originalFee) }} {{ formatFee(item.applyFee) }} USD
</p>
<p class="tab_tie_col" v-if="item.remark">
<span>{{ $t('journalFeeApproval.remark') }}:</span>{{ item.remark }}
</p>
<p class="tab_tie_col" v-if="item.rejectRemark">
<span>{{ $t('journalFeeApproval.rejectRemark') }}:</span>{{ item.rejectRemark }}
</p>
<p class="tab_tie_col" v-if="item.operator && item.operator !== '-'">
<span>{{ $t('journalFeeApproval.operator') }}:</span>{{ item.operator }}
</p>
</div>
</el-timeline-item>
</el-timeline>
<p v-else-if="!historyLoading" class="history-dialog__empty">{{ $t('journalFeeApproval.noHistory') }}</p>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="historyVisible = false">{{ $t('journalFeeApproval.cancel') }}</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import bus from '../common/bus';
const PENDING_STATUS = new Set([0, '0', 'pending', 'wait', 'waiting']);
function pickValue() {
for (let i = 0; i < arguments.length; i++) {
const value = arguments[i];
if (value !== undefined && value !== null) {
return value;
}
}
return '';
}
function normalizeHistoryItem(row) {
if (!row || typeof row !== 'object') return null;
const status = row.status !== undefined ? row.status : row.state;
const userInfo = row.user_info || row.editor_info || row.apply_user_info || {};
return {
originalFee: pickValue(row.old_fee, row.original_fee, row.origin_fee, ''),
applyFee: pickValue(row.fee, row.apply_fee, ''),
remark: pickValue(row.remark, row.fee_remark, ''),
rejectRemark: pickValue(row.reject_remark, row.reject_reason, row.audit_remark, ''),
operator: pickValue(
row.operator,
row.editor_name,
row.apply_user,
row.username,
row.realname,
row.account,
userInfo.realname,
userInfo.account,
userInfo.username,
userInfo.name,
'-'
),
applyTime: pickValue(row.ctime, row.apply_time, row.create_time, row.add_time, row.update_time, row.time, 0),
status: status
};
}
function normalizeApplyRow(row) {
if (!row || typeof row !== 'object') return null;
const articleInfo = row.article_info || {};
const journalInfo = row.journal_info || {};
const userInfo = row.user_info || row.editor_info || row.apply_user_info || {};
const status = row.status !== undefined ? row.status : row.state;
const rawHistory = Array.isArray(articleInfo.history) ? articleInfo.history : Array.isArray(row.history) ? row.history : [];
return {
id: pickValue(row.id, row.apply_id, ''),
articleId: pickValue(row.article_id, articleInfo.article_id, ''),
journalId: pickValue(journalInfo.journal_id, articleInfo.journal_id, row.journal_id, ''),
journalName: pickValue(journalInfo.title, journalInfo.journal_name, journalInfo.name, row.journal_name, row.journal_title, '-'),
articleTitle: pickValue(articleInfo.article_title, articleInfo.title, articleInfo.name, row.article_title, row.title, '-'),
acceptSn: pickValue(articleInfo.accept_sn, articleInfo.sn, row.accept_sn, row.sn, '-'),
originalFee: pickValue(row.old_fee, row.original_fee, row.origin_fee, articleInfo.old_fee, ''),
applyFee: pickValue(row.fee, row.apply_fee, articleInfo.fee, ''),
remark: pickValue(row.remark, row.fee_remark, articleInfo.fee_remark, articleInfo.remark, ''),
operator: pickValue(
row.operator,
row.editor_name,
row.apply_user,
row.username,
row.realname,
row.account,
userInfo.realname,
userInfo.account,
userInfo.username,
userInfo.name,
'-'
),
applyTime: pickValue(row.ctime, row.apply_time, row.create_time, row.add_time, row.update_time, row.time, 0),
status: status,
history: rawHistory.map(normalizeHistoryItem).filter(Boolean).sort((a, b) => Number(b.applyTime) - Number(a.applyTime))
};
}
function isPendingStatus(status) {
if (status === undefined || status === null || status === '') return true;
return PENDING_STATUS.has(status);
}
function extractApplyCounts(data) {
const counts = { 0: 0, 1: 0, 2: 0 };
if (!data) return counts;
if (Array.isArray(data)) {
data.forEach(function (item) {
if (!item || typeof item !== 'object') return;
const state = item.state !== undefined ? item.state : item.status;
const count = Number(item.count != null ? item.count : item.num != null ? item.num : item.total);
if ([0, 1, 2, '0', '1', '2'].indexOf(state) >= 0 && !isNaN(count)) {
counts[Number(state)] = count;
}
});
return counts;
}
if (typeof data === 'object') {
counts[0] =
Number(
data.pending != null
? data.pending
: data.wait != null
? data.wait
: data.pending_count != null
? data.pending_count
: data.state0 != null
? data.state0
: data['0'] != null
? data['0']
: 0
) || 0;
counts[1] =
Number(
data.accept != null
? data.accept
: data.pass != null
? data.pass
: data.accept_count != null
? data.accept_count
: data.state1 != null
? data.state1
: data['1'] != null
? data['1']
: 0
) || 0;
counts[2] =
Number(
data.reject != null
? data.reject
: data.refuse != null
? data.refuse
: data.reject_count != null
? data.reject_count
: data.state2 != null
? data.state2
: data['2'] != null
? data['2']
: 0
) || 0;
}
return counts;
}
function extractListTotal(data, fallbackLength) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return Number(fallbackLength) || 0;
}
const total = Number(data.total != null ? data.total : data.count != null ? data.count : data.totalCount);
if (!isNaN(total) && total > 0) return total;
return Number(fallbackLength) || 0;
}
function sortByApplyTimeDesc(list) {
return (list || []).slice().sort(function (a, b) {
return Number(b.applyTime || 0) - Number(a.applyTime || 0);
});
}
export default {
name: 'JournalFeeApproval',
data() {
return {
loading: false,
activeState: 0,
pageIndex: 1,
pageSize: 10,
total: 0,
displayList: [],
statusCounts: {
0: 0,
1: 0,
2: 0
},
rejectVisible: false,
rejectSubmitting: false,
rejectForm: {
id: '',
acceptSn: '',
articleTitle: '',
remark: ''
},
historyVisible: false,
historyLoading: false,
historyList: [],
historyArticle: {
articleId: '',
acceptSn: '',
articleTitle: ''
}
};
},
computed: {
rejectRules() {
return {
remark: [
{
required: true,
message: this.$t('journalFeeApproval.rejectRemarkRequired'),
trigger: 'blur'
}
]
};
},
statusTabs() {
return [
{
state: 0,
label: this.$t('journalFeeApproval.tabPending'),
count: this.statusCounts[0] || 0
},
{
state: 1,
label: this.$t('journalFeeApproval.tabAccepted'),
count: this.statusCounts[1] || 0
},
{
state: 2,
label: this.$t('journalFeeApproval.tabRejected'),
count: this.statusCounts[2] || 0
}
];
},
emptyTipText() {
if (this.activeState === 1) {
return this.$t('journalFeeApproval.noAccepted');
}
if (this.activeState === 2) {
return this.$t('journalFeeApproval.noRejected');
}
return this.$t('journalFeeApproval.noPending');
}
},
created() {
if (!this.isFeeApprovalAccount()) {
this.$message.warning(this.$t('journalFeeApproval.noPermission'));
this.$router.replace('/dashboard');
return;
}
this.refreshAll();
},
methods: {
isFeeApprovalAccount() {
return String(localStorage.getItem('U_id') || '') === '24950';
},
goArticleDetail(row) {
if (!row || !row.articleId) {
this.$message.warning(this.$t('journalFeeApproval.missingArticleId'));
return;
}
this.$router.push({
path: '/articleDetailEditor',
query: {
id: row.articleId
}
});
},
fetchCounts() {
return this.$api
.post('api/Order/getApplyCount', {})
.then((res) => {
if (res && res.code === 0) {
this.statusCounts = extractApplyCounts(res.data);
}
})
.catch(function () {
// 保留已有数量
});
},
refreshAll() {
this.fetchCounts().finally(() => {
this.fetchList();
});
},
switchState(state) {
if (this.activeState === state) return;
this.activeState = state;
this.pageIndex = 1;
this.fetchList();
},
handleTabClick(tab) {
this.switchState(Number(tab.name));
},
handlePageSizeChange() {
this.pageIndex = 1;
this.fetchList();
},
fetchList() {
this.loading = true;
this.$api
.post('api/Order/getApplyList', {
state: this.activeState,
pageIndex: this.pageIndex,
pageSize: this.pageSize
})
.then((res) => {
this.loading = false;
if (res.code === 0) {
const rawList = this.extractList(res.data);
const normalized = rawList.map(normalizeApplyRow).filter(Boolean);
this.displayList = sortByApplyTimeDesc(normalized);
this.total = extractListTotal(res.data, this.displayList.length);
this.emitFeeApprovalBadgeRefresh();
} else {
this.displayList = [];
this.total = 0;
this.emitFeeApprovalBadgeRefresh();
this.$message.error(res.msg || this.$t('journalFeeApproval.loadFailed'));
}
})
.catch(() => {
this.loading = false;
this.displayList = [];
this.total = 0;
this.emitFeeApprovalBadgeRefresh();
this.$message.error(this.$t('journalFeeApproval.loadFailed'));
});
},
emitFeeApprovalBadgeRefresh() {
bus.$emit('fee-approval-badge-refresh');
},
extractList(data) {
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
return data.list || data.applies || data.applys || data.items || data.data || data.history || [];
},
normalizeArticleHistoryList(data) {
return this.extractList(data)
.map(normalizeHistoryItem)
.filter(Boolean)
.sort((a, b) => Number(b.applyTime) - Number(a.applyTime));
},
extractHistoryArticleMeta(data) {
if (!data || typeof data !== 'object') return {};
const articleInfo = data.article_info || data.article || {};
return {
acceptSn: pickValue(articleInfo.accept_sn, articleInfo.sn, data.accept_sn, data.sn, ''),
articleTitle: pickValue(
articleInfo.article_title,
articleInfo.title,
articleInfo.name,
data.article_title,
data.title,
''
)
};
},
applyHistoryArticleMeta(data, fallback) {
const meta = this.extractHistoryArticleMeta(data);
if (meta.acceptSn) {
this.historyArticle.acceptSn = meta.acceptSn;
} else if (fallback && fallback.acceptSn) {
this.historyArticle.acceptSn = fallback.acceptSn;
}
if (meta.articleTitle) {
this.historyArticle.articleTitle = meta.articleTitle;
} else if (fallback && fallback.articleTitle) {
this.historyArticle.articleTitle = fallback.articleTitle;
}
},
openHistoryDialog(row) {
if (!row || !row.articleId) {
this.$message.warning(this.$t('journalFeeApproval.missingArticleId'));
return;
}
this.historyArticle = {
articleId: row.articleId,
acceptSn: row.acceptSn,
articleTitle: row.articleTitle
};
this.historyVisible = true;
this.historyLoading = true;
this.historyList = [];
this.$api
.post('api/Order/getArticlePriceApplyList', {
article_id: row.articleId
})
.then((res) => {
this.historyLoading = false;
if (res.code === 0) {
this.applyHistoryArticleMeta(res.data, row);
this.historyList = this.normalizeArticleHistoryList(res.data);
if (this.historyList.length === 0 && row.history && row.history.length > 0) {
this.historyList = row.history;
}
} else {
this.applyHistoryArticleMeta(null, row);
this.historyList = row.history && row.history.length > 0 ? row.history : [];
if (this.historyList.length === 0) {
this.$message.error(res.msg || this.$t('journalFeeApproval.historyLoadFailed'));
}
}
})
.catch(() => {
this.historyLoading = false;
this.applyHistoryArticleMeta(null, row);
this.historyList = row.history && row.history.length > 0 ? row.history : [];
if (this.historyList.length === 0) {
this.$message.error(this.$t('journalFeeApproval.historyLoadFailed'));
}
});
},
resetHistoryDialog() {
this.historyLoading = false;
this.historyList = [];
this.historyArticle = {
articleId: '',
acceptSn: '',
articleTitle: ''
};
},
formatFee(value) {
if (value === '' || value === null || value === undefined) return '-';
const num = Number(value);
if (isNaN(num)) return value;
return num.toFixed(2);
},
formatDateTime(timestamp) {
if (!timestamp) return '-';
if (typeof timestamp === 'string' && timestamp.indexOf('-') > 0) {
return timestamp;
}
const ts = String(timestamp).length === 10 ? Number(timestamp) * 1000 : Number(timestamp);
const date = new Date(ts);
if (isNaN(date.getTime())) return '-';
const pad = (n) => (n < 10 ? '0' + n : '' + n);
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
},
getStatusLabel(status) {
const text = String(status).toLowerCase();
if (text === '2' || text.includes('reject') || text === '拒绝' || text === '驳回') {
return this.$t('journalFeeApproval.statusReject');
}
if (text === '1' || text.includes('accept') || text.includes('agree') || text === '同意' || text === '通过') {
return this.$t('journalFeeApproval.statusAccept');
}
if (text === '0' || text.includes('pending') || text.includes('wait') || text === '待审' || text === '待审批') {
return this.$t('journalFeeApproval.statusPending');
}
return this.$t('journalFeeApproval.statusOther');
},
statusTagType(status) {
const text = String(status).toLowerCase();
if (text === '2' || text.includes('reject')) return 'danger';
if (text === '1' || text.includes('accept')) return 'success';
if (text === '0' || text.includes('pending')) return 'warning';
return 'info';
},
handleAccept(row) {
this.$confirm(this.$t('journalFeeApproval.acceptConfirm'), this.$t('journalFeeApproval.tip'), {
type: 'warning',
confirmButtonText: this.$t('journalFeeApproval.accept'),
cancelButtonText: this.$t('journalFeeApproval.cancel')
})
.then(() => {
const load = this.$loading({
lock: true,
text: 'Loading...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
});
this.$api
.post('api/Order/acceptPriceApply', { id: row.id })
.then((res) => {
load.close();
if (res.code === 0) {
this.$message.success(this.$t('journalFeeApproval.acceptSuccess'));
this.refreshAll();
} else {
this.$message.error(res.msg || this.$t('journalFeeApproval.actionFailed'));
}
})
.catch(() => {
load.close();
this.$message.error(this.$t('journalFeeApproval.actionFailed'));
});
})
.catch(() => {});
},
openRejectDialog(row) {
this.rejectForm = {
id: row.id,
acceptSn: row.acceptSn,
articleTitle: row.articleTitle,
remark: row.remark || ''
};
this.rejectVisible = true;
this.$nextTick(() => {
if (this.$refs.rejectFormRef) this.$refs.rejectFormRef.clearValidate();
});
},
resetRejectForm() {
this.rejectForm = {
id: '',
acceptSn: '',
articleTitle: '',
remark: ''
};
this.rejectSubmitting = false;
},
submitReject() {
this.$refs.rejectFormRef.validate((valid) => {
if (!valid) return;
this.rejectSubmitting = true;
this.$api
.post('api/Order/rejectPriceApply', {
id: this.rejectForm.id,
remark: this.rejectForm.remark
})
.then((res) => {
this.rejectSubmitting = false;
if (res.code === 0) {
this.$message.success(this.$t('journalFeeApproval.rejectSuccess'));
this.rejectVisible = false;
this.refreshAll();
} else {
this.$message.error(res.msg || this.$t('journalFeeApproval.actionFailed'));
}
})
.catch(() => {
this.rejectSubmitting = false;
this.$message.error(this.$t('journalFeeApproval.actionFailed'));
});
});
}
}
};
</script>
<style scoped>
.handle-box {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.status-tabs {
flex: 1;
min-width: 0;
}
.status-tabs ::v-deep .el-tabs__header {
margin-bottom: 0;
}
.status-tabs ::v-deep .el-tabs__content {
display: none;
}
.status-tabs ::v-deep .el-tabs__item {
font-weight: 500;
}
.status-tabs ::v-deep #tab-0 {
color: #e6a23c;
}
.status-tabs ::v-deep #tab-0.is-active {
color: #e6a23c;
background: #fdf6ec;
border-color: #f3d19e;
border-bottom-color: #fff;
}
.status-tabs ::v-deep #tab-1 {
color: #67c23a;
}
.status-tabs ::v-deep #tab-1.is-active {
color: #67c23a;
background: #f0f9eb;
border-color: #c2e7b0;
border-bottom-color: #fff;
}
.status-tabs ::v-deep #tab-2 {
color: #f56c6c;
}
.status-tabs ::v-deep #tab-2.is-active {
color: #f56c6c;
background: #fef0f0;
border-color: #fbc4c4;
border-bottom-color: #fff;
}
.refresh-btn {
flex-shrink: 0;
}
.pagination-box {
margin-top: 16px;
text-align: right;
}
.table {
width: 100%;
font-size: 14px;
}
.tab_tie_col {
margin-bottom: 6px;
color: #333;
word-wrap: break-word;
word-break: normal;
line-height: 1.6;
}
.tab_tie_col > span {
color: #888;
margin-right: 6px;
font-size: 13px;
}
.article-title {
margin-bottom: 0;
}
.article-title-link {
color: inherit;
cursor: default;
transition: color 0.2s;
}
.article-title-link:hover {
color: #409eff;
cursor: pointer;
text-decoration: underline;
}
.apply-fee {
color: #e6a23c;
}
.approval-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.history-dialog__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: 0 0 8px;
color: #606266;
font-size: 13px;
}
.history-dialog__article {
margin-bottom: 16px;
}
.history-dialog__article-label {
display: block;
margin-bottom: 4px;
color: #606266;
font-size: 13px;
}
.history-dialog__title {
margin: 0;
color: #303133;
font-size: 14px;
font-weight: 600;
line-height: 1.6;
}
.history-dialog__item {
padding: 2px 0;
}
.history-dialog__meta-line {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 8px;
}
.history-dialog__time {
color: #909399;
font-size: 12px;
}
.history-dialog__empty {
margin: 0;
color: #909399;
font-size: 13px;
text-align: center;
}
.history-dialog {
min-height: 120px;
}
</style>

View File

@@ -232,12 +232,38 @@
</el-card>
<!-- 证书弹出框 -->
<el-dialog :visible.sync="cerVisible" width="600px" :close-on-click-modal="false">
<el-image class="table-td-thumb rev_digol" :src="this.IMG_Url"></el-image>
<el-dialog :title="$t('reviewHistory.certificateTitle')" :visible.sync="cerVisible" width="640px" :close-on-click-modal="false">
<div class="certificate-actions">
<el-button
type="primary"
plain
icon="el-icon-picture-outline"
:loading="exportingCertificateImage"
:disabled="!IMG_Url || exportingCertificatePdf"
@click="downloadCertificateImage"
>
{{ $t('reviewHistory.exportCertificateImage') }}
</el-button>
<el-button
type="primary"
icon="el-icon-document"
:loading="exportingCertificatePdf"
:disabled="!IMG_Url || exportingCertificateImage"
@click="downloadCertificatePdf"
>
{{ $t('reviewHistory.generateCertificatePdf') }}
</el-button>
</div>
<el-image
ref="certificateImage"
class="table-td-thumb rev_digol certificate-preview"
:src="IMG_Url"
fit="contain"
></el-image>
</el-dialog>
<el-dialog :visible.sync="feilVisible" width="600px" :close-on-click-modal="false">
<h2 style="text-align: center">No certificate</h2>
<h2 style="text-align: center">{{ $t('reviewHistory.noCertificate') }}</h2>
<span slot="footer" class="dialog-footer">
<el-button type="primary" @click="feilVisible = false">OK</el-button>
</span>
@@ -249,6 +275,8 @@
</template>
<script>
import { downloadCertificateImage, downloadCertificatePdf } from '@/utils/certificateExport';
export default {
data() {
return {
@@ -264,8 +292,12 @@ export default {
},
link_Tota3: 0,
IMG_Url: '',
certificateRow: null,
certificateFileName: 'certificate',
cerVisible: false,
feilVisible: false,
exportingCertificateImage: false,
exportingCertificatePdf: false,
dynamicTags: [
{
label: 'Submission System 2.0',
@@ -383,6 +415,8 @@ export default {
.then((res) => {
if (res.code == 0) {
this.IMG_Url = this.Common.mediaUrl + res.data.icon;
this.certificateRow = row;
this.certificateFileName = this.buildCertificateFileName(row);
this.cerVisible = true;
} else {
this.$message.error(res.msg);
@@ -394,6 +428,60 @@ export default {
});
},
buildCertificateFileName(row) {
const title = row && row.article_title ? String(row.article_title).trim() : '';
if (title) {
return 'certificate-' + title;
}
if (row && row.art_rev_id) {
return 'certificate-' + row.art_rev_id;
}
return 'certificate';
},
buildCertificatePdfFileName(row) {
const acceptSn = row && row.accept_sn ? String(row.accept_sn).trim() : '';
if (acceptSn) {
return 'Certificate-' + acceptSn;
}
if (row && row.art_rev_id) {
return 'Certificate-' + row.art_rev_id;
}
return 'Certificate';
},
async downloadCertificateImage() {
if (!this.IMG_Url || this.exportingCertificateImage) {
return;
}
this.exportingCertificateImage = true;
try {
await downloadCertificateImage(this.IMG_Url, this.certificateFileName);
this.$message.success(this.$t('reviewHistory.exportCertificateImageSuccess'));
} catch (error) {
console.error(error);
this.$message.error(this.$t('reviewHistory.exportCertificateImageFail'));
} finally {
this.exportingCertificateImage = false;
}
},
async downloadCertificatePdf() {
if (!this.IMG_Url || this.exportingCertificatePdf) {
return;
}
this.exportingCertificatePdf = true;
try {
await downloadCertificatePdf(this.IMG_Url, this.buildCertificatePdfFileName(this.certificateRow));
this.$message.success(this.$t('reviewHistory.generateCertificatePdfSuccess'));
} catch (error) {
console.error(error);
this.$message.error(this.$t('reviewHistory.generateCertificatePdfFail'));
} finally {
this.exportingCertificatePdf = false;
}
},
// 关闭标签
handleClose(tag) {
// this.dynamicTags.splice(this.dynamicTags.indexOf(tag), 1);
@@ -473,4 +561,14 @@ td {
/* text-align: center; */
display: flex;
}
.certificate-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-bottom: 16px;
}
.certificate-preview {
display: block;
margin: 0 auto;
}
</style>

View File

@@ -361,6 +361,13 @@ export default new Router({
title: 'Order List'
}
},
{
path: '/journalFeeApproval',
component: () => import('../components/page/journalFeeApproval.vue'),
meta: {
title: 'Journal Fee Approval'
}
},
{
path: '/articleReviewer',
component: () => import('../components/page/articleReviewer.vue'),

View File

@@ -0,0 +1,250 @@
import { saveAs } from 'file-saver';
import { resolveManuscriptFetchUrl } from './manuscriptWordReferences';
function sanitizeFileName(name) {
const base = String(name || 'certificate')
.replace(/[\\/:*?"<>|]/g, '')
.replace(/\s+/g, '-')
.trim()
.slice(0, 80);
return base || 'certificate';
}
async function fetchImageBlob(fetchUrl) {
const response = await fetch(fetchUrl);
if (!response.ok) {
throw new Error('Failed to load certificate image');
}
return response.blob();
}
function loadImageElement(imageUrl) {
return new Promise(function (resolve, reject) {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
resolve(img);
};
img.onerror = function () {
reject(new Error('Failed to load certificate image'));
};
img.src = imageUrl;
});
}
async function loadCertificateImage(imageUrl) {
const fetchUrl = resolveManuscriptFetchUrl(imageUrl) || imageUrl;
try {
const blob = await fetchImageBlob(fetchUrl);
const objectUrl = URL.createObjectURL(blob);
try {
const img = await loadImageElement(objectUrl);
return {
img: img,
revoke: function () {
URL.revokeObjectURL(objectUrl);
}
};
} catch (error) {
URL.revokeObjectURL(objectUrl);
throw error;
}
} catch (fetchError) {
const img = await loadImageElement(fetchUrl);
return {
img: img,
revoke: function () {}
};
}
}
function imageToPngDataUrl(img) {
const width = img.naturalWidth || img.width;
const height = img.naturalHeight || img.height;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas is not supported');
}
ctx.drawImage(img, 0, 0, width, height);
return {
dataUrl: canvas.toDataURL('image/png'),
width: width,
height: height
};
}
function imageToJpegBytes(img, quality) {
const width = img.naturalWidth || img.width;
const height = img.naturalHeight || img.height;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas is not supported');
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL('image/jpeg', quality == null ? 0.92 : quality);
return {
bytes: dataUrlToUint8Array(dataUrl),
width: width,
height: height
};
}
function triggerBlobDownload(blob, fileName) {
saveAs(blob, fileName);
}
function dataUrlToUint8Array(dataUrl) {
const parts = String(dataUrl).split(',');
const binary = atob(parts[1]);
const len = binary.length;
const buffer = new Uint8Array(len);
for (let i = 0; i < len; i += 1) {
buffer[i] = binary.charCodeAt(i);
}
return buffer;
}
function dataUrlToBlob(dataUrl) {
const parts = String(dataUrl).split(',');
const mimeMatch = parts[0].match(/:(.*?);/);
const mime = mimeMatch ? mimeMatch[1] : 'image/png';
return new Blob([dataUrlToUint8Array(dataUrl)], { type: mime });
}
function concatUint8Arrays(chunks) {
const total = chunks.reduce(function (sum, chunk) {
return sum + chunk.length;
}, 0);
const out = new Uint8Array(total);
let offset = 0;
chunks.forEach(function (chunk) {
out.set(chunk, offset);
offset += chunk.length;
});
return out;
}
function stringToUint8Array(text) {
const len = text.length;
const out = new Uint8Array(len);
for (let i = 0; i < len; i += 1) {
out[i] = text.charCodeAt(i) & 0xff;
}
return out;
}
/** 单页 JPEG 图片 PDF不依赖 jspdf避免 webpack 编译 node_modules 新语法 */
function buildSingleImagePdf(jpegBytes, widthPx, heightPx) {
const widthPt = Math.round(widthPx * 0.75 * 100) / 100;
const heightPt = Math.round(heightPx * 0.75 * 100) / 100;
const contentStream = 'q\n' + widthPt + ' 0 0 ' + heightPt + ' 0 0 cm\n/Im1 Do\nQ\n';
const chunks = [];
const objOffsets = {};
function totalLen() {
let length = 0;
chunks.forEach(function (chunk) {
length += chunk.length;
});
return length;
}
function pushText(text) {
chunks.push(stringToUint8Array(text));
}
function startObject(num) {
objOffsets[num] = totalLen();
}
pushText('%PDF-1.4\n');
startObject(1);
pushText('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n');
startObject(2);
pushText('2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n');
startObject(3);
pushText(
'3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' +
widthPt +
' ' +
heightPt +
'] /Resources << /XObject << /Im1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n'
);
startObject(4);
pushText(
'4 0 obj\n<< /Type /XObject /Subtype /Image /Width ' +
widthPx +
' /Height ' +
heightPx +
' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ' +
jpegBytes.length +
' >>\nstream\n'
);
chunks.push(jpegBytes);
pushText('\nendstream\nendobj\n');
startObject(5);
pushText(
'5 0 obj\n<< /Length ' +
contentStream.length +
' >>\nstream\n' +
contentStream +
'endstream\nendobj\n'
);
const xrefPos = totalLen();
let xref = 'xref\n0 6\n0000000000 65535 f \n';
for (let i = 1; i <= 5; i += 1) {
xref += String(objOffsets[i]).padStart(10, '0') + ' 00000 n \n';
}
xref += 'trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n' + xrefPos + '\n%%EOF';
pushText(xref);
return concatUint8Arrays(chunks);
}
/** 导出审稿证书为 PNG 图片 */
export async function downloadCertificateImage(imageUrl, fileName) {
if (!imageUrl) {
throw new Error('Certificate image is missing');
}
const loaded = await loadCertificateImage(imageUrl);
try {
const png = imageToPngDataUrl(loaded.img);
const blob = dataUrlToBlob(png.dataUrl);
triggerBlobDownload(blob, sanitizeFileName(fileName) + '.png');
} finally {
loaded.revoke();
}
}
/** 将审稿证书导出为 PDF单页尺寸与图片一致 */
export async function downloadCertificatePdf(imageUrl, fileName) {
if (!imageUrl) {
throw new Error('Certificate image is missing');
}
const loaded = await loadCertificateImage(imageUrl);
try {
const jpeg = imageToJpegBytes(loaded.img, 0.92);
const pdfBytes = buildSingleImagePdf(jpeg.bytes, jpeg.width, jpeg.height);
triggerBlobDownload(new Blob([pdfBytes], { type: 'application/pdf' }), sanitizeFileName(fileName) + '.pdf');
} finally {
loaded.revoke();
}
}

View File

@@ -1,6 +1,9 @@
import {
AlignmentType,
BorderStyle,
CommentRangeEnd,
CommentRangeStart,
CommentReference,
ExternalHyperlink,
ImageRun,
LineRuleType,
@@ -25,7 +28,11 @@ import {
getDocumentGridProperties
} from '@/utils/exportWordLayout';
import orcidIconUrl from '@/assets/img/orcid.png';
import { createTmrLogoImageRun } from '@/utils/exportManuscriptWordHeader';
import {
createTmrLogoImageRun,
resolveManuscriptPageHeaderCitationParts,
resolveManuscriptPageHeaderTypeText
} from '@/utils/exportManuscriptWordHeader';
/** 标题:阿里巴巴普惠体 H / Calibri四号 14pt */
const TITLE_FONT_EAST_ASIA = '阿里巴巴普惠体 H';
@@ -80,6 +87,8 @@ const HOME_PAGE_LEFT_FONT_SIZE = 13;
const CITATION_CONTENT_COLOR = '0070C0';
/** 缺少 online_date / 其它元数据时占位文案 */
const MISSING_DATA_PLACEHOLDER = 'No data';
const ACCEPTED_DATE_PLACEHOLDER = 'XXX';
const ONLINE_DATE_PLACEHOLDER = 'input online_date';
/** 缺少数据时显示正红色 rgb(255, 0, 0) */
const MISSING_DATA_COLOR = 'FF0000';
/** @deprecated 使用 MISSING_DATA_COLOR */
@@ -118,7 +127,11 @@ const HOME_PAGE_LEFT_PARAGRAPH_OOXML =
'<w:widowControl w:val="0"/>';
/** Abstract 结构化标签:粗体蓝色 rgb(0, 112, 192) */
const ABSTRACT_LABEL_COLOR = '0070C0';
const ABSTRACT_SECTION_LABEL_PATTERN = /\b(Background:|Methods:|Results:|Conclusion:)\s*/gi;
const ABSTRACT_SECTION_LABEL_PATTERN = /\b(Background:|Objective:|Methods:|Results:|Conclusion:)\s*/gi;
const ABSTRACT_OBJECTIVE_BACKGROUND_COMMENT_TEXT =
'根据本刊摘要格式要求,请将"Objective"修改为"Background",并同时将冒号后的内容调整为研究相关的背景介绍,而非直接陈述研究目的。';
const ABSTRACT_NEXT_SECTION_AFTER_OBJECTIVE_PATTERN =
/\b(Background:|Methods:|Results:|Conclusion:)\s*/i;
/** Abstract 段落(图一):段前 0.1 行、段后 0、固定行距 10 磅、两端对齐、对齐网格 */
const ABSTRACT_PARAGRAPH_SPACING = {
before: 0,
@@ -204,8 +217,19 @@ const TITLE_PARAGRAPH_SPACING_OOXML =
' w:beforeAutospacing="0" w:afterAutospacing="0"/>';
const COMPETING_INTERESTS_TEXT = 'The authors declare no conflicts of interest.';
const PEER_REVIEW_TEXT =
'We thank the peer reviewers for their constructive comments and suggestions, which helped improve the quality of this manuscript.';
function resolveJournalDisplayName(headerData) {
const journal = (headerData && headerData.journal) || {};
return String(journal.title || journal.jname || journal.name || journal.jabbr || '').trim();
}
function buildPeerReviewText(headerData) {
const journalName = resolveJournalDisplayName(headerData);
if (journalName) {
return journalName + ' reviewers for their contribution to the peer review of this paper.';
}
return 'Reviewers for their contribution to the peer review of this paper.';
}
/** 元数据双栏表格:无边框 */
const METADATA_TABLE_BORDER_NONE = {
@@ -1769,29 +1793,65 @@ function getProductionOnlineDate(production, headerData) {
return String(fromHeader || fromProduction || fromTypeset || '').trim();
}
/** Available online 专用:使用 online_date缺失时显示 No data. */
/** Available online 专用:使用 online_date缺失时显示 input online_date红色 */
function formatOnlineDateDisplay(production, headerData) {
const raw = getProductionOnlineDate(production, headerData);
if (!raw || /^no time$/i.test(raw)) {
return { text: MISSING_DATA_PLACEHOLDER, missing: true };
return { text: ONLINE_DATE_PLACEHOLDER, missing: true };
}
const formatted = formatManuscriptDisplayDate(raw);
if (formatted.missing) {
return { text: MISSING_DATA_PLACEHOLDER, missing: true };
return { text: ONLINE_DATE_PLACEHOLDER, missing: true };
}
return formatted;
}
/** Accepted 专用:缺失时显示 No data. */
/** Accepted 专用:缺失时显示 XXX红色 */
function formatAcceptedDateDisplay(review) {
const formatted = formatManuscriptDisplayDate(review && review.accepted_time);
if (formatted.missing) {
return { text: MISSING_DATA_PLACEHOLDER, missing: true };
return { text: ACCEPTED_DATE_PLACEHOLDER, missing: true };
}
return formatted;
}
function resolveCopyrightYear(headerData) {
const production = (headerData && headerData.production) || {};
const stageInfo = (headerData && headerData.stageInfo) || {};
const fromStageInfo = stageInfo.stage_year != null ? String(stageInfo.stage_year).trim() : '';
if (fromStageInfo) {
return fromStageInfo;
}
const fromProduction = production.stage_year != null ? String(production.stage_year).trim() : '';
if (fromProduction) {
return fromProduction;
}
const citationParts = resolveManuscriptPageHeaderCitationParts(headerData || {});
const stageMatch = String(citationParts.stage || '').trim().match(/^(\d{4})/);
if (stageMatch) {
return stageMatch[1];
}
const review = (headerData && headerData.finalReview) || {};
const accepted = formatManuscriptDisplayDate(review.accepted_time);
if (!accepted.missing) {
const acceptedYearMatch = String(accepted.text || '').match(/(\d{4})/);
if (acceptedYearMatch) {
return acceptedYearMatch[1];
}
}
return String(new Date().getFullYear());
}
function appendCopyrightNoticeParagraph(paragraphs, headerData) {
const year = resolveCopyrightYear(headerData);
const text =
'© ' +
year +
' Author(s). Published by TMR Publishing Group Limited. This is an open access article under the CC-BY license. (https://creativecommons.org/licenses/by/4.0/)';
paragraphs.push(createHomePageLeftParagraph([createStyledTextRun(text, getHomePageLeftContentFontStyle())]));
}
function buildCitationRuns(headerData) {
const production = (headerData && headerData.production) || {};
const journal = (headerData && headerData.journal) || {};
@@ -1803,6 +1863,12 @@ function buildCitationRuns(headerData) {
const abbr = String(production.abbr || '').trim();
const title = resolveManuscriptDisplayTitle(headerData);
const journalJabbr = String((journal && journal.jabbr) || '').trim();
const citationParts = resolveManuscriptPageHeaderCitationParts(headerData);
let stage = String(citationParts.stage || '').trim();
if (stage.endsWith('.')) {
stage = stage.slice(0, -1);
}
const doiSuffix = String(citationParts.doiSuffix || '').trim();
if (!abbr && !title) {
return [];
@@ -1818,6 +1884,12 @@ function buildCitationRuns(headerData) {
if (journalJabbr) {
runs.push(createStyledTextRun(journalJabbr + '.', journalStyle));
}
if (stage) {
runs.push(createStyledTextRun(' ' + stage + '.', citationStyle));
}
if (doiSuffix) {
runs.push(createStyledTextRun(' doi: 10.53388/' + doiSuffix + '.', citationStyle));
}
return runs;
}
@@ -1834,16 +1906,20 @@ function createMetadataBodyParagraph(text) {
return createHomePageLeftParagraph([createStyledTextRun(content, getHomePageLeftContentFontStyle())]);
}
function appendMetadataInlinePlain(paragraphs, label, text) {
function appendMetadataInlinePlain(paragraphs, label, text, options) {
const opts = options || {};
const trailingPeriod = opts.trailingPeriod === true;
const content = normalizeMetadataFieldValue(text);
paragraphs.push(
createHomePageLeftParagraph([
createStyledTextRun(label + ' ', getHomePageLeftPlainFontStyle()),
content
? createStyledTextRun(content, getHomePageLeftContentFontStyle())
: createMissingDataRun()
])
);
const runs = [
createStyledTextRun(label + ' ', getHomePageLeftPlainFontStyle()),
content
? createStyledTextRun(content, getHomePageLeftContentFontStyle())
: createMissingDataRun()
];
if (trailingPeriod) {
runs.push(createStyledTextRun('.', getHomePageLeftContentFontStyle()));
}
paragraphs.push(createHomePageLeftParagraph(runs));
}
function appendReviewDatesParagraph(paragraphs, finalReview, production, headerData) {
@@ -1870,6 +1946,7 @@ function appendReviewDatesParagraph(paragraphs, finalReview, production, headerD
}
});
runs.push(createStyledTextRun('.', contentStyle));
paragraphs.push(createHomePageLeftParagraph(runs));
}
@@ -1893,8 +1970,118 @@ function createAbstractHeadingParagraph() {
]);
}
function buildAbstractContentRuns(text) {
const content = String(text || '').trim();
function hasAbstractSectionLabel(text, label) {
return new RegExp('\\b' + label + '\\s*:\\s*', 'i').test(String(text || ''));
}
function isArticleManuscriptType(headerData) {
return resolveManuscriptPageHeaderTypeText(headerData) === 'ARTICLE';
}
function shouldAddAbstractObjectiveBackgroundComment(headerData, abstractText) {
const content = String(abstractText || '').trim();
if (!content) {
return false;
}
if (!isArticleManuscriptType(headerData)) {
return false;
}
if (hasAbstractSectionLabel(content, 'Background')) {
return false;
}
if (!hasAbstractSectionLabel(content, 'Objective')) {
return false;
}
return true;
}
function extractAbstractObjectiveSectionRange(text) {
const content = String(text || '');
const objectiveMatch = content.match(/\bObjective\s*:\s*/i);
if (!objectiveMatch || objectiveMatch.index == null) {
return null;
}
const start = objectiveMatch.index;
const afterObjective = start + objectiveMatch[0].length;
const rest = content.slice(afterObjective);
const nextLabelMatch = rest.match(ABSTRACT_NEXT_SECTION_AFTER_OBJECTIVE_PATTERN);
const end =
nextLabelMatch && nextLabelMatch.index != null
? afterObjective + nextLabelMatch.index
: content.length;
let trimmedEnd = end;
while (trimmedEnd > start && /\s/.test(content.charAt(trimmedEnd - 1))) {
trimmedEnd -= 1;
}
return { start, end: trimmedEnd };
}
export function buildAbstractObjectiveWordCommentDefinitions(headerData) {
const production = (headerData && headerData.production) || {};
const abstractText = htmlToPlainText(production.abstract || '');
if (!shouldAddAbstractObjectiveBackgroundComment(headerData, abstractText)) {
return [];
}
const range = extractAbstractObjectiveSectionRange(abstractText);
if (!range) {
return [];
}
return [
{
id: 0,
author: 'Editor',
initials: 'Ed',
date: new Date(),
text: ABSTRACT_OBJECTIVE_BACKGROUND_COMMENT_TEXT,
range
}
];
}
export function buildManuscriptWordDocumentComments(definitions) {
if (!definitions || !definitions.length) {
return null;
}
return {
children: definitions.map(function (def) {
const textBlocks = String(def.text || '')
.split(/\n\s*\n/)
.map(function (block) {
return block.trim();
})
.filter(Boolean);
const paragraphs =
textBlocks.length > 0
? textBlocks.map(function (block) {
return new Paragraph({
children: [new TextRun(block)]
});
})
: [
new Paragraph({
children: [new TextRun('')]
})
];
return {
id: def.id,
author: def.author || 'Editor',
initials: def.initials || 'Ed',
date: def.date || new Date(),
children: paragraphs
};
})
};
}
function buildAbstractStyledContentRuns(text) {
const content = String(text || '');
if (!content) {
return [];
}
@@ -1925,11 +2112,53 @@ function buildAbstractContentRuns(text) {
runs.push(createStyledTextRun(content.slice(lastIndex), baseStyle));
}
return runs.length ? runs : [createStyledTextRun(content, baseStyle)];
return runs;
}
function createAbstractContentParagraph(text) {
const runs = buildAbstractContentRuns(text);
function buildAbstractContentRuns(text, options) {
const opts = options || {};
const content = String(text || '').trim();
if (!content) {
return [];
}
const commentRange = opts.commentRange;
if (
commentRange &&
Number.isFinite(commentRange.start) &&
Number.isFinite(commentRange.end) &&
Number.isFinite(commentRange.commentId)
) {
const safeStart = Math.max(0, Math.min(commentRange.start, content.length));
const safeEnd = Math.max(safeStart, Math.min(commentRange.end, content.length));
const runs = [];
if (safeStart > 0) {
runs.push.apply(runs, buildAbstractStyledContentRuns(content.slice(0, safeStart)));
}
if (safeEnd > safeStart) {
runs.push(new CommentRangeStart(commentRange.commentId));
runs.push.apply(runs, buildAbstractStyledContentRuns(content.slice(safeStart, safeEnd)));
runs.push(new CommentRangeEnd(commentRange.commentId));
runs.push(
new TextRun({
children: [new CommentReference(commentRange.commentId)]
})
);
}
if (safeEnd < content.length) {
runs.push.apply(runs, buildAbstractStyledContentRuns(content.slice(safeEnd)));
}
return runs.length ? runs : [createStyledTextRun(content, getMetadataTableFontStyle())];
}
const styledRuns = buildAbstractStyledContentRuns(content);
return styledRuns.length ? styledRuns : [createStyledTextRun(content, getMetadataTableFontStyle())];
}
function createAbstractContentParagraph(text, options) {
const runs = buildAbstractContentRuns(text, options);
if (!runs.length) {
return null;
}
@@ -1974,18 +2203,28 @@ function buildLeftMetadataParagraphs(headerData) {
paragraphs.push(createHomePageLeftEmptyLineParagraph());
}
appendMetadataBlock(paragraphs, 'Peer review information', PEER_REVIEW_TEXT);
appendMetadataBlock(paragraphs, 'Peer review information', buildPeerReviewText(headerData));
const boardName =
finalReview && finalReview.reviewer_id && finalReview.realname ? finalReview.realname : '';
appendMetadataInlinePlain(paragraphs, 'Editorial Advisory Board:', boardName);
appendMetadataInlinePlain(paragraphs, 'Production editor:', htmlToPlainText(production.executive_editor || ''));
appendMetadataInlinePlain(paragraphs, 'Editorial Advisory Board:', boardName, { trailingPeriod: true });
appendMetadataInlinePlain(paragraphs, 'Production editor:', htmlToPlainText(production.executive_editor || ''), {
trailingPeriod: true
});
appendReviewDatesParagraph(paragraphs, finalReview, production, headerData);
appendCopyrightNoticeParagraph(paragraphs, headerData);
return paragraphs.length ? paragraphs : [createMissingMetadataBodyParagraph()];
}
function buildRightMetadataParagraphs(headerData) {
function formatHomePageKeywords(text) {
return htmlToPlainText(text)
.trim()
.replace(/\s*,\s*/g, '; ');
}
function buildRightMetadataParagraphs(headerData, options) {
const opts = options || {};
const production = (headerData && headerData.production) || {};
const paragraphs = [];
@@ -1993,7 +2232,9 @@ function buildRightMetadataParagraphs(headerData) {
const abstractText = htmlToPlainText(production.abstract || '');
if (abstractText) {
const abstractParagraph = createAbstractContentParagraph(abstractText);
const abstractParagraph = createAbstractContentParagraph(abstractText, {
commentRange: opts.abstractObjectiveComment || null
});
if (abstractParagraph) {
paragraphs.push(abstractParagraph);
}
@@ -2001,7 +2242,7 @@ function buildRightMetadataParagraphs(headerData) {
paragraphs.push(createMissingAbstractBodyParagraph());
}
const keywords = String(production.keywords || '').trim();
const keywords = formatHomePageKeywords(production.keywords || '');
if (keywords) {
if (abstractText) {
paragraphs.push(createAbstractEmptyLineParagraph());
@@ -2040,10 +2281,10 @@ function createMetadataTableCell(paragraphs, widthTwips) {
return new TableCell(cellOptions);
}
function buildMetadataTable(headerData) {
function buildMetadataTable(headerData, options) {
/** 首页表格:左栏 Author contributions 6.44cm,右栏 Abstract 11.36cm */
const leftParagraphs = buildLeftMetadataParagraphs(headerData);
const rightParagraphs = buildRightMetadataParagraphs(headerData);
const rightParagraphs = buildRightMetadataParagraphs(headerData, options);
return new Table({
width: {
@@ -2509,8 +2750,8 @@ export async function patchMetadataTableProperties(blob) {
});
}
function buildHomePageTableSectionChildren(headerData) {
return [buildMetadataTable(headerData)];
function buildHomePageTableSectionChildren(headerData, options) {
return [buildMetadataTable(headerData, options)];
}
function buildMetadataSectionChildren(headerData) {
@@ -2577,7 +2818,7 @@ function buildTitleBlockChildren(headerData, orcidImageData, tmrLogoImageData) {
return children;
}
export function buildAuthorHeaderSectionGroups(headerData, orcidImageData, tmrLogoImageData) {
export function buildAuthorHeaderSectionGroups(headerData, orcidImageData, tmrLogoImageData, options) {
if (!headerData) {
return [];
}
@@ -2587,7 +2828,7 @@ export function buildAuthorHeaderSectionGroups(headerData, orcidImageData, tmrLo
return [];
}
const homePageTableChildren = buildHomePageTableSectionChildren(headerData);
const homePageTableChildren = buildHomePageTableSectionChildren(headerData, options);
if (!homePageTableChildren.length) {
return [];
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,9 @@
import {
AlignmentType,
BorderStyle,
CommentRangeEnd,
CommentRangeStart,
CommentReference,
Document,
LineRuleType,
Packer,
@@ -18,6 +21,8 @@ import { saveAs } from 'file-saver';
import JSZip from 'jszip';
import { TableUtils } from '@/common/js/TableUtils';
import { isMathFormulaTableRecord } from '@/utils/mathFormulaModule';
import { expandCitationBracket } from '@/utils/manuscriptCitationRelevance';
import { tokenizeReferenceHighlightSegments } from '@/utils/manuscriptMediaReferences';
/** 斑马纹 rgb(250, 231, 232) */
const ODD_ROW_FILL = 'FAE7E8';
@@ -430,33 +435,117 @@ function normalizeCitationBracketCommaSpacing(text) {
});
}
/** 匹配正文参考文献标号:\[[0-9, -\-\]{1,}\] */
const CITATION_BRACKET_PATTERN = /\[[0-9, \-]+\]/g;
function appendRunsForCitationText(text, style, appendRun) {
function createCitationCommentMatcher(citationComments) {
const byRef = {};
(citationComments || []).forEach(function (comment) {
if (!comment || comment.refNo == null) {
return;
}
const key = String(comment.refNo);
if (!byRef[key]) {
byRef[key] = [];
}
byRef[key].push(comment);
});
Object.keys(byRef).forEach(function (key) {
byRef[key].sort(function (a, b) {
return Number(a.occurrence || 0) - Number(b.occurrence || 0);
});
});
const nextIndex = {};
return {
matchBracket: function (inner) {
const nums = expandCitationBracket(inner);
for (let i = 0; i < nums.length; i += 1) {
const refNo = nums[i];
const key = String(refNo);
const idx = nextIndex[key] || 0;
nextIndex[key] = idx + 1;
const list = byRef[key];
if (list && list[idx]) {
return list[idx].commentId;
}
}
return null;
}
};
}
function appendRunsForReferenceHighlightText(text, style, appendRun, commentMatcher) {
const normalized = normalizeCitationBracketCommaSpacing(text);
if (!normalized) {
return;
}
let lastIndex = 0;
let match;
CITATION_BRACKET_PATTERN.lastIndex = 0;
while ((match = CITATION_BRACKET_PATTERN.exec(normalized)) !== null) {
if (match.index > lastIndex) {
appendRun(normalized.slice(lastIndex, match.index), style);
const segments = tokenizeReferenceHighlightSegments(normalized);
segments.forEach(function (segment) {
if (!segment || !segment.text) {
return;
}
appendRun(match[0], Object.assign({}, style, { blue: true }));
lastIndex = match.index + match[0].length;
}
if (segment.blue && /^\[/.test(segment.text)) {
const bracketText = segment.text;
const inner = bracketText.slice(1, -1);
const commentId = commentMatcher ? commentMatcher.matchBracket(inner) : null;
if (commentId != null && typeof appendRun.onCommentBracket === 'function') {
appendRun.onCommentBracket(bracketText, Object.assign({}, style, { blue: true }), commentId);
} else {
appendRun(bracketText, Object.assign({}, style, { blue: true }));
}
return;
}
if (segment.blue) {
appendRun(segment.text, Object.assign({}, style, { blue: true }));
return;
}
appendRun(segment.text, style);
});
}
if (lastIndex < normalized.length) {
appendRun(normalized.slice(lastIndex), style);
function createStyledExportTextRun(text, style, boldDefault) {
return new TextRun({
text,
font: FONT_NAME,
size: TABLE_FONT_SIZE,
bold: boldDefault || (style && style.bold),
italics: style && style.italic,
superScript: style && style.sup,
subScript: style && style.sub,
color: style && style.blue ? BLUE_COLOR : undefined,
kern: FONT_KERN_MIN_1PT
});
}
function createCitationAppendRun(runs, boldDefault, commentMatcher) {
const appendRun = function (text, style) {
if (!text) {
return;
}
runs.push(createStyledExportTextRun(text, style, boldDefault));
};
if (commentMatcher) {
appendRun.onCommentBracket = function (bracketText, style, commentId) {
runs.push(new CommentRangeStart(commentId));
runs.push(createStyledExportTextRun(bracketText, style, boldDefault));
runs.push(new CommentRangeEnd(commentId));
runs.push(
new TextRun({
children: [new CommentReference(commentId)]
})
);
};
}
return appendRun;
}
function htmlToTextRuns(html, options) {
const { bold = false } = options || {};
const opts = options || {};
const bold = opts.bold === true;
const citationComments = opts.citationComments || null;
const commentMatcher =
citationComments && citationComments.length ? createCitationCommentMatcher(citationComments) : null;
const runs = [];
const raw = String(html || '');
@@ -474,24 +563,13 @@ function htmlToTextRuns(html, options) {
if (typeof document === 'undefined') {
const plainRuns = [];
appendRunsForCitationText(htmlToPlainText(raw), { bold, italics: false, sup: false, sub: false, blue: false }, function (
text,
style
) {
plainRuns.push(
new TextRun({
text,
font: FONT_NAME,
size: TABLE_FONT_SIZE,
bold: bold || style.bold,
italics: style.italic,
superScript: style.sup,
subScript: style.sub,
color: style.blue ? BLUE_COLOR : undefined,
kern: FONT_KERN_MIN_1PT
})
);
});
const appendRun = createCitationAppendRun(plainRuns, bold, commentMatcher);
appendRunsForReferenceHighlightText(
htmlToPlainText(raw),
{ bold, italics: false, sup: false, sub: false, blue: false },
appendRun,
commentMatcher
);
if (plainRuns.length) {
return plainRuns;
}
@@ -508,32 +586,15 @@ function htmlToTextRuns(html, options) {
const root = document.createElement('div');
root.innerHTML = raw;
function appendRun(text, style) {
if (!text) {
return;
}
runs.push(
new TextRun({
text,
font: FONT_NAME,
size: TABLE_FONT_SIZE,
bold: bold || style.bold,
italics: style.italic,
superScript: style.sup,
subScript: style.sub,
color: style.blue ? BLUE_COLOR : undefined,
kern: FONT_KERN_MIN_1PT
})
);
}
const appendRun = createCitationAppendRun(runs, bold, commentMatcher);
function walk(node, style) {
if (node.nodeType === Node.TEXT_NODE) {
appendRunsForCitationText(
appendRunsForReferenceHighlightText(
decodeHtmlEntities(node.textContent).replace(/\u00a0/g, ' '),
style,
appendRun
appendRun,
commentMatcher
);
return;
}
@@ -596,6 +657,10 @@ function htmlToTextRuns(html, options) {
return runs;
}
function htmlToTextRunsWithCitationComments(html, options, citationComments) {
return htmlToTextRuns(html, Object.assign({}, options || {}, { citationComments: citationComments || [] }));
}
/** 表头:相邻片段之间补空格,再规范为“一词一空” */
function formatHeaderWordSpacing(html) {
const raw = String(html || '');
@@ -857,7 +922,7 @@ function buildTableWordChildren(processedItem, options) {
return children;
}
/** 稿件导出:标题 + 表格 + 表注均为单栏(分节符在标题前 / 表前,双栏恢复在表注后 / 表后 */
/** 稿件导出:标题 + 表格 + 表注均为单栏(与图片块相同,由 exportManuscriptWord 分节 */
export function buildTableWordManuscriptParts(processedItem, options) {
const blockChildren = buildTableWordChildren(processedItem, options);
const tableIndex = blockChildren.findIndex(function (child) {
@@ -947,6 +1012,7 @@ export {
FONT_NAME,
htmlToPlainText,
htmlToTextRuns,
htmlToTextRunsWithCitationComments,
PAGE_MARGINS,
patchBodyTableCaptionCenter,
splitHtmlSegments,

View File

@@ -0,0 +1,289 @@
/** 正文图表引用Fig. 1 / Figure 1 / Figures 10 and 11 / Table 1 / Tables 24 等 */
const MEDIA_NUMBER_LIST = '(\\d+(?:\\s*(?:,|and|&|to||—|-)\\s*\\d+)*)';
export const FIGURE_REFERENCE_PATTERN = new RegExp(
'\\bFig(?:s?\\.)?(?:ure)?s?\\s+' + MEDIA_NUMBER_LIST,
'gi'
);
export const TABLE_REFERENCE_PATTERN = new RegExp('\\bTables?\\s+' + MEDIA_NUMBER_LIST, 'gi');
const CITATION_BRACKET_PATTERN = /\[(\d+(?:\d+)?(?:, ?\d+(?:\d+)?)*)\]/g;
function isOffsetInsideBlueTag(fullString, offset) {
const before = String(fullString || '').slice(0, offset);
const open = before.lastIndexOf('<blue>');
if (open < 0) {
return false;
}
const close = before.lastIndexOf('</blue>');
return open > close;
}
function wrapRegexMatchesInBlue(html, pattern) {
const source = String(html || '');
if (!source) {
return source;
}
const re = new RegExp(pattern.source, pattern.flags);
return source.replace(re, function (match, _numbers, offset, fullString) {
if (isOffsetInsideBlueTag(fullString, offset)) {
return match;
}
const prefix = fullString.substring(offset - 6, offset);
const suffix = fullString.substring(offset + match.length, offset + match.length + 7);
if (prefix === '<blue>' && suffix === '</blue>') {
return match;
}
return '<blue>' + match + '</blue>';
});
}
export function expandMediaNumberList(text) {
const result = [];
String(text || '')
.replace(/\u00a0/g, ' ')
.split(/\s*(?:,|and|&)\s*/i)
.forEach(function (part) {
const token = part.trim();
if (!token) {
return;
}
const range = token.match(/^(\d+)\s*[–—-]\s*(\d+)$/);
if (range) {
const a = Number(range[1]);
const b = Number(range[2]);
const lo = Math.min(a, b);
const hi = Math.max(a, b);
for (let i = lo; i <= hi; i += 1) {
result.push(i);
}
return;
}
if (/^\d+$/.test(token)) {
result.push(Number(token));
}
});
return result;
}
export function extractFigureNumbersFromText(text) {
const nums = [];
const re = new RegExp(FIGURE_REFERENCE_PATTERN.source, 'gi');
let match = null;
while ((match = re.exec(String(text || ''))) !== null) {
expandMediaNumberList(match[1]).forEach(function (n) {
nums.push(n);
});
}
return nums;
}
export function extractTableNumbersFromText(text) {
const nums = [];
const re = new RegExp(TABLE_REFERENCE_PATTERN.source, 'gi');
let match = null;
while ((match = re.exec(String(text || ''))) !== null) {
expandMediaNumberList(match[1]).forEach(function (n) {
nums.push(n);
});
}
return nums;
}
export function parseFigureNumberFromTitle(title) {
const m = String(title || '')
.trim()
.match(/^Fig(?:\.|ure)?s?\.?\s*(\d+)/i);
return m ? Number(m[1]) : null;
}
export function parseTableNumberFromTitle(title) {
const m = String(title || '')
.trim()
.match(/^Table\s+(\d+)/i);
return m ? Number(m[1]) : null;
}
export function collectFigureNumbersFromWordList(wordList) {
const set = {};
(wordList || []).forEach(function (item) {
if (!item || item.type != 1) {
return;
}
const title = item.image && item.image.title ? item.image.title : '';
const n = parseFigureNumberFromTitle(title);
if (n != null && !isNaN(n)) {
set[n] = true;
}
});
return Object.keys(set)
.map(Number)
.sort(function (a, b) {
return a - b;
});
}
export function collectTableNumbersFromWordList(wordList) {
const set = {};
(wordList || []).forEach(function (item) {
if (!item || item.type != 2) {
return;
}
const title = item.table && item.table.title ? item.table.title : '';
const n = parseTableNumberFromTitle(title);
if (n != null && !isNaN(n)) {
set[n] = true;
}
});
return Object.keys(set)
.map(Number)
.sort(function (a, b) {
return a - b;
});
}
function stripHtmlToPlain(html) {
return String(html || '')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* 将正文中的参考文献标号、Figure/Figure、Table 引用包裹为 <blue>(已包裹则跳过)
*/
export function wrapReferenceHighlightsInBlueHtml(html) {
let next = String(html || '');
if (!next) {
return next;
}
next = next.replace(CITATION_BRACKET_PATTERN, function (match, content, offset, fullString) {
if (isOffsetInsideBlueTag(fullString, offset)) {
return match;
}
const prefix = fullString.substring(offset - 6, offset);
const suffix = fullString.substring(offset + match.length, offset + match.length + 7);
if (prefix === '<blue>' && suffix === '</blue>') {
return match;
}
if (/^\d+$/.test(content) || /, ?/.test(content) || //.test(content)) {
return '<blue>' + match + '</blue>';
}
return match;
});
next = wrapRegexMatchesInBlue(next, FIGURE_REFERENCE_PATTERN);
next = wrapRegexMatchesInBlue(next, TABLE_REFERENCE_PATTERN);
return next;
}
/**
* 校验正文中的 Figure/Table 引用是否存在于当前稿件图表列表
* @returns {Array<{ kind: 'figure'|'table', number: number, amId: *, snippet: string }>}
*/
export function validateMediaReferencesInWordList(wordList) {
const figureNums = collectFigureNumbersFromWordList(wordList);
const tableNums = collectTableNumbersFromWordList(wordList);
const figureSet = {};
const tableSet = {};
figureNums.forEach(function (n) {
figureSet[n] = true;
});
tableNums.forEach(function (n) {
tableSet[n] = true;
});
const issues = [];
(wordList || []).forEach(function (item) {
if (!item || item.type == 1 || item.type == 2) {
return;
}
const html = String(item.content || item.text || '');
if (!html.trim()) {
return;
}
const plain = stripHtmlToPlain(html);
const amId = item.am_id != null ? item.am_id : item.p_main_id;
extractFigureNumbersFromText(plain).forEach(function (num) {
if (!figureSet[num]) {
issues.push({
kind: 'figure',
number: num,
amId: amId,
snippet: plain.slice(0, 120)
});
}
});
extractTableNumbersFromText(plain).forEach(function (num) {
if (!tableSet[num]) {
issues.push({
kind: 'table',
number: num,
amId: amId,
snippet: plain.slice(0, 120)
});
}
});
});
const seen = {};
return issues.filter(function (issue) {
const key = issue.kind + '|' + issue.number + '|' + issue.amId;
if (seen[key]) {
return false;
}
seen[key] = true;
return true;
});
}
/** 按 token 切分文本,供 Word 导出时标蓝 */
export function tokenizeReferenceHighlightSegments(text) {
const normalized = String(text || '').replace(/\u00a0/g, ' ');
if (!normalized) {
return [];
}
const combined = new RegExp(
'(\\[[0-9, \\-]+\\])|(' +
FIGURE_REFERENCE_PATTERN.source +
')|(' +
TABLE_REFERENCE_PATTERN.source +
')',
'gi'
);
const segments = [];
let lastIndex = 0;
let match = null;
while ((match = combined.exec(normalized)) !== null) {
if (match.index > lastIndex) {
segments.push({
text: normalized.slice(lastIndex, match.index),
blue: false
});
}
segments.push({
text: match[0],
blue: true
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < normalized.length) {
segments.push({
text: normalized.slice(lastIndex),
blue: false
});
}
return segments.length ? segments : [{ text: normalized, blue: false }];
}

File diff suppressed because it is too large Load Diff

View File

@@ -131,6 +131,7 @@ export function buildReferencesEditableHtml(references, labels) {
const intro = L.intro || '';
const saveTip = L.saveTip || '';
const downloadBtn = L.downloadEdited || 'Download edited HTML';
const copyBracketBtn = L.copyBracket || 'Copy [n] format';
const refTitle = L.referencesTitle || 'References';
const bulkHint = L.bulkHint || '';
const bulkContent = buildReferencesBulkHtml(list);
@@ -147,6 +148,7 @@ export function buildReferencesEditableHtml(references, labels) {
'.intro{color:#606266;font-size:14px;margin-bottom:12px;white-space:pre-wrap}' +
'.actions{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px}' +
'.btn{border:1px solid #409eff;background:#409eff;color:#fff;border-radius:4px;padding:8px 16px;font-size:13px;cursor:pointer}' +
'.btn--secondary{border-color:#67c23a;background:#67c23a}' +
'.ref-panel{background:#fff;border-radius:8px;padding:20px 24px;box-shadow:0 1px 4px rgba(0,0,0,.08);font-size:12px}' +
'.ref-bulk{min-height:480px;padding:12px 14px;outline:none;border:1px solid #dcdfe6;border-radius:4px;line-height:1.6;white-space:pre-wrap;word-break:break-word}' +
'.ref-bulk:focus{border-color:#409eff;box-shadow:0 0 0 2px rgba(64,158,255,.15)}' +
@@ -157,6 +159,7 @@ export function buildReferencesEditableHtml(references, labels) {
'<div class="intro">' + escapeHtml(intro) + '</div>' +
'<div class="actions">' +
'<button type="button" class="btn" id="download-edited-btn">' + escapeHtml(downloadBtn) + '</button>' +
'<button type="button" class="btn btn--secondary" id="copy-bracket-btn">' + escapeHtml(copyBracketBtn) + '</button>' +
'</div>' +
'<div class="intro" style="font-size:12px;color:#909399">' + escapeHtml(saveTip) + '</div>' +
'</div>' +
@@ -165,16 +168,9 @@ export function buildReferencesEditableHtml(references, labels) {
'<div id="ref-bulk" class="ref-bulk" contenteditable="true" spellcheck="false">' +
bulkContent +
'</div></div></div>' +
'<script>(function(){' +
'function serializePage(){var clone=document.documentElement.cloneNode(true);' +
'var btn=clone.querySelector("#download-edited-btn");if(btn){btn.remove();}' +
'return "<!DOCTYPE html>"+clone.outerHTML;}' +
'document.getElementById("download-edited-btn").addEventListener("click",function(){' +
'var html=serializePage();var blob=new Blob([html],{type:"text/html;charset=utf-8"});' +
'var url=URL.createObjectURL(blob);var a=document.createElement("a");' +
'a.href=url;a.download=' + JSON.stringify(L.downloadFileName || 'references-edited.html') + ';' +
'document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url);});' +
'})();<' +
'<script>' +
buildReferencesEditorPageScript(L) +
'<' +
'/script></body></html>'
);
}
@@ -232,6 +228,15 @@ function splitReferenceSegmentsByBlockElements(raw) {
return [];
}
const numberedRefItems = root.querySelectorAll('p.word-ref-item[data-list-num], p[data-list-num]');
if (numberedRefItems.length > 1) {
return Array.from(numberedRefItems)
.map(function (node) {
return unwrapReferenceHtmlSegment(node.innerHTML || node.textContent || '');
})
.filter(Boolean);
}
const directBlocks = Array.from(root.children).filter(function (el) {
const tag = el.tagName.toLowerCase();
return tag === 'p' || tag === 'div';
@@ -316,7 +321,10 @@ function inferReferenceTypeFromHtml(html) {
if (/ISBN\s*:/i.test(raw)) {
return 'book';
}
return 'journal';
if (/doi\.org\/10\.\d{4,9}\//i.test(raw)) {
return 'journal';
}
return 'other';
}
function plainSegmentToReferenceHtml(segment) {
@@ -341,6 +349,104 @@ function segmentToReferenceItem(segment) {
};
}
/** Available at: 前合并为单行,冒号后换行;序号改为 [n] */
function formatReferenceSegmentForBracketCopy(segment, index) {
let seg = stripLeadingReferenceNumber(String(segment || '').trim());
if (!seg) {
return '';
}
const availableMatch = seg.match(/^([\s\S]*?)(Available at:\s*)([\s\S]*)$/i);
if (availableMatch) {
const before = availableMatch[1]
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const linkPart = availableMatch[3].replace(/^<br\s*\/?>/i, '').trim();
seg = before + ' Available at:<br>' + linkPart;
} else {
seg = seg.replace(/<br\s*\/?>/gi, ' ').replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
}
return '[' + (index + 1) + '] ' + seg;
}
/** 将编辑框整段文献转为 [n] 编号 + Available at 换行格式 */
export function formatReferenceBulkForBracketCopy(bulkHtml) {
const segments = splitReferenceSegments(normalizeReferenceBulkHtml(bulkHtml));
const items = segments
.map(function (segment, index) {
return formatReferenceSegmentForBracketCopy(segment, index);
})
.filter(Boolean);
if (!items.length) {
return { plain: '', html: '', items: [] };
}
const htmlBlock = items.join('<br><br>');
const plain = htmlToPlainText(htmlBlock.replace(/Available at:<br\s*\/?>/gi, 'Available at:\n')).trim();
const htmlDoc =
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="font-family:' +
REF_COPY_FONT +
';font-size:12pt">' +
items
.map(function (item) {
return '<p style="margin:0 0 8pt;line-height:1.5">' + item + '</p>';
})
.join('') +
'</body></html>';
return { plain: plain, html: htmlDoc, items: items };
}
function buildReferencesEditorPageScript(labels) {
const L = labels || {};
const copySuccess = JSON.stringify(L.copyBracketSuccess || 'Copied to clipboard');
const copyEmpty = JSON.stringify(L.copyBracketEmpty || 'Nothing to copy');
const copyFail = JSON.stringify(L.copyBracketFail || 'Copy failed');
return (
'(function(){' +
'function stripLeadingReferenceNumber(text){return String(text||"").replace(/^\\s*(?:<[^>]+>\\s*)*\\[\\d+\\]\\s*/i,"").replace(/^\\s*(?:<[^>]+>\\s*)*\\d+\\.\\s*/i,"").trim();}' +
'function normalizeReferenceBulkHtml(html){return String(html||"").replace(/\\r\\n/g,"\\n").replace(/<div>\\s*<br\\s*\\/?>\\s*<\\/div>/gi,"<br><br>").replace(/<\\/div>\\s*<div[^>]*>/gi,"<br><br>").replace(/^<div[^>]*>/i,"").replace(/<\\/div>$/i,"");}' +
'function splitReferenceSegments(body){var raw=normalizeReferenceBulkHtml(body);raw=String(raw||"").trim();if(!raw){return[];}if(/<br\\s*\\/?>\\s*<br\\s*\\/?>/i.test(raw)){return raw.split(/<br\\s*\\/?>\\s*<br\\s*\\/?>/i).map(function(p){return p.trim();}).filter(Boolean);}var normalized=raw.replace(/\\r\\n/g,"\\n").replace(/<br\\s*\\/?>/gi,"\\n");if(/\\n(?=\\d+\\.\\s)/.test(normalized)){return normalized.split(/\\n(?=\\d+\\.\\s)/).map(function(p){return p.trim();}).filter(Boolean);}if(/\\n(?=\\[\\d+\\])/.test(normalized)){return normalized.split(/\\n(?=\\[\\d+\\])/).map(function(p){return p.trim();}).filter(Boolean);}var parts=normalized.split(/\\n\\s*\\n+/);if(parts.length>1){return parts.map(function(p){return p.trim();}).filter(Boolean);}return[raw];}' +
'function formatReferenceSegmentForBracketCopy(segment,index){var seg=stripLeadingReferenceNumber(String(segment||"").trim());if(!seg){return"";}var availableMatch=seg.match(/^([\\s\\S]*?)(Available at:\\s*)([\\s\\S]*)$/i);if(availableMatch){var before=availableMatch[1].replace(/<br\\s*\\/?>/gi," ").replace(/\\n/g," ").replace(/\\s+/g," ").trim();var linkPart=availableMatch[3].replace(/^<br\\s*\\/?>/i,"").trim();seg=before+" Available at:<br>"+linkPart;}else{seg=seg.replace(/<br\\s*\\/?>/gi," ").replace(/\\n/g," ").replace(/\\s+/g," ").trim();}return"["+(index+1)+"] "+seg;}' +
'function htmlToPlainText(html){var node=document.createElement("div");node.innerHTML=String(html||"");return(node.textContent||node.innerText||"").replace(/\\u00a0/g," ").trim();}' +
'function formatReferenceBulkForBracketCopy(bulkHtml){var segments=splitReferenceSegments(bulkHtml);var items=segments.map(function(seg,i){return formatReferenceSegmentForBracketCopy(seg,i);}).filter(Boolean);if(!items.length){return{plain:"",html:""};}var htmlBlock=items.join("<br><br>");var plain=htmlToPlainText(htmlBlock.replace(/Available at:<br\\s*\\/?>/gi,"Available at:\\n")).trim();var htmlDoc="<!DOCTYPE html><html><head><meta charset=\\"utf-8\\"></head><body style=\\"font-family:Charis SIL,Georgia,serif;font-size:12pt\\">"+items.map(function(item){return"<p style=\\"margin:0 0 8pt;line-height:1.5\\">"+item+"</p>";}).join("")+"</body></html>";return{plain:plain,html:htmlDoc};}' +
'function copyRichTextToClipboard(plain,html){plain=String(plain||"").trim();html=String(html||"").trim();if(!plain){return Promise.reject(new Error("EMPTY"));}if(navigator.clipboard&&window.ClipboardItem&&html){var htmlBlob=new Blob([html],{type:"text/html"});var plainBlob=new Blob([plain],{type:"text/plain"});return navigator.clipboard.write([new ClipboardItem({"text/html":htmlBlob,"text/plain":plainBlob})]).catch(function(){return navigator.clipboard.writeText(plain);});}if(navigator.clipboard&&navigator.clipboard.writeText){return navigator.clipboard.writeText(plain);}var ta=document.createElement("textarea");ta.value=plain;document.body.appendChild(ta);ta.select();document.execCommand("copy");document.body.removeChild(ta);return Promise.resolve();}' +
'function serializePage(){var clone=document.documentElement.cloneNode(true);var copyBtn=clone.querySelector("#copy-bracket-btn");if(copyBtn){copyBtn.remove();}var dlBtn=clone.querySelector("#download-edited-btn");if(dlBtn){dlBtn.remove();}return"<!DOCTYPE html>"+clone.outerHTML;}' +
'document.getElementById("download-edited-btn").addEventListener("click",function(){var html=serializePage();var blob=new Blob([html],{type:"text/html;charset=utf-8"});var url=URL.createObjectURL(blob);var a=document.createElement("a");a.href=url;a.download=' +
JSON.stringify(L.downloadFileName || 'references-edited.html') +
';document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url);});' +
'document.getElementById("copy-bracket-btn").addEventListener("click",function(){var bulk=document.getElementById("ref-bulk");var result=formatReferenceBulkForBracketCopy(bulk?bulk.innerHTML:"");if(!result.plain){alert(' +
copyEmpty +
');return;}copyRichTextToClipboard(result.plain,result.html).then(function(){alert(' +
copySuccess +
');}).catch(function(){alert(' +
copyFail +
');});});' +
'})();'
);
}
/** 将上传/粘贴的参考文献 HTML 条目转为引文相关性核查用的 ref 列表 */
export function referencesFromUploadedHtmlItems(items) {
return (items || [])
.map(function (item) {
const html = item && (item.html || item.refer_frag);
if (!html) {
return null;
}
return {
refer_frag: html,
refer_type: item.refer_type
};
})
.filter(Boolean);
}
/** 将整段参考文献内容拆分为 Word 导出条目 */
export function parseReferencesBulkContent(content) {
const raw = String(content || '').trim();
@@ -433,6 +539,10 @@ export function buildReferencesHtmlLabels(translate) {
saveTip: t('commonTable.refHtmlSaveTip'),
downloadEdited: t('commonTable.refHtmlDownloadEdited'),
downloadFileName: t('commonTable.refHtmlDownloadFileName'),
copyBracket: t('commonTable.refHtmlCopyBracket'),
copyBracketSuccess: t('commonTable.refHtmlCopyBracketSuccess'),
copyBracketEmpty: t('commonTable.refHtmlCopyBracketEmpty'),
copyBracketFail: t('commonTable.refHtmlCopyBracketFail'),
bulkHint: t('commonTable.refHtmlBulkHint')
};
}

View File

@@ -0,0 +1,177 @@
import { expandCitationBracket } from '@/utils/manuscriptCitationRelevance';
import {
dedupeRowsByParaCite,
flattenProgressRecords
} from '@/utils/manuscriptReferenceAnnotationReport';
const CITATION_BRACKET_RE = /(?:<blue>\s*)?\[([^\]]+)\](?:\s*<\/blue>)?/gi;
function bracketContainsCiteNum(inner, citeNum) {
const n = Number(citeNum);
if (isNaN(n)) {
return false;
}
return expandCitationBracket(inner).indexOf(n) >= 0;
}
/** 在段落 HTML 中定位第 N 次出现的引用标号括号0-based occurrence */
export function findCiteMatchInHtml(html, citeNum, targetOccurrence) {
const raw = String(html || '');
if (!raw) {
return null;
}
const re = new RegExp(CITATION_BRACKET_RE.source, 'gi');
let occurrence = 0;
let match = null;
while ((match = re.exec(raw)) !== null) {
if (bracketContainsCiteNum(match[1], citeNum)) {
if (occurrence === targetOccurrence) {
return { index: match.index, len: match[0].length };
}
occurrence += 1;
}
}
return null;
}
/** Word 批注正文:无中英文标签,各段之间空一行 */
export function buildRelevanceCommentText(row) {
const parts = [];
const reason = String(row.reason || row.combinedReason || '').trim();
if (reason) {
parts.push(reason);
}
const authorComment = String(row.authorComment || '').trim();
if (authorComment) {
parts.push(authorComment);
}
return parts.join('\n\n');
}
function resolveWordItemAmKey(item) {
if (!item) {
return '';
}
if (item.am_id != null && item.am_id !== '') {
return String(item.am_id);
}
if (item.p_main_id != null && item.p_main_id !== '') {
return String(item.p_main_id);
}
return '';
}
function findWordItemByAmKey(wordList, amKey) {
if (!amKey) {
return null;
}
return (wordList || []).find(function (item) {
return resolveWordItemAmKey(item) === amKey;
});
}
/**
* 从 referenceCheckProgressAI 进度构建 Word 批注定义,以及按 am_id 分组的正文引用批注锚点。
* 仅包含需修改项(弱相关/不相关,对应分析报告 HTML 中的黄/红标记)。
*/
export function buildReferenceRelevanceWordCommentAnnotations(wordList, progressList, labels, existingDefinitions) {
const flatRecords = flattenProgressRecords(progressList);
const issueRows = dedupeRowsByParaCite(flatRecords).filter(function (row) {
return row && row.needModify;
});
const definitions = [];
const commentsByAmId = {};
let nextId = 1;
(existingDefinitions || []).forEach(function (def) {
if (def && Number.isFinite(def.id)) {
nextId = Math.max(nextId, def.id + 1);
}
});
issueRows.forEach(function (row) {
const amKey = row.amId != null ? String(row.amId) : '';
if (!amKey) {
return;
}
const item = findWordItemByAmKey(wordList, amKey);
if (!item || item.type == 1 || item.type == 2) {
return;
}
const html = String(item.content || item.text || '');
if (!html.trim()) {
return;
}
const citeOccurrence = Math.max(0, (row.citeIndex != null ? Number(row.citeIndex) : 1) - 1);
const hit = findCiteMatchInHtml(html, row.refNo, citeOccurrence);
if (!hit) {
return;
}
const commentText = buildRelevanceCommentText(row);
if (!commentText) {
return;
}
const commentId = nextId;
nextId += 1;
definitions.push({
id: commentId,
author: 'Editor',
initials: 'Ed',
date: new Date(),
text: commentText
});
if (!commentsByAmId[amKey]) {
commentsByAmId[amKey] = [];
}
commentsByAmId[amKey].push({
refNo: row.refNo,
occurrence: citeOccurrence,
commentId: commentId
});
});
return {
definitions: definitions,
commentsByAmId: commentsByAmId
};
}
export function mergeWordCommentDefinitions(baseDefinitions, extraDefinitions) {
const base = Array.isArray(baseDefinitions) ? baseDefinitions.slice() : [];
const extra = Array.isArray(extraDefinitions) ? extraDefinitions : [];
if (!extra.length) {
return base;
}
let maxId = -1;
base.forEach(function (def) {
if (def && Number.isFinite(def.id)) {
maxId = Math.max(maxId, def.id);
}
});
extra.forEach(function (def) {
if (!def) {
return;
}
const copy = Object.assign({}, def);
if (!Number.isFinite(copy.id) || base.some(function (existing) {
return existing && existing.id === copy.id;
})) {
maxId += 1;
copy.id = maxId;
} else {
maxId = Math.max(maxId, copy.id);
}
base.push(copy);
});
return base;
}

View File

@@ -0,0 +1,431 @@
import { htmlToPlainText } from '@/utils/manuscriptCitationRelevance';
import mammoth from 'mammoth';
import {
importWordDocumentWithMath,
isReferencesHeadingText,
isBibliographyStartParagraph,
annotateOrderedListItemsInHtml
} from '@/utils/wordMathImport';
function normalizePlain(text) {
return String(text || '')
.replace(/\u00a0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function collectParagraphLikeNodes(root) {
const nodes = [];
const walk = function (parent) {
Array.from(parent.childNodes || []).forEach(function (node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return;
const tag = node.tagName.toLowerCase();
if (tag === 'p' || /^h[1-6]$/.test(tag)) {
nodes.push(node);
return;
}
if (tag === 'li') {
nodes.push(node);
return;
}
if (tag === 'table') {
nodes.push(node);
return;
}
if (tag === 'div') {
if (node.classList && node.classList.contains('wordTableHtml')) {
nodes.push(node);
return;
}
walk(node);
}
});
};
walk(root);
return nodes;
}
function findReferencesStartInNodes(nodes) {
const list = nodes || [];
for (let i = 0; i < list.length; i++) {
const plain = normalizePlain(list[i].textContent);
if (isReferencesHeadingText(plain)) {
return i;
}
}
// 纯参考文献 Word如 reference.docx全文为编号条目无 References 标题
if (isReferenceOnlyWordNodes(list)) {
return 0;
}
const startScan = Math.max(0, Math.floor(list.length * 0.25));
for (let j = startScan; j < list.length; j++) {
const plain = normalizePlain(list[j].textContent);
if (isBibliographyStartParagraph(plain)) {
return j;
}
}
return -1;
}
function nodeLooksLikeReferenceEntry(node, plain) {
if (!plain) {
return false;
}
const listNum = node.getAttribute && node.getAttribute('data-list-num');
const isRefItem = node.classList && node.classList.contains('word-ref-item');
return !!(listNum || isRefItem || isBibliographyStartParagraph(plain));
}
/** 判断是否整篇 Word 仅含参考文献(校对后单独导出的 reference.docx */
function isReferenceOnlyWordNodes(nodes) {
const list = (nodes || []).filter(function (node) {
return normalizePlain(node.textContent).length > 0;
});
if (list.length < 2) {
return false;
}
const firstPlain = normalizePlain(list[0].textContent);
if (!nodeLooksLikeReferenceEntry(list[0], firstPlain)) {
return false;
}
let refCount = 0;
for (let i = 0; i < list.length; i++) {
const plain = normalizePlain(list[i].textContent);
if (nodeLooksLikeReferenceEntry(list[i], plain)) {
refCount += 1;
}
}
return refCount >= Math.max(2, Math.ceil(list.length * 0.6));
}
function nodeToReferenceBulkSegment(node) {
if (!node) return '';
const tag = String(node.tagName || '').toLowerCase();
const listNum = node.getAttribute && node.getAttribute('data-list-num');
const listAttr = listNum ? ' data-list-num="' + listNum + '"' : '';
if (tag === 'li') {
return '<p' + listAttr + '>' + (node.innerHTML || '') + '</p>';
}
if (tag === 'p' || /^h[1-6]$/.test(tag)) {
if (listNum) {
return '<p' + listAttr + '>' + (node.innerHTML || '') + '</p>';
}
return node.outerHTML || '<p>' + (node.innerHTML || '') + '</p>';
}
return '<p>' + (node.innerHTML || node.textContent || '') + '</p>';
}
function stripLeadingReferenceNumber(text) {
return String(text || '')
.replace(/^\s*(?:<[^>]+>\s*)*\[\d+\]\s*/i, '')
.replace(/^\s*(?:<[^>]+>\s*)*\d+\.\s*/i, '')
.trim();
}
function parseLeadingReferenceNumber(segment) {
const raw = String(segment || '').trim();
if (!raw) return '';
let match = raw.match(/^\s*(?:<[^>]+>\s*)*\[(\d+)\]\s*/i);
if (match) return match[1];
match = raw.match(/^\s*(?:<[^>]+>\s*)*(\d+)\.\s*/i);
if (match) return match[1];
const plain = normalizePlain(htmlToPlainText(raw));
match = plain.match(/^\[(\d+)\]/);
if (match) return match[1];
match = plain.match(/^(\d+)\.\s*/);
if (match) return match[1];
return '';
}
function normalizeReferenceSegmentAvailableAt(seg) {
const availableMatch = seg.match(/^([\s\S]*?)(Available at:\s*)([\s\S]*)$/i);
if (availableMatch) {
const before = availableMatch[1]
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const linkPart = availableMatch[3].replace(/^<br\s*\/?>/i, '').trim();
return before + ' Available at:<br>' + linkPart;
}
return seg.replace(/<br\s*\/?>/gi, ' ').replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
}
function formatReferenceSegmentWithOriginalNumber(segment, listNumHint) {
const rawSegment = typeof segment === 'string' ? segment : String((segment && segment.html) || '');
const num =
listNumHint ||
(typeof segment === 'object' && segment && segment.listNum) ||
parseLeadingReferenceNumber(rawSegment);
let seg = stripLeadingReferenceNumber(rawSegment.trim());
if (!seg) return '';
seg = normalizeReferenceSegmentAvailableAt(seg);
if (num) {
return '[' + num + '] ' + seg;
}
return seg;
}
function splitReferenceBodySegments(bulkHtml) {
const container = document.createElement('div');
container.innerHTML = bulkHtml;
const blocks = [];
container.querySelectorAll('p, li').forEach(function (node) {
const html = (node.innerHTML || '').trim();
if (!html) return;
blocks.push({
html: html,
listNum: node.getAttribute('data-list-num') || ''
});
});
if (blocks.length) return blocks;
const normalized = bulkHtml.replace(/<br\s*\/?>/gi, '\n');
if (/\n(?=\d+\.\s)/.test(normalized)) {
return normalized
.split(/\n(?=\d+\.\s)/)
.map(function (part) {
return { html: part.trim(), listNum: parseLeadingReferenceNumber(part.trim()) };
})
.filter(function (item) {
return item.html;
});
}
if (/\n(?=\[\d+\])/.test(normalized)) {
return normalized
.split(/\n(?=\[\d+\])/)
.map(function (part) {
return { html: part.trim(), listNum: parseLeadingReferenceNumber(part.trim()) };
})
.filter(function (item) {
return item.html;
});
}
return bulkHtml.trim() ? [{ html: bulkHtml.trim(), listNum: '' }] : [];
}
function formatExtractedReferences(nodes, startIdx) {
const sectionNodes = nodes.slice(startIdx);
if (!sectionNodes.length) {
return { html: '', plainText: '', count: 0 };
}
let bodyStart = 0;
const firstPlain = normalizePlain(sectionNodes[0].textContent);
if (isReferencesHeadingText(firstPlain)) {
bodyStart = 1;
}
const headingHtml = bodyStart > 0 ? nodeToReferenceBulkSegment(sectionNodes[0]) : '';
const bulkHtml = sectionNodes
.slice(bodyStart)
.map(nodeToReferenceBulkSegment)
.join('');
if (!bulkHtml.trim()) {
const headingPlain = headingHtml ? htmlToPlainText(headingHtml).trim() : '';
return {
html: headingHtml,
plainText: headingPlain,
count: 0
};
}
const items = splitReferenceBodySegments(bulkHtml)
.map(function (block) {
return formatReferenceSegmentWithOriginalNumber(block.html, block.listNum);
})
.filter(Boolean);
const bodyHtml = items
.map(function (item) {
return '<p class="word-ref-p">' + item + '</p>';
})
.join('');
const headingPlain = headingHtml ? htmlToPlainText(headingHtml).trim() : '';
const plainParts = [];
if (headingPlain) plainParts.push(headingPlain);
if (items.length) {
plainParts.push(
items
.map(function (item) {
return htmlToPlainText(item).replace(/\s+/g, ' ').trim();
})
.filter(Boolean)
.join('\n\n')
);
}
return {
html: headingHtml + bodyHtml,
plainText: plainParts.join('\n\n'),
count: items.length
};
}
/** 从 Word 导入 HTML 中提取 References 段落(含标题) */
export function extractReferencesSectionFromWordHtml(html) {
if (!html || typeof document === 'undefined') {
return { found: false, html: '', plainText: '', count: 0, startIndex: -1, manuscriptUrl: '' };
}
html = annotateOrderedListItemsInHtml(html);
const container = document.createElement('div');
container.innerHTML = html;
const nodes = collectParagraphLikeNodes(container);
const startIdx = findReferencesStartInNodes(nodes);
if (startIdx < 0) {
return { found: false, html: '', plainText: '', count: 0, startIndex: -1, manuscriptUrl: '' };
}
const formatted = formatExtractedReferences(nodes, startIdx);
return {
found: true,
html: formatted.html,
plainText: formatted.plainText,
count: formatted.count || 0,
startIndex: startIdx,
manuscriptUrl: ''
};
}
export function resolveManuscriptFileUrl(path, mediaUrl, baseUrl) {
const raw = String(path || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) {
return raw;
}
const media = String(mediaUrl || '');
if (media) {
return media + raw.replace(/^\/+/, '');
}
const normalized = raw.replace(/^\/+/, '');
if (normalized.indexOf('public/') === 0) {
const root = String(baseUrl || '').replace(/\/$/, '');
return root ? root + '/' + normalized : '/' + normalized;
}
const root = String(baseUrl || '').replace(/\/$/, '');
return root ? root + '/public/' + normalized : '/public/' + normalized;
}
/** fetch 下载用同源路径,避免 mediaUrl 跨域 */
export function resolveManuscriptFetchUrl(path, mediaUrl) {
const raw = String(path || '').trim();
if (!raw) return '';
let relative = raw;
if (/^https?:\/\//i.test(raw)) {
const media = String(mediaUrl || '');
if (media && raw.indexOf(media) === 0) {
relative = raw.slice(media.length);
} else {
const publicIdx = raw.indexOf('/public/');
if (publicIdx >= 0) {
relative = raw.slice(publicIdx + '/public/'.length);
} else {
return raw;
}
}
}
relative = relative.replace(/^\/+/, '');
if (relative.indexOf('public/') === 0) {
relative = relative.slice('public/'.length);
}
return '/public/' + relative;
}
async function downloadManuscriptDocx(manuscriptPath, mediaUrl, baseUrl) {
const fileUrl = resolveManuscriptFetchUrl(manuscriptPath, mediaUrl);
const displayUrl = resolveManuscriptFileUrl(manuscriptPath, mediaUrl, baseUrl);
if (!fileUrl) {
throw new Error('NO_MANUSCRIPT_URL');
}
let response;
try {
response = await fetch(fileUrl);
} catch (error) {
const err = new Error('FETCH_DOCX_CORS');
err.cause = error;
err.fileUrl = fileUrl;
throw err;
}
if (!response.ok) {
const err = new Error('FETCH_DOCX_FAILED');
err.status = response.status;
err.fileUrl = fileUrl;
throw err;
}
const blob = await response.blob();
const fileName = manuscriptPath.split('/').pop() || 'manuscript.docx';
return {
file: new File([blob], fileName, {
type: blob.type || 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
}),
fileUrl: displayUrl || fileUrl
};
}
async function fetchArticleManuscriptPath(apiClient, articleId) {
const res = await apiClient.post('api/Article/getArticleDetail', {
articleId: articleId,
human: 'editor'
});
const article = (res && res.article) || {};
return String(article.manuscript_url || '').trim();
}
async function importWordHtmlWithMammoth(file) {
if (!file || !file.arrayBuffer) return '';
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer });
return result.value || '';
}
/**
* 从 Word 稿件识别并提取 References。
* 优先使用 options.manuscriptPath / gridData如 getFilesForArticle 的 file_url
* 否则回退 getArticleDetail.manuscript_url。
*/
export async function fetchWordReferencesSection(apiClient, options) {
const opts = options || {};
let manuscriptPath = String(opts.manuscriptPath || opts.gridData || opts.fileUrl || '').trim();
const useGridDataOnly = !!opts.useGridDataOnly;
if (!manuscriptPath) {
if (useGridDataOnly || !apiClient || opts.articleId == null || opts.articleId === '') {
const err = new Error('NO_MANUSCRIPT_FILE');
throw err;
}
manuscriptPath = await fetchArticleManuscriptPath(apiClient, opts.articleId);
}
if (!manuscriptPath) {
const err = new Error('NO_MANUSCRIPT_FILE');
throw err;
}
const downloaded = await downloadManuscriptDocx(manuscriptPath, opts.mediaUrl, opts.baseUrl);
let wordHtml = await importWordDocumentWithMath(downloaded.file, { keepReferences: true });
let extracted = extractReferencesSectionFromWordHtml(wordHtml);
if (!extracted.found) {
const mammothHtml = await importWordHtmlWithMammoth(downloaded.file);
extracted = extractReferencesSectionFromWordHtml(mammothHtml);
}
extracted.manuscriptUrl = downloaded.fileUrl;
return extracted;
}

View File

@@ -0,0 +1,542 @@
import { htmlToPlainText } from '@/utils/manuscriptCitationRelevance';
import { parseBookReferenceContent } from '@/utils/parseBookReference';
/** 导入时去掉 (Chinese) 并规整空格 */
export function normalizeImportedReferenceText(text) {
return String(text || '')
.replace(/\(\s*Chinese\s*\)/gi, '')
.replace(/\u00a0/g, ' ')
.replace(/\s+\./g, '.')
.replace(/\s+/g, ' ')
.trim();
}
function sanitizeImportedReferenceHtml(html) {
return String(html || '')
.replace(/\(\s*Chinese\s*\)/gi, '')
.replace(/\(\s*Chinese\s*\)\s*\./gi, '.')
.replace(/\u00a0/g, ' ')
.replace(/\s+\./g, '.')
.replace(/\s{2,}/g, ' ')
.trim();
}
export function normalizeImportedReferenceFields(fields) {
if (!fields) return fields;
const keys = ['author', 'title', 'joura', 'dateno', 'doilink', 'doi', 'isbn', 'content'];
const out = Object.assign({}, fields);
keys.forEach(function (key) {
if (out[key] != null && out[key] !== '') {
out[key] = normalizeImportedReferenceText(out[key]);
}
});
return swapMisassignedYearAndSource(out);
}
/** 年份误写入 joura、来源误写入 dateno 时纠正web/news 引用常见) */
function swapMisassignedYearAndSource(fields) {
if (!fields) return fields;
const joura = String(fields.joura || '').trim();
const dateno = String(fields.dateno || '').trim();
if (
looksLikePlainYear(joura) &&
dateno &&
!looksLikePlainYear(dateno) &&
!looksLikeDateno(dateno)
) {
return Object.assign({}, fields, {
joura: dateno,
dateno: normalizePlainYearDateno(joura)
});
}
return fields;
}
function splitAtFirstPeriod(text) {
const raw = String(text || '').trim();
if (!raw) return { before: '', after: '' };
const match = raw.match(/\s*\.\s+/);
if (!match || match.index == null) {
return { before: raw, after: '' };
}
return {
before: raw.slice(0, match.index).trim(),
after: raw.slice(match.index + match[0].length).trim()
};
}
function withAuthorPeriod(name) {
const t = String(name || '').trim();
if (!t) return '';
return t.endsWith('.') ? t : t + '.';
}
function trimField(text) {
return normalizeImportedReferenceText(
String(text || '')
.replace(/\s+\.\s*$/, '')
.replace(/\.\s*$/, '')
);
}
function stripLeadingReferenceNumber(text) {
return String(text || '')
.replace(/^\s*(?:<[^>]+>\s*)*\[\d+\]\s*/i, '')
.replace(/^\s*(?:<[^>]+>\s*)*\d+\.\s*/i, '')
.trim();
}
function fixMissingSpaceAfterPeriod(plain) {
return String(plain || '').replace(/([A-Za-z)\]])\.([A-Za-z])/g, '$1. $2');
}
function looksLikeDateno(text) {
const value = String(text || '').trim();
return /^\d{4}[;:]\d+(?:\([^)]*\))?(?::[\w\-—eE.]+(?:[\-–—][\w\-—eE.]+)?)?\.?$/.test(value);
}
function looksLikePlainYear(text) {
return /^\d{4}\.?$/.test(String(text || '').trim());
}
function normalizePlainYearDateno(text) {
return String(text || '').trim().replace(/\.$/, '');
}
/** 提取末尾纯年份(如 web/news 引用Source. 2025. */
function extractPlainYearFromTail(plain) {
const text = String(plain || '').trim();
const match = text.match(/(?:^|\.\s+)(\d{4})\.\s*$/);
if (!match) {
return { body: text, dateno: '' };
}
const year = match[1];
let body = text.slice(0, match.index).trim().replace(/\.\s*$/, '');
return {
body: body,
dateno: normalizePlainYearDateno(year)
};
}
function normalizeDatenoField(dateno) {
return normalizeImportedReferenceText(String(dateno || '').trim()).replace(/^(\d{4}):/, '$1;');
}
function extractDoilink(html) {
const raw = String(html || '');
let body = raw.trim();
let doilink = '';
const availMatch = raw.match(/Available\s+at:\s*([\s\S]*)$/i);
if (availMatch) {
const tail = availMatch[1];
const anchorMatch = tail.match(/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>/i);
doilink = anchorMatch
? anchorMatch[1].trim()
: htmlToPlainText(tail).replace(/\s+/g, ' ').trim();
body = raw.slice(0, availMatch.index).trim();
} else {
const anchorMatch = raw.match(/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>/i);
if (anchorMatch) {
doilink = anchorMatch[1].trim();
body = raw.replace(anchorMatch[0], '').trim();
} else {
const urlMatch = raw.match(/(https?:\/\/(?:dx\.)?doi\.org\/[^\s<]+|10\.\d{4,9}\/[^\s<]+)\s*$/i);
if (urlMatch) {
doilink = htmlToPlainText(urlMatch[1]).replace(/\s+/g, ' ').trim();
body = raw.slice(0, urlMatch.index).trim();
}
}
}
if (doilink && !/^https?:\/\//i.test(doilink)) {
doilink = 'https://doi.org/' + doilink.replace(/^https?:\/\/doi\.org\//i, '');
}
return { body: body, doilink: doilink };
}
function mergeAdjacentItalicHtml(html) {
let result = String(html || '');
let prev = '';
while (prev !== result) {
prev = result;
result = result.replace(/<\/(?:i|em)>\s*<(?:i|em)(?:\s[^>]*)?>/gi, '');
}
return result;
}
function extractAllItalicTexts(html) {
const merged = mergeAdjacentItalicHtml(html);
const texts = [];
const re = /<(?:i|em)\b[^>]*>([\s\S]*?)<\/(?:i|em)>/gi;
let match;
while ((match = re.exec(merged)) !== null) {
const text = htmlToPlainText(match[1]).replace(/\s+/g, ' ').trim();
if (text) {
texts.push({ text: text, index: match.index, length: match[0].length });
}
}
return { merged: merged, texts: texts };
}
function cleanJournalName(name) {
return trimField(String(name || ''))
.replace(/\s*\.?\s*-+\.?\s*$/g, '')
.replace(/\s*\.\s*$/g, '')
.trim();
}
function extractJournalFromPlainBeforeDateno(plain) {
const text = String(plain || '').trim();
const patterns = [
/\.\s*([^.\d][^.]*?)\.\s*(\d{4}[;:]\d+(?:\([^)]*\))?:)/,
/\.\s*([^.\d][^.]+?)\s+(\d{4}[;:]\d+(?:\([^)]*\))?:)/,
/\s([A-Za-z][A-Za-z0-9 .&'-]{1,80})\.\s*(\d{4}[;:]\d+(?:\([^)]*\))?:)/,
/\.\s*([^.\d][^.]*?)\.\s*(\d{4}[;:]\d+(?:\([^)]*\))?)\.?\s*$/,
/\s([A-Za-z][A-Za-z0-9 .&'-]{1,80})\.\s*(\d{4}[;:]\d+(?:\([^)]*\))?)\.?\s*$/
];
for (let i = 0; i < patterns.length; i++) {
const match = text.match(patterns[i]);
if (match && match[1] && !looksLikeDateno(match[1])) {
return cleanJournalName(match[1]);
}
}
return '';
}
function extractJouraFromHead(head) {
const parts = String(head || '')
.split(/\.\s+/)
.map(function (part) {
return part.trim();
})
.filter(Boolean);
if (parts.length < 2) {
return { joura: '', rest: String(head || '').trim(), plainYear: '' };
}
while (parts.length && looksLikeDateno(parts[parts.length - 1])) {
parts.pop();
}
let plainYear = '';
if (parts.length && looksLikePlainYear(parts[parts.length - 1])) {
plainYear = normalizePlainYearDateno(parts.pop());
}
if (parts.length < 2) {
return { joura: '', rest: parts.join('. '), plainYear: plainYear };
}
let joura = cleanJournalName(parts.pop());
if (!joura || looksLikeDateno(joura) || looksLikePlainYear(joura)) {
if (joura && looksLikePlainYear(joura) && !plainYear) {
plainYear = normalizePlainYearDateno(joura);
}
return { joura: '', rest: parts.join('. '), plainYear: plainYear };
}
return { joura: joura, rest: parts.join('. '), plainYear: plainYear };
}
function extractItalicJournal(html) {
const raw = String(html || '');
const italicInfo = extractAllItalicTexts(raw);
if (!italicInfo.texts.length) {
return { body: raw.trim(), joura: '' };
}
const plainForPos = htmlToPlainText(italicInfo.merged).replace(/\s+/g, ' ').trim();
const datenoMatch = plainForPos.match(/\d{4}[;:]\d+(?:\([^)]*\))?:/);
const datenoPos = datenoMatch ? datenoMatch.index : plainForPos.length;
let joura = '';
for (let i = italicInfo.texts.length - 1; i >= 0; i--) {
const item = italicInfo.texts[i];
const pos = htmlToPlainText(italicInfo.merged.slice(0, item.index)).replace(/\s+/g, ' ').length;
if (pos <= datenoPos && item.text.length <= 120) {
joura = item.text;
break;
}
}
if (!joura) {
joura = italicInfo.texts[italicInfo.texts.length - 1].text;
}
joura = cleanJournalName(joura);
const body = italicInfo.merged
.replace(/<(?:i|em)\b[^>]*>([\s\S]*?)<\/(?:i|em)>/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
return { body: body, joura: joura };
}
function extractDateno(plain) {
const text = String(plain || '').trim();
const patterns = [
/(\d{4}[;:]\d+(?:\([^)]*\))?:[\w\-—eE.]+(?:[\-–—][\w\-—eE.]+)?)\.?\s*$/,
/(\d{4}[;:]\d+(?:\([^)]*\))?:[\w\-—eE]+)\.?\s*$/,
/(\d{4}[;:]\d+(?:\([^)]*\))?)\.?\s*$/
];
for (let i = 0; i < patterns.length; i++) {
const match = text.match(patterns[i]);
if (match) {
return {
body: text.slice(0, match.index).trim().replace(/\.\s*$/, ''),
dateno: normalizeDatenoField(match[1])
};
}
}
return { body: text, dateno: '' };
}
function parseJournalReferenceContent(content) {
let html = stripLeadingReferenceNumber(String(content || '').trim());
if (!html) return null;
const linkPart = extractDoilink(html);
html = linkPart.body;
const italicPart = extractItalicJournal(html);
html = italicPart.body;
let plain = fixMissingSpaceAfterPeriod(normalizeImportedReferenceText(htmlToPlainText(html)));
plain = plain.replace(/\.\s*$/, '');
const datenoPart = extractDateno(plain);
let dateno = datenoPart.dateno;
let head = datenoPart.body;
if (!dateno) {
const plainYearPart = extractPlainYearFromTail(head || plain);
if (plainYearPart.dateno) {
dateno = plainYearPart.dateno;
head = plainYearPart.body;
}
}
let joura = cleanJournalName(italicPart.joura || '');
if (looksLikeDateno(joura) || looksLikePlainYear(joura)) {
if (looksLikePlainYear(joura) && !dateno) {
dateno = normalizePlainYearDateno(joura);
}
joura = '';
}
if (!joura) {
joura = extractJournalFromPlainBeforeDateno(head || plain);
}
const jouraFromHead = extractJouraFromHead(head);
if (!joura && jouraFromHead.joura) {
joura = jouraFromHead.joura;
head = jouraFromHead.rest;
} else if (joura && head) {
const jouraEsc = joura.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
head = head
.replace(new RegExp('\\.\s*' + jouraEsc + '\.?\s*$', 'i'), '')
.replace(/\s*\.?\s*-+\.?\s*$/g, '')
.trim();
}
if (!dateno && jouraFromHead.plainYear) {
dateno = jouraFromHead.plainYear;
}
if (looksLikeDateno(joura) && !dateno) {
dateno = normalizeDatenoField(joura);
const recovered = extractJouraFromHead(head);
joura = extractJournalFromPlainBeforeDateno(head) || recovered.joura;
head = recovered.rest;
if (!dateno && recovered.plainYear) {
dateno = recovered.plainYear;
}
}
if (!dateno) {
const trailingYear = extractPlainYearFromTail(head);
if (trailingYear.dateno) {
dateno = trailingYear.dateno;
head = trailingYear.body;
}
}
joura = cleanJournalName(joura);
const authorSplit = splitAtFirstPeriod(head);
const author = withAuthorPeriod(authorSplit.before);
const title = trimField(authorSplit.after);
if (!author && !title && !joura && !dateno && !linkPart.doilink) {
return null;
}
return {
refer_type: 'journal',
author,
title,
joura,
dateno: dateno,
doilink: linkPart.doilink,
doi: linkPart.doilink ? linkPart.doilink.replace(/^https?:\/\/doi\.org\//i, '').toLowerCase() : '',
isbn: '',
content: ''
};
}
function hasReferenceDoiLink(fields, rawHtml) {
const link = String((fields && (fields.doilink || fields.doi)) || '').trim();
if (link) {
if (/^https?:\/\/(?:dx\.)?doi\.org\//i.test(link)) {
return true;
}
if (/^doi:/i.test(link)) {
return true;
}
if (/^10\.\d{4,9}\//i.test(link)) {
return true;
}
}
return /doi\.org\/10\.\d{4,9}\//i.test(String(rawHtml || ''));
}
function hasReferenceIsbn(fields, rawHtml) {
const isbn = String((fields && fields.isbn) || '').trim();
if (isbn) {
return true;
}
return /ISBN\s*:/i.test(String(rawHtml || ''));
}
function buildReferenceContentFromPartialFields(fields) {
const parts = [];
if (fields && fields.author) {
parts.push(String(fields.author).trim());
}
if (fields && fields.title) {
parts.push(String(fields.title).trim());
}
if (fields && fields.joura) {
parts.push(String(fields.joura).trim());
}
if (fields && fields.dateno) {
parts.push(String(fields.dateno).trim());
}
let content = parts
.filter(Boolean)
.join(' ')
.replace(/\s+\./g, '.')
.replace(/\s+/g, ' ')
.trim();
const link = String((fields && fields.doilink) || '').trim();
if (link && !/Available at:/i.test(content)) {
content = content + '. Available at: ' + link;
}
return normalizeImportedReferenceText(content);
}
/** 无 DOI 且无 ISBN 的条目归为 other有 DOI 为 journal有 ISBN 为 book */
export function finalizeReferenceFields(fields, html) {
const base = Object.assign({}, fields || {});
const rawHtml = String(html || '').trim();
const plainFromHtml = normalizeImportedReferenceText(htmlToPlainText(rawHtml));
if (hasReferenceIsbn(base, rawHtml)) {
return normalizeImportedReferenceFields(
Object.assign({}, base, {
refer_type: 'book'
})
);
}
if (hasReferenceDoiLink(base, rawHtml)) {
return normalizeImportedReferenceFields(
Object.assign({}, base, {
refer_type: 'journal'
})
);
}
const content =
base.content ||
plainFromHtml ||
buildReferenceContentFromPartialFields(base);
return normalizeImportedReferenceFields({
refer_type: 'other',
author: '',
title: '',
joura: '',
dateno: '',
doilink: base.doilink || '',
doi: base.doi || '',
isbn: '',
content: content
});
}
/** 将单条参考文献 HTML/文本拆解为结构化字段 */
export function parseReferenceFieldsFromHtml(html, referTypeHint) {
const raw = sanitizeImportedReferenceHtml(stripLeadingReferenceNumber(String(html || '').trim()));
if (!raw) return null;
const hinted = String(referTypeHint || '').toLowerCase();
if (hinted === 'book' || /ISBN\s*:/i.test(raw)) {
const plain = normalizeImportedReferenceText(htmlToPlainText(raw));
const parsed = parseBookReferenceContent(plain);
if (parsed && parsed.isbn) {
return finalizeReferenceFields(
{
refer_type: 'book',
author: parsed.author || '',
title: parsed.title || '',
joura: '',
dateno: parsed.dateno || '',
doilink: '',
doi: '',
isbn: parsed.isbn || '',
content: ''
},
raw
);
}
}
const journalParsed = parseJournalReferenceContent(raw);
if (journalParsed) {
return finalizeReferenceFields(journalParsed, raw);
}
return finalizeReferenceFields(
{
refer_type: hinted || 'other',
author: '',
title: '',
joura: '',
dateno: '',
doilink: '',
doi: '',
isbn: '',
content: normalizeImportedReferenceText(htmlToPlainText(raw))
},
raw
);
}
export function applyDealContentToFields(fields, dealData) {
const base = fields || {};
const data = dealData || {};
if (!data || typeof data !== 'object') return normalizeImportedReferenceFields(base);
return normalizeImportedReferenceFields({
refer_type: base.refer_type || 'journal',
author: data.author || base.author || '',
title: data.title || base.title || '',
joura: data.joura || base.joura || '',
dateno: data.dateno || base.dateno || '',
doilink: data.doilink || data.doi || base.doilink || '',
doi: data.doi || data.doilink || base.doi || '',
isbn: data.isbn || base.isbn || '',
content: base.content || ''
});
}

View File

@@ -107,6 +107,238 @@ function allByLocal(parent, name) {
return Array.from(parent.children || []).filter((child) => localName(child) === target);
}
function getWordAttrVal(el) {
if (!el) return '';
return el.getAttribute('w:val') || el.getAttribute('val') || '';
}
function getParagraphNumPr(p) {
const pPr = firstByLocal(p, 'pPr');
if (pPr) {
const numPr = firstByLocal(pPr, 'numPr');
if (numPr) return numPr;
}
return firstByLocal(p, 'numPr');
}
function toRomanNumber(num) {
const n = parseInt(num, 10);
if (!Number.isFinite(n) || n <= 0) return String(num);
const pairs = [
[1000, 'M'],
[900, 'CM'],
[500, 'D'],
[400, 'CD'],
[100, 'C'],
[90, 'XC'],
[50, 'L'],
[40, 'XL'],
[10, 'X'],
[9, 'IX'],
[5, 'V'],
[4, 'IV'],
[1, 'I']
];
let rest = n;
let out = '';
pairs.forEach(([val, sym]) => {
while (rest >= val) {
out += sym;
rest -= val;
}
});
return out;
}
function formatNumberByNumFmt(n, numFmt) {
const num = parseInt(n, 10);
if (!Number.isFinite(num)) return String(n);
switch (String(numFmt || 'decimal').toLowerCase()) {
case 'decimal':
case 'decimalzero':
return String(num);
case 'lowerletter':
return String.fromCharCode(97 + ((num - 1) % 26));
case 'upperletter':
return String.fromCharCode(65 + ((num - 1) % 26));
case 'lowerroman':
return toRomanNumber(num).toLowerCase();
case 'upperroman':
return toRomanNumber(num);
case 'bullet':
return '•';
default:
return String(num);
}
}
function parseWordNumberingXml(numberingXml) {
const abstractNums = {};
const nums = {};
if (!numberingXml) return { abstractNums, nums };
const doc = new DOMParser().parseFromString(numberingXml, 'application/xml');
const root = doc.documentElement;
if (!root) return { abstractNums, nums };
allByLocal(root, 'abstractNum').forEach((el) => {
const id = el.getAttribute('w:abstractNumId') || el.getAttribute('abstractNumId') || '';
if (!id) return;
const levels = {};
allByLocal(el, 'lvl').forEach((lvl) => {
const ilvl = lvl.getAttribute('w:ilvl') || lvl.getAttribute('ilvl') || '0';
levels[ilvl] = {
start: parseInt(getWordAttrVal(firstByLocal(lvl, 'start')) || '1', 10) || 1,
numFmt: getWordAttrVal(firstByLocal(lvl, 'numFmt')) || 'decimal',
lvlText: getWordAttrVal(firstByLocal(lvl, 'lvlText')) || `%${parseInt(ilvl, 10) + 1}.`
};
});
abstractNums[id] = { levels };
});
allByLocal(root, 'num').forEach((el) => {
const numId = el.getAttribute('w:numId') || el.getAttribute('numId') || '';
if (!numId) return;
const abstractNumId = getWordAttrVal(firstByLocal(el, 'abstractNumId'));
const overrides = {};
allByLocal(el, 'lvlOverride').forEach((ov) => {
const ilvl = ov.getAttribute('w:ilvl') || ov.getAttribute('ilvl') || '0';
overrides[ilvl] = overrides[ilvl] || {};
const startOverride = firstByLocal(ov, 'startOverride');
if (startOverride) {
overrides[ilvl].start = parseInt(getWordAttrVal(startOverride) || '1', 10) || 1;
}
const lvlEl = firstByLocal(ov, 'lvl');
if (lvlEl) {
const ovIlvl = lvlEl.getAttribute('w:ilvl') || lvlEl.getAttribute('ilvl') || ilvl;
overrides[ovIlvl] = overrides[ovIlvl] || {};
overrides[ovIlvl].lvl = {
start: parseInt(getWordAttrVal(firstByLocal(lvlEl, 'start')) || '1', 10) || 1,
numFmt: getWordAttrVal(firstByLocal(lvlEl, 'numFmt')) || 'decimal',
lvlText: getWordAttrVal(firstByLocal(lvlEl, 'lvlText')) || `%${parseInt(ovIlvl, 10) + 1}.`
};
}
});
nums[numId] = { abstractNumId, overrides };
});
return { abstractNums, nums };
}
async function loadWordNumberingDefinitions(zip) {
const numberingFile = zip.file('word/numbering.xml');
if (!numberingFile) {
return { abstractNums: {}, nums: {} };
}
const numberingXml = await numberingFile.async('string');
return parseWordNumberingXml(numberingXml);
}
function createWordListNumberTracker(definitions) {
const defs = definitions || { abstractNums: {}, nums: {} };
const states = {};
let lastListNumber = '';
function getLevelDef(numId, ilvl) {
const numDef = defs.nums[numId];
if (!numDef) return null;
const absDef = defs.abstractNums[numDef.abstractNumId];
if (!absDef) return null;
const base = absDef.levels[ilvl] || absDef.levels[String(parseInt(ilvl, 10))];
const ov = numDef.overrides && (numDef.overrides[ilvl] || numDef.overrides[String(ilvl)]);
if (ov && ov.lvl) {
return {
start: ov.start != null ? ov.start : ov.lvl.start,
numFmt: ov.lvl.numFmt,
lvlText: ov.lvl.lvlText
};
}
if (!base) return null;
return {
start: ov && ov.start != null ? ov.start : base.start,
numFmt: base.numFmt,
lvlText: base.lvlText
};
}
function ensureCounter(numId, ilvl) {
if (!states[numId]) states[numId] = { levels: {} };
const st = states[numId].levels;
if (st[ilvl] == null) {
const def = getLevelDef(numId, ilvl);
st[ilvl] = (def && def.start != null ? def.start : 1) - 1;
}
return st[ilvl];
}
function advanceCounter(numId, ilvl) {
ensureCounter(numId, ilvl);
states[numId].levels[ilvl] += 1;
Object.keys(states[numId].levels).forEach((key) => {
if (parseInt(key, 10) > parseInt(ilvl, 10)) {
delete states[numId].levels[key];
}
});
return states[numId].levels[ilvl];
}
function formatLabel(numId, ilvl, currentVal) {
const levelDef = getLevelDef(numId, ilvl);
if (!levelDef) return String(currentVal);
const lvlText = levelDef.lvlText || `%${parseInt(ilvl, 10) + 1}.`;
return lvlText.replace(/%(\d+)/g, (_, levelStr) => {
const idx = parseInt(levelStr, 10) - 1;
const idxStr = String(idx);
const counterVal = idxStr === ilvl ? currentVal : ensureCounter(numId, idxStr) + 1;
const def = getLevelDef(numId, idxStr);
return formatNumberByNumFmt(counterVal, def && def.numFmt);
});
}
return {
resolveParagraphListLabel(p) {
const numPr = getParagraphNumPr(p);
if (!numPr) {
lastListNumber = '';
return '';
}
const numId = getWordAttrVal(firstByLocal(numPr, 'numId'));
if (!numId) {
lastListNumber = '';
return '';
}
const ilvl = getWordAttrVal(firstByLocal(numPr, 'ilvl')) || '0';
const levelDef = getLevelDef(numId, ilvl);
if (levelDef && String(levelDef.numFmt).toLowerCase() === 'bullet') {
lastListNumber = '';
return levelDef.lvlText || '•';
}
const currentVal = advanceCounter(numId, ilvl);
lastListNumber = String(currentVal);
return formatLabel(numId, ilvl, currentVal);
},
getLastListNumber() {
return lastListNumber;
}
};
}
/** mammoth 等输出的 <ol><li> 补全 data-list-num便于保留原序号 */
export function annotateOrderedListItemsInHtml(html) {
if (!html || typeof document === 'undefined') return html || '';
const div = document.createElement('div');
div.innerHTML = html;
div.querySelectorAll('ol').forEach((ol) => {
let n = parseInt(ol.getAttribute('start') || '1', 10) || 1;
Array.from(ol.children || []).forEach((child) => {
if (!child || child.tagName.toLowerCase() !== 'li') return;
child.setAttribute('data-list-num', String(n));
n += 1;
});
});
return div.innerHTML;
}
/** Word OMML m:dPr 上的分隔符begChr / endChr */
function getDelimCharFromD(node, which) {
const dPr = firstByLocal(node, 'dPr');
@@ -657,7 +889,12 @@ export function isReferencesHeadingText(text) {
.replace(/\u00a0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return /^references\.?\s*$/i.test(t);
if (/^references\.?\s*$/i.test(t)) return true;
if (/^reference\.?\s*$/i.test(t)) return true;
if (/^bibliography\.?\s*$/i.test(t)) return true;
if (/^参考文献\.?\s*$/i.test(t)) return true;
if (/^(\d+[\.\)]\s*)?(references|reference|bibliography|参考文献)\.?\s*$/i.test(t)) return true;
return false;
}
/** 参考文献条目含无编号、Available at / ISBN 格式) */
@@ -1308,8 +1545,20 @@ function paragraphToHtmlFromRunsOnly(p, imageCache) {
return html.trim();
}
function mergeAdjacentItalicTagsInHtml(html) {
let result = String(html || '');
let prev = '';
while (prev !== result) {
prev = result;
result = result.replace(/<\/i>\s*<i>/gi, '');
result = result.replace(/<\/em>\s*<em>/gi, '');
}
return result;
}
function normalizeParagraphHtml(html) {
if (!html) return '';
html = mergeAdjacentItalicTagsInHtml(html);
const plain = stripHtmlToPlain(html);
const cleaned = cleanImportedPlainText(plain);
if (!cleaned) return '';
@@ -1634,7 +1883,50 @@ function tableToHtml(table, imageCache) {
return html;
}
function documentXmlToHtml(documentXml, imageCache) {
function paragraphInnerHasListPrefix(inner, prefix) {
const plainInner = String(inner || '')
.replace(/<[^>]+>/g, '')
.replace(/\u00a0/g, ' ')
.trim();
const plainPrefix = String(prefix || '')
.replace(/\u00a0/g, ' ')
.trim();
if (!plainPrefix || !plainInner) return false;
const escaped = plainPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp('^\\s*' + escaped + '(?:\\s|$)').test(plainInner);
}
function appendImportedParagraphHtml(target, p, imageCache, listTracker) {
let inner = paragraphToHtml(p, imageCache);
let listNum = '';
let listPrefix = '';
if (listTracker) {
listPrefix = listTracker.resolveParagraphListLabel(p);
if (listPrefix) {
listNum = listTracker.getLastListNumber() || '';
if (!paragraphInnerHasListPrefix(inner, listPrefix)) {
inner = escapeHtmlPlainTextForPaste(listPrefix) + (/\s$/.test(listPrefix) ? '' : ' ') + inner;
}
}
}
const isEmpty = !inner;
if (isEmpty) {
if (target.lastWasEmpty) return;
target.lastWasEmpty = true;
target.html += '<p><br></p>';
} else {
target.lastWasEmpty = false;
if (listNum) {
target.html += `<p class="word-ref-item" data-list-num="${escapeHtmlPlainTextForPaste(listNum)}">${inner}</p>`;
} else {
target.html += `<p>${inner}</p>`;
}
}
}
function documentXmlToHtml(documentXml, imageCache, options) {
const includeReferencesSection = !!(options && options.includeReferencesSection);
const listTracker = options && options.listTracker;
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(documentXml, 'application/xml');
const body =
@@ -1642,34 +1934,28 @@ function documentXmlToHtml(documentXml, imageCache) {
Array.from(xmlDoc.getElementsByTagName('*')).find((el) => localName(el) === 'body');
if (!body) return '';
let html = '';
let reachedReferences = false;
let lastWasEmpty = false;
const target = { html: '', lastWasEmpty: false };
let omitRest = false;
Array.from(body.children || []).forEach((child) => {
if (reachedReferences) return;
if (omitRest) return;
const name = localName(child);
if (name === 'p') {
if (isSectionBreakOnlyParagraph(child)) return;
if (isReferencesHeadingParagraph(child)) {
reachedReferences = true;
if (includeReferencesSection) {
appendImportedParagraphHtml(target, child, imageCache, listTracker);
return;
}
omitRest = true;
return;
}
const inner = paragraphToHtml(child, imageCache);
const isEmpty = !inner;
if (isEmpty) {
if (lastWasEmpty) return;
lastWasEmpty = true;
html += '<p><br></p>';
} else {
lastWasEmpty = false;
html += `<p>${inner}</p>`;
}
appendImportedParagraphHtml(target, child, imageCache, listTracker);
} else if (name === 'tbl') {
lastWasEmpty = false;
html += wrapImportedTableHtml(tableToHtml(child, imageCache));
target.lastWasEmpty = false;
target.html += wrapImportedTableHtml(tableToHtml(child, imageCache));
}
});
return html;
return target.html;
}
export function collapseEmptyParagraphsInHtml(html) {
@@ -1892,7 +2178,12 @@ export async function importWordDocumentWithMath(file, options = {}) {
const documentXml = await documentFile.async('string');
const imageRelMap = await buildImageRelMap(zip);
const imageCache = await buildImageDataUriCache(zip, documentXml, imageRelMap);
let html = documentXmlToHtml(documentXml, imageCache);
const numberingDefs = await loadWordNumberingDefinitions(zip);
const listTracker = createWordListNumberTracker(numberingDefs);
let html = documentXmlToHtml(documentXml, imageCache, {
includeReferencesSection: !!options.keepReferences,
listTracker
});
if (!html || !html.replace(/<[^>]+>/g, '').trim()) {
const result = await mammoth.convertToHtml(
{ arrayBuffer },
@@ -1911,6 +2202,13 @@ export async function importWordDocumentWithMath(file, options = {}) {
if (options.textOnly) {
return prepareWordHtmlForEditor(html);
}
if (options.keepReferences) {
html = annotateOrderedListItemsInHtml(html);
html = collapseEmptyParagraphsInHtml(
mergeAdjacentBlueTags(normalizeSpacesAroundBlueTags(convertStyledBlueToTags(html)))
);
return html;
}
html = collapseEmptyParagraphsInHtml(postProcessImportedWordHtml(html));
return parseHtmlToLatex(html);
}

View File

@@ -0,0 +1,485 @@
import { htmlToPlainText } from '@/utils/manuscriptCitationRelevance';
import { parseReferencesBulkContent } from '@/utils/manuscriptReferenceHtml';
import { extractReferencesSectionFromWordHtml, fetchWordReferencesSection } from '@/utils/manuscriptWordReferences';
import { importWordDocumentWithMath } from '@/utils/wordMathImport';
import { applyDealContentToFields, parseReferenceFieldsFromHtml, normalizeImportedReferenceFields, normalizeImportedReferenceText, finalizeReferenceFields } from '@/utils/parseReferenceFields';
function mapUploadedItemsToRows(items) {
return (items || []).map(function (item, index) {
const referType = String(item.refer_type || 'journal').toLowerCase();
const fields = parseReferenceFieldsFromHtml(item.html, referType);
return {
index: index + 1,
html: item.html || '',
refer_type: fields ? fields.refer_type : referType,
fields: fields
};
});
}
/** 解析本地 Word 文件中的 References 条目 */
export async function parseLocalWordReferencesFile(file) {
if (!file) {
return { found: false, rows: [], count: 0 };
}
const html = await importWordDocumentWithMath(file, { keepReferences: true });
const extracted = extractReferencesSectionFromWordHtml(html);
if (!extracted.found) {
return { found: false, rows: [], count: 0 };
}
const items = parseReferencesBulkContent(extracted.html);
const rows = mapUploadedItemsToRows(items);
return {
found: rows.length > 0,
rows: rows,
count: rows.length
};
}
/** 从 gridData 稿件路径下载并解析 References */
export async function parseManuscriptWordReferences(apiClient, options) {
const extracted = await fetchWordReferencesSection(apiClient, options || {});
if (!extracted.found) {
return { found: false, rows: [], count: 0 };
}
const items = parseReferencesBulkContent(extracted.html);
const rows = mapUploadedItemsToRows(items);
return {
found: rows.length > 0,
rows: rows,
count: rows.length
};
}
function previewExistingReference(row) {
if (!row) return '';
if (row.refer_frag) {
return htmlToPlainText(row.refer_frag).replace(/\s+/g, ' ').trim();
}
const parts = [];
if (row.author) parts.push(row.author);
if (row.title) parts.push(row.title);
if (row.joura) parts.push(row.joura);
if (row.dateno) parts.push(row.dateno);
if (row.doilink) parts.push('Available at: ' + row.doilink);
return parts.join(' ').replace(/\s+/g, ' ').trim();
}
function normalizeWordRefBatchRow(row) {
if (!row) return row;
let normalized = normalizeImportedReferenceFields(row);
if (normalized.doilink) {
normalized.doilink = normalizeDoilinkFull(normalized.doilink);
normalized.doi = normalizeDoiValue(normalized.doilink);
}
if (normalized.dateno) {
normalized.dateno = normalized.dateno.replace(/^(\d{4}):/, '$1;');
}
normalized = finalizeReferenceFields(normalized, row.wordHtml || normalized.content || '');
return Object.assign({}, row, normalized);
}
/** 按序号将 Word 条目与现有参考文献一一对应 */
export function buildWordRefBatchRows(existingRows, uploadedRows) {
const existing = existingRows || [];
const uploaded = uploadedRows || [];
const maxLen = Math.max(existing.length, uploaded.length);
const rows = [];
for (let i = 0; i < maxLen; i++) {
const existingRow = existing[i] || null;
const uploadedRow = uploaded[i] || null;
const fields = (uploadedRow && uploadedRow.fields) || null;
const prevExistingRow = i > 0 ? existing[i - 1] : null;
rows.push(
normalizeWordRefBatchRow({
index: i + 1,
apply: !!(uploadedRow && fields),
p_refer_id: existingRow ? existingRow.p_refer_id : null,
pre_p_refer_id: existingRow
? existingRow.pre_p_refer_id
: prevExistingRow
? prevExistingRow.p_refer_id
: null,
existingType: existingRow ? existingRow.refer_type : '',
existingPreview: previewExistingReference(existingRow),
refer_type: fields ? fields.refer_type : uploadedRow ? uploadedRow.refer_type : 'journal',
author: fields ? fields.author : '',
title: fields ? fields.title : '',
joura: fields ? fields.joura : '',
dateno: fields ? fields.dateno : '',
doilink: fields ? fields.doilink : '',
doi: fields ? fields.doi || fields.doilink : '',
isbn: fields ? fields.isbn : '',
content: fields ? fields.content : '',
wordHtml: uploadedRow ? uploadedRow.html : '',
unmatched: !existingRow || !uploadedRow,
missingExisting: !existingRow,
missingUploaded: !uploadedRow
})
);
}
return rows;
}
/** 调用 dealContent 精修字段拆解 */
export async function enrichWordRefBatchRowsWithDealContent(apiClient, rows) {
const list = rows || [];
const updated = [];
for (let i = 0; i < list.length; i++) {
const row = list[i];
if (!row || !row.wordHtml) {
updated.push(normalizeWordRefBatchRow(row));
continue;
}
try {
const content = normalizeImportedReferenceText(htmlToPlainText(row.wordHtml));
const res = await apiClient.post('api/References/dealContent', { content: content });
const data = res && res.data ? res.data : null;
if (data) {
const merged = applyDealContentToFields(
{
refer_type: row.refer_type,
author: row.author,
title: row.title,
joura: row.joura,
dateno: row.dateno,
doilink: row.doilink,
doi: row.doi || row.doilink,
isbn: row.isbn,
content: row.content
},
data
);
const nextRow = normalizeWordRefBatchRow(
Object.assign({}, row, merged, {
refer_type: merged.refer_type || row.refer_type,
doi: merged.doi || merged.doilink || row.doi || row.doilink
})
);
updated.push(nextRow);
continue;
}
} catch (err) {
// 保留本地拆解结果
}
updated.push(normalizeWordRefBatchRow(row));
}
return updated;
}
function normalizeDoiValue(doilink) {
let raw = String(doilink || '').trim();
if (!raw) return '';
raw = raw.replace(/^https?:\/\/doi\.org\//i, '');
raw = raw.replace(/^doi:/i, '');
return raw.toLowerCase();
}
function normalizeDoilinkFull(doilink) {
const raw = String(doilink || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
const doi = normalizeDoiValue(raw);
return doi ? 'https://doi.org/' + doi : raw;
}
function appendReferPart(content, part) {
const piece = String(part || '').trim();
if (!piece) return content;
let out = String(content || '').trim();
if (!out) return piece;
if (out.endsWith('.')) {
return out + piece;
}
return out + '.' + piece;
}
/** journal 的 content 字段,与 editRefer 接口一致 */
export function buildJournalReferContent(fields) {
const author = String((fields && fields.author) || '').trim();
const title = String((fields && fields.title) || '').trim();
const joura = String((fields && fields.joura) || '').trim();
const dateno = String((fields && fields.dateno) || '').trim();
const doilink = normalizeDoilinkFull(fields && fields.doilink);
let content = author;
if (title) {
if (!content) {
content = title;
} else if (content.endsWith('.')) {
content = content + ' ' + title;
} else {
content = content + '. ' + title;
}
}
if (joura) {
content = content + ' ' + joura;
if (!/\.\s*$/.test(joura)) {
content = content + '.';
}
}
if (dateno) {
const datenoForContent = dateno.replace(/^(\d{4});/, '$1:');
content = appendReferPart(content, datenoForContent);
}
if (doilink) {
content = content + '.Available at: ' + doilink;
}
return content;
}
/** book 的 content 字段 */
export function buildBookReferContent(fields) {
const author = String((fields && fields.author) || '').trim();
const title = String((fields && fields.title) || '').trim();
const dateno = String((fields && fields.dateno) || '').trim();
const isbn = String((fields && fields.isbn) || '').trim();
let content = author;
if (title) {
content = appendReferPart(content, title);
}
if (dateno) {
content = appendReferPart(content, dateno);
}
if (isbn) {
content = content + '. Available at: ISBN: ' + isbn;
}
return content;
}
export function buildEditReferPayload(row, pArticleId) {
const normalized = normalizeWordRefBatchRow(row || {});
const referType = String(normalized.refer_type || 'journal').toLowerCase();
const base = {
p_article_id: pArticleId != null && pArticleId !== '' ? pArticleId : '',
p_refer_id: normalized.p_refer_id,
pre_p_refer_id: normalized.pre_p_refer_id != null && normalized.pre_p_refer_id !== '' ? normalized.pre_p_refer_id : ''
};
if (referType === 'book') {
return Object.assign(base, {
doi: '',
refer_type: 'book',
author: normalized.author || '',
title: normalized.title || '',
dateno: normalized.dateno || '',
isbn: normalized.isbn || '',
joura: '',
doilink: '',
content: buildBookReferContent(normalized)
});
}
if (referType === 'other') {
return Object.assign(base, {
doi: normalizeDoiValue(normalized.doilink || normalized.doi),
refer_type: 'other',
author: '',
title: '',
dateno: '',
joura: '',
doilink: normalizeDoilinkFull(normalized.doilink) || '',
isbn: '',
content: normalized.content || ''
});
}
const doilink = normalizeDoilinkFull(normalized.doilink);
const doi = normalizeDoiValue(normalized.doilink || normalized.doi);
return Object.assign(base, {
doi: doi,
refer_type: 'journal',
author: normalized.author || '',
title: normalized.title || '',
joura: normalized.joura || '',
dateno: normalized.dateno || '',
doilink: doilink,
isbn: '',
content: buildJournalReferContent(normalized)
});
}
export function extractAddedReferId(data) {
if (!data || typeof data !== 'object') {
return '';
}
if (data.p_refer_id != null && data.p_refer_id !== '') {
return String(data.p_refer_id);
}
return '';
}
/** 新增参考文献 payloadp_refer_id 留空pre_p_refer_id 指向前一条 */
export function buildAddReferPayload(row, pArticleId, preReferId) {
const payload = buildEditReferPayload(row, pArticleId);
delete payload.p_refer_id;
payload.pre_p_refer_id = preReferId != null && preReferId !== '' ? String(preReferId) : '';
return payload;
}
export function canWordRefBatchRowSave(row) {
if (!row || row.missingUploaded) {
return false;
}
if (row.p_refer_id) {
return true;
}
return !!row.missingExisting;
}
const WORD_REF_BATCH_FIELD_DEFS = {
journal: ['author', 'title', 'joura', 'dateno', 'doilink'],
book: ['author', 'title', 'dateno', 'isbn'],
other: ['content']
};
const WORD_REF_BATCH_REQUIRED_FIELDS = {
journal: ['title', 'joura', 'dateno'],
book: ['author', 'title', 'dateno'],
other: ['content']
};
export function getWordRefBatchFieldKeys(referType) {
const type = String(referType || 'journal').toLowerCase();
return (WORD_REF_BATCH_FIELD_DEFS[type] || WORD_REF_BATCH_FIELD_DEFS.journal).slice();
}
export function isWordRefBatchRowParseIncomplete(row) {
if (!row || row.missingUploaded) {
return false;
}
const type = String(row.refer_type || 'journal').toLowerCase();
const required = WORD_REF_BATCH_REQUIRED_FIELDS[type] || WORD_REF_BATCH_REQUIRED_FIELDS.journal;
return required.some(function (key) {
return !String(row[key] || '').trim();
});
}
export function stripWordRefBatchHtmlPreview(html) {
return htmlToPlainText(String(html || ''))
.replace(/\s+/g, ' ')
.trim();
}
/** 切换为 other 时:用 Word 原文(或已拆字段拼接)填入 content */
export function buildWordRefBatchContentFromRow(row) {
if (!row) {
return '';
}
const fromWord = stripWordRefBatchHtmlPreview(row.wordHtml);
if (fromWord) {
return normalizeImportedReferenceText(fromWord);
}
const parts = [];
if (row.author) {
parts.push(String(row.author).trim());
}
if (row.title) {
parts.push(String(row.title).trim());
}
if (row.joura) {
parts.push(String(row.joura).trim());
}
if (row.dateno) {
parts.push(String(row.dateno).trim());
}
let content = parts
.filter(Boolean)
.join(' ')
.replace(/\s+\./g, '.')
.replace(/\s+/g, ' ')
.trim();
const doilink = String(row.doilink || '').trim();
if (doilink && !/Available at:/i.test(content)) {
content = content + '. Available at: ' + doilink;
}
return normalizeImportedReferenceText(content || row.content || '');
}
export function applyWordRefBatchOtherType(row) {
if (!row) {
return row;
}
row.refer_type = 'other';
row.content = buildWordRefBatchContentFromRow(row);
const plain = String(row.content || '');
const urlMatch = plain.match(/Available at:\s*(https?:\/\/\S+)/i);
if (urlMatch) {
row.doilink = urlMatch[1].replace(/[.,;)\]]+$/, '');
}
return row;
}
/**
* 按序号批量保存:已有条目走 editReferWord 多出的条目走 addReferByParticleid
* 每条成功后用返回/已有的 p_refer_id 作为下一条的 pre_p_refer_id。
*/
export async function saveWordRefBatchRows(apiClient, options) {
const opts = options || {};
const rows = (opts.rows || [])
.filter(function (row) {
return row && row.apply && canWordRefBatchRowSave(row);
})
.sort(function (a, b) {
return (a.index || 0) - (b.index || 0);
});
const pArticleId = opts.pArticleId;
const useUserAdd = opts.role === 'user';
const addEndpoint = useUserAdd ? 'api/Preaccept/addRefer' : 'api/Preaccept/addReferByParticleid';
let lastReferId = null;
let ok = 0;
let fail = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
try {
if (row.p_refer_id) {
const payload = buildEditReferPayload(row, pArticleId);
if (lastReferId != null && lastReferId !== '') {
payload.pre_p_refer_id = lastReferId;
}
const res = await apiClient.post('api/Preaccept/editRefer', payload);
if (res && res.code == 0) {
ok += 1;
lastReferId = String(row.p_refer_id);
} else {
fail += 1;
}
continue;
}
if (row.missingExisting) {
const payload = buildAddReferPayload(row, pArticleId, lastReferId);
if (useUserAdd && opts.articleId != null && opts.articleId !== '') {
payload.article_id = opts.articleId;
}
const res = await apiClient.post(addEndpoint, payload);
if (res && res.code == 0) {
ok += 1;
const newId = extractAddedReferId(res.data);
if (newId) {
lastReferId = newId;
}
} else {
fail += 1;
}
continue;
}
fail += 1;
} catch (err) {
fail += 1;
}
}
return { ok: ok, fail: fail };
}