47 Commits

Author SHA1 Message Date
a7ae30b0ac Merge branch 'master' of https://git.nuttyreading.com/wangjinlei/tougao_web into similarity-check 2026-05-20 10:02:20 +08:00
7542d724ce 类型 6 青年编委2025前 7 作者2025年之前的 2026-05-20 09:10:01 +08:00
7570e0a1eb tijiao 2026-05-19 14:17:28 +08:00
2a7b9a0ec2 自动化添加审稿人类型 2026-05-19 11:28:33 +08:00
00d58f8d56 自动化渲染变量显示 2026-05-19 10:09:15 +08:00
b288e65064 Merge branch 'master' of https://git.nuttyreading.com/wangjinlei/tougao_web into similarity-check 2026-05-18 15:46:39 +08:00
d4784339fd 任务列表显示 2026-05-18 11:46:26 +08:00
463100f669 自动任务详情 下拉框 显示任务详情 2026-05-18 09:27:34 +08:00
d765628bb3 批量上传 2026-05-15 10:54:06 +08:00
77c0fac34d 提交 2026-05-14 13:11:54 +08:00
74a6c4b74b 支付信息提示 2026-05-14 11:29:17 +08:00
be8ea4e486 提交 2026-05-14 11:26:39 +08:00
4426077094 Merge branch 'master' of https://git.nuttyreading.com/wangjinlei/tougao_web into similarity-check 2026-05-13 14:18:21 +08:00
6050dd365d 作者端预接收按钮 2026-05-13 14:14:50 +08:00
723ec0d190 提交 2026-05-13 13:25:05 +08:00
b10de50fdf 提交 2026-05-13 09:47:36 +08:00
f67d8d5600 邮件预览 2026-05-09 10:29:34 +08:00
67a4875b01 自动化详情页 任务显示 2026-05-08 13:53:37 +08:00
8e59702f0b 申请青年编委单独页面校验显示 2026-05-06 10:49:41 +08:00
ea1564018e 必填项 变成英文提示词 2026-05-06 09:52:32 +08:00
4d7c230abe 提交 2026-04-29 17:46:12 +08:00
8b9e35287c 自动化配置增加青年编委模块 2026-04-29 17:13:40 +08:00
9234318139 自主修改邮箱 2026-04-29 12:49:55 +08:00
a4cbce2db7 自动化样式 2026-04-29 09:20:47 +08:00
56cda2c232 模版列表记忆功能 2026-04-28 13:27:09 +08:00
0c9ff0fe68 专家库退订状态+修改列表页数据显示 2026-04-28 11:12:15 +08:00
ca1b10c418 滑动选中 2026-04-24 15:05:57 +08:00
bb36bcc645 修改邮件模版预览按钮 2026-04-24 14:51:25 +08:00
fe4bd7c9b0 任务工厂 2026-04-24 11:05:22 +08:00
632913aaad 提交 2026-04-23 09:12:13 +08:00
711a3fe2ec 任务工厂列表版 2026-04-22 11:42:47 +08:00
0d913e90a7 国家 2026-04-17 13:35:56 +08:00
7458beb8b2 批量查询领域 2026-04-17 10:56:44 +08:00
8fbcf39a25 提交 2026-04-17 10:36:48 +08:00
1e2e8146cc 审稿校验空格 2026-04-16 11:02:01 +08:00
64b1d20b32 排版拖拽显示问题 2026-04-15 13:09:08 +08:00
ec74b6e38c 审稿报告验证 2026-04-15 10:47:09 +08:00
1844d2d125 审稿报告字数统计 加入限制 排除 空格之类的 2026-04-15 10:39:44 +08:00
ae5be5f20f 修改预览邮件模版变量 英文提示词 2026-04-14 09:09:30 +08:00
c92a5019c3 专家代表作假数据显示 2026-04-13 14:44:31 +08:00
0086899ec4 加了变量预览 2026-04-13 14:18:51 +08:00
075fae74ab 修改自动化位置 2026-04-10 09:21:13 +08:00
dd59e5611c monitorManuscript加sn 2026-04-09 13:20:56 +08:00
81f16cf190 在monitorManuscript增加 价格和 支付状态 2026-04-09 13:12:57 +08:00
8c87861d8e 调整关键词页面 2026-04-03 09:50:49 +08:00
3f53a6c7d0 关键字管理 2026-03-30 13:07:33 +08:00
7d3e6654fd 提交 2026-03-27 13:24:04 +08:00
54 changed files with 14991 additions and 1546 deletions

View File

@@ -19,8 +19,8 @@ const service = axios.create({
// baseURL: 'https://submission.tmrjournals.com/', //正式 记得切换
// baseURL: 'http://www.tougao.com/', //测试本地 记得切换
// baseURL: 'http://192.168.110.110/tougao/public/index.php/',
// baseURL: '/api', //本地
baseURL: '/', //正式
baseURL: '/api', //本地
// baseURL: '/', //正式
});

View File

@@ -391,7 +391,8 @@ str = str.replace(regex, function (match, content, offset, fullString) {
const allTables = [];
if (!tables || tables.length === 0) {
console.warn("未找到表格内容,请检查 XML 结构");
return [];
callback([]);
return;
}
for (const table of tables) {
const rows = table.getElementsByTagNameNS(namespace, "tr");
@@ -1244,7 +1245,31 @@ str = str.replace(regex, function (match, content, offset, fullString) {
},
getCleanTextForCount(html) {
if (!html) return "";
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// 1. 处理公式 (保留公式文本内容)
const wmaths = tempDiv.querySelectorAll('wmath');
wmaths.forEach(wm => {
const textNode = document.createTextNode(" " + wm.textContent + " ");
wm.parentNode.replaceChild(textNode, wm);
});
// 2. 移除所有引用标签 [1], [2] (防止用户靠狂刷引用来凑字数)
const refs = tempDiv.querySelectorAll('span.reference-link, a.ref'); // 根据你系统的 class 名调整
refs.forEach(r => r.remove());
// 3. 获取纯文本 (textContent 是浏览器原生方法,能处理所有标签及其属性)
let text = tempDiv.textContent || tempDiv.innerText || "";
// 4. 标准化空格:将 HTML 实体、换行、多个空格统一转为一个空格
return text.replace(/ /ig, " ")
.replace(/[\r\n\t]+/g, " ")
.replace(/\s+/g, " ")
.trim();
},

View File

@@ -128,6 +128,7 @@ export default {
},
created() {
this.initORCID();
bus.$on('editorSessionLocalRestored', this.syncLsUserToHeader);
if (this.user_id == 24) {
this.daojishi = '2021.9.3 - 2021.9.30';
this.curStartTime = '2021-10-01 00:00:00';
@@ -162,6 +163,11 @@ export default {
}
},
methods: {
syncLsUserToHeader() {
this.user_id = localStorage.getItem('U_id');
this.user_cap = localStorage.getItem('U_role');
this.$forceUpdate();
},
goHome() {
this.$router.push('/');
},
@@ -176,11 +182,14 @@ export default {
localStorage.removeItem('U_role');
localStorage.removeItem('U_id');
localStorage.removeItem('U_name');
localStorage.removeItem('U_email');
localStorage.removeItem('U_status');
localStorage.removeItem('ms_journal_alias');
localStorage.removeItem('journalTypeData');
localStorage.removeItem('journalTypeDataAll');
localStorage.removeItem('opMedicalListData');
localStorage.removeItem('mailboxCollect_j_email_id');
localStorage.removeItem('mailboxCollect_journal_id');
this.$router.push('/login');
}
},
@@ -292,6 +301,9 @@ export default {
},
immediate: true
}
},
beforeDestroy() {
bus.$off('editorSessionLocalRestored', this.syncLsUserToHeader);
}
};
</script>

View File

@@ -120,6 +120,7 @@ export default {
},
created() {
this.initORCID();
bus.$on('editorSessionLocalRestored', this.onEditorSessionLocalRestored);
if (this.user_id == 24) {
this.daojishi = '2021.9.3 - 2021.9.30';
this.curStartTime = '2021-10-01 00:00:00';
@@ -155,6 +156,10 @@ export default {
}
},
methods: {
onEditorSessionLocalRestored() {
this.updateUsername();
this.$forceUpdate();
},
updateUsername() {
this.localUsername = localStorage.getItem('U_name');
@@ -174,11 +179,14 @@ export default {
localStorage.removeItem('U_role');
localStorage.removeItem('U_id');
localStorage.removeItem('U_name');
localStorage.removeItem('U_email');
localStorage.removeItem('U_status');
localStorage.removeItem('ms_journal_alias');
localStorage.removeItem('journalTypeData');
localStorage.removeItem('journalTypeDataAll');
localStorage.removeItem('opMedicalListData');
localStorage.removeItem('mailboxCollect_j_email_id');
localStorage.removeItem('mailboxCollect_journal_id');
this.$router.push('/login');
}
},
@@ -287,6 +295,7 @@ export default {
}
},
beforeDestroy() {
bus.$off('editorSessionLocalRestored', this.onEditorSessionLocalRestored);
// 步骤 C2: 销毁前移除监听器
if (this.$bus) {
this.$bus.$off('user-name-updated', this.updateUsername);

View File

@@ -20,8 +20,15 @@
<template v-for="subItem in item.subs">
<el-submenu v-if="subItem.subs" :index="subItem.index" :key="subItem.index + '-submenu'">
<template slot="title">
{{ subItem.title }}
<!-- <el-badge is-dot :hidden="false">{{ subItem.title }}</el-badge> -->
<el-badge
v-if="subItem.index === '45'"
is-dot
:hidden="applyBadgeYouth <= 0"
class="sidebar-submenu-apply-badge"
>
<span>{{ subItem.title }}</span>
</el-badge>
<template v-else>{{ subItem.title }}</template>
</template>
<template v-for="(threeItem, i) in subItem.subs">
@@ -35,8 +42,16 @@
{{ fourItem.title }}
</el-menu-item>
</el-submenu>
<el-menu-item v-else :index="threeItem.index" :key="threeItem.index + '-item'"
>{{ threeItem.title }}
<el-menu-item v-else :index="threeItem.index" :key="threeItem.index + '-item'">
<el-badge
v-if="threeItem.index === 'youthApplyList'"
is-dot
:hidden="applyBadgeYouth <= 0"
class="sidebar-menu-youth-apply-badge"
>
<span class="sidebar-menu-youth-apply-badge__text">{{ threeItem.title }}</span>
</el-badge>
<template v-else>{{ threeItem.title }}</template>
</el-menu-item>
</template>
</el-submenu>
@@ -64,8 +79,8 @@
</el-submenu>
</template>
<!-- 编委 -->
<template v-if="this.user_cap.includes(',board')||this.user_cap.includes('board_editor')">
<template v-if="this.user_cap.includes(',board') || this.user_cap.includes('board_editor')">
<el-submenu index="7">
<template slot="title"> <i class="el-icon-notebook-1"></i> {{ $t('sidebar.edit_oria') }} </template>
<!-- <el-menu-item index="editorial">
@@ -74,7 +89,7 @@
<!-- <el-menu-item index="edithistory">
{{ $t('sidebar.edit_oria2') }}
</el-menu-item> -->
<el-menu-item index="editPeerewer">
<el-menu-item index="editPeerewer">
{{ $t('sidebar.edit_ewer1') }}
</el-menu-item>
<el-menu-item index="editPerhistory">
@@ -83,9 +98,10 @@
</el-submenu>
</template>
<!-- 主编 -->
<template v-if="this.user_cap.includes('chief')||this.user_cap.includes('chief_editor')||this.user_cap.includes('deputy_editor')">
<template
v-if="this.user_cap.includes('chief') || this.user_cap.includes('chief_editor') || this.user_cap.includes('deputy_editor')"
>
<el-submenu index="6">
<template slot="title"> <i class="el-icon-document-copy"></i> {{ $t('sidebar.man_ing') }} </template>
<el-menu-item index="managing">
@@ -156,13 +172,8 @@
<template v-if="this.userrole == 1">
<el-submenu index="mailboxManagement">
<template slot="title"> <i class="el-icon-message"></i> {{ $t('sidebar.promotionManagement') }} </template>
<el-menu-item index="expertDatabase">
{{ $t('sidebar.expertDatabase') }}
</el-menu-item>
<el-menu-item index="mailboxConfig">
{{ $t('sidebar.mailboxManagement') }}
<el-menu-item index="autoPromotion">
{{ $t('sidebar.autoPromotion') }}
</el-menu-item>
<el-menu-item index="mailboxCollect">
{{ $t('sidebar.mailboxCollect') }}
@@ -170,20 +181,32 @@
<el-menu-item index="mailboxMould">
{{ $t('sidebar.emailTemplates') }}
</el-menu-item>
<el-menu-item index="autoPromotion">
{{ $t('sidebar.autoPromotion') }}
<el-menu-item index="mailboxConfig">
{{ $t('sidebar.mailboxManagement') }}
</el-menu-item>
<el-submenu index="expertDatabaseSub">
<template slot="title">
{{ $t('sidebar.expertDatabase') }}
</template>
<el-menu-item index="expertDatabase">
{{ $t('sidebar.expertList') }}
</el-menu-item>
<el-menu-item index="crawlTaskMonitor">
{{ $t('sidebar.crawlTasks') }}
</el-menu-item>
</el-submenu>
<el-menu-item index="countryManagement">
{{ $t('sidebar.countryManagement') }}
</el-menu-item>
</el-submenu>
<el-submenu index="tools">
<template slot="title"> <i class="el-icon-paperclip"></i> {{ $t('sidebar.tools') }} </template>
<el-menu-item index="RejectedArticles">
{{ $t('sidebar.ReArticles') }}
</el-menu-item>
<el-menu-item index="scholarCrawlers">
{{ $t('sidebar.scholarCrawlers') }}
</el-menu-item>
<!-- <el-menu-item index="1" key="1"> -->
<a href="http://master.tmrjournals.com" target="_blank" class="linkBar"> Management System </a>
<!-- </el-menu-item> -->
@@ -212,6 +235,8 @@ export default {
user_cap: localStorage.getItem('U_role'),
menuList: [],
/** 青年编委申请红点Young Scientist 父级 + Apply 子项,数据来自 getYboardApplys */
applyBadgeYouth: 0,
items: [],
// 作者
author_items: [
@@ -225,7 +250,6 @@ export default {
index: '1',
title: this.$t('sidebar.author'),
subs: [
{
index: 'articleList',
title: this.$t('sidebar.author1')
@@ -237,10 +261,11 @@ export default {
{
index: 'articleAdd',
title: this.$t('sidebar.author2')
} , {
},
{
index: 'orderListAuthor',
title: this.$t('sidebar.author4')
},
}
]
}
// ,{
@@ -303,7 +328,6 @@ export default {
index: 'Promotionsystem',
title: this.$t('menu.Promotionsystem'),
subs: [
{
index: 'disseMRecord',
title: this.$t('menu.userManSys6')
@@ -354,7 +378,7 @@ export default {
index: '4',
title: this.$t('sidebar.userManSys'),
subs: [
{
{
//论文编辑系统
icon: 'el-icon-lx-copy',
index: 'Userdatabase',
@@ -670,74 +694,64 @@ export default {
}
},
mounted() {
// if(this.user_cap.includes(',board')||this.user_cap.includes('board_editor')||this.user_cap.includes('chief')||this.user_cap.includes('chief_editor')||this.user_cap.includes('deputy_editor')){
// Promise.all([
// this.$api
// .post('api/Finalreview/lists', {
// 'reviewer_id': localStorage.getItem('U_id'),state:5,
// 'page': 1,
// 'size': 999999,
// })
// ]).then(([res1]) => {
// console.log('res1 at line 770:', res1)
// const totalCheck = res1.data.total || 0; // 待审核
// if (totalCheck > 0 ) {
// const h = this.$createElement;
// const messageNodes = [];
// if (totalCheck > 0) {
// messageNodes.push(
// h(
// 'p',
// {
// style: {
// color: '#006699',
// marginTop: '10px',
// marginBottom: '4px',
// display: 'block', width: '280px' // 设置宽度
// }
// },
// `You have received 【${totalCheck}】 invitations .`
// )
// );
// // 第二行
// messageNodes.push(
// h(
// 'p',
// {
// style: {
// color: '#888',
// display: 'block', width: '280px' // 设置宽度
// }
// },
// 'Please process them promptly in the final review history.'
// )
// );
// }
// this.$notify({
// title: 'Final Review',
// message: h('div', {
// style: 'width: 200px;'
// }, messageNodes)
// });
// }
// });
// }
if (String(this.userrole) === '1') {
this.fetchApplyBadgeSummary();
}
// if(this.user_cap.includes(',board')||this.user_cap.includes('board_editor')||this.user_cap.includes('chief')||this.user_cap.includes('chief_editor')||this.user_cap.includes('deputy_editor')){
// Promise.all([
// this.$api
// .post('api/Finalreview/lists', {
// 'reviewer_id': localStorage.getItem('U_id'),state:5,
// 'page': 1,
// 'size': 999999,
// })
// ]).then(([res1]) => {
// console.log('res1 at line 770:', res1)
// const totalCheck = res1.data.total || 0; // 待审核
// if (totalCheck > 0 ) {
// const h = this.$createElement;
// const messageNodes = [];
// if (totalCheck > 0) {
// messageNodes.push(
// h(
// 'p',
// {
// style: {
// color: '#006699',
// marginTop: '10px',
// marginBottom: '4px',
// display: 'block', width: '280px' // 设置宽度
// }
// },
// `You have received 【${totalCheck}】 invitations .`
// )
// );
// // 第二行
// messageNodes.push(
// h(
// 'p',
// {
// style: {
// color: '#888',
// display: 'block', width: '280px' // 设置宽度
// }
// },
// 'Please process them promptly in the final review history.'
// )
// );
// }
// this.$notify({
// title: 'Final Review',
// message: h('div', {
// style: 'width: 200px;'
// }, messageNodes)
// });
// }
// });
// }
},
created() {
localStorage.setItem('collapse', this.collapse);
if (this.userrole == 2) {
//其余的身份(显示作者)
@@ -779,7 +793,7 @@ export default {
index: 'JournalManagementAll',
title: this.$t('sidebar.journalList')
},
{
index: 'GroupClassification',
title: this.$t('sidebar.GroupClassification')
@@ -851,10 +865,33 @@ export default {
localStorage.setItem('collapse', this.collapse);
bus.$emit('collapse-content', msg);
});
bus.$on('apply-badge-refresh', () => {
this.fetchApplyBadgeSummary();
});
},
beforeDestroy() {
bus.$off('apply-badge-refresh');
},
methods: {
// 获取数据
getDate() {}
getDate() {},
fetchApplyBadgeSummary() {
if (String(this.userrole) !== '1') return;
const editorId = localStorage.getItem('U_id');
if (!editorId) return;
this.$api
.post('api/User/getYboardApplys', { editor_id: editorId })
.then((res) => {
if (res && res.code === 0 && res.data && Array.isArray(res.data.applys)) {
this.applyBadgeYouth = res.data.applys.length > 0 ? 1 : 0;
} else {
this.applyBadgeYouth = 0;
}
})
.catch(() => {
this.applyBadgeYouth = 0;
});
}
}
};
</script>
@@ -928,4 +965,38 @@ export default {
.linkBar:hover {
background: #00527a;
}
/* Young Scientist 父级标题红点 */
.sidebar-submenu-apply-badge {
display: inline-block;
vertical-align: middle;
}
.sidebar-submenu-apply-badge ::v-deep .el-badge__content.is-dot {
top: 28%;
margin-top: 6px;
right: -4px;
border: 0;
display: none !important;
}
/* Apply 子菜单行上的红点,与菜单行高对齐 */
.sidebar-menu-youth-apply-badge {
display: inline-block;
vertical-align: middle;
line-height: 50px;
}
.sidebar-menu-youth-apply-badge ::v-deep .el-badge__content.is-dot {
top: 28%;
margin-top: 6px;
right: -4px;
border: 0;
display: none !important;
}
.sidebar-menu-youth-apply-badge__text {
color: inherit;
vertical-align: middle;
}
</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

@@ -20,6 +20,13 @@ const en = {
info: {
realname: 'English names can only contain uppercase and lowercase letters, "-", and spaces'
},
articleAdd: {
qqEmailAlertLine1: 'The publisher does not currently accept submissions from QQ Mail.',
qqEmailAlertLine2Before: 'Please go to',
qqEmailDashboardLink: 'Personal Center (Dashboard)',
qqEmailAlertLine2After: ' to change your registered email.',
qqEmailSubmitBlockedMsg: 'QQ Mail is not supported for submission. Please go to Personal Center (Dashboard) and change your email.'
},
total: {
author: 'author',
editor: 'editor',
@@ -40,6 +47,25 @@ const en = {
status: 'Status',
delete: 'Delete',
deleteInfo: 'Are you sure you want to delete this journal installment?',
plagiarismNotChecked: 'Not checked',
plagiarismChecking: 'Checking…',
plagiarismRecheck: 'Re-check',
plagiarismDuplicateCheck: 'Re-check',
plagiarismCheckFailed: 'Failed to start plagiarism check.',
plagiarismStatusFailed: 'Failed to load plagiarism status.',
plagiarismNoReportUrl: 'Report link is not available yet.',
plagiarismReportDetailFailed: 'Could not load manuscript details. Please try again.',
plagiarismListTitle: 'Plagiarism check history',
plagiarismAutoCheck: 'Auto plagiarism check',
plagiarismRefresh: 'Refresh',
plagiarismEmptyList: 'No plagiarism checks yet',
plagiarismSimilarity: 'Similarity',
plagiarismFile: 'File',
plagiarismPreviewPdf: 'Preview report',
plagiarismReportLink: 'Report',
plagiarismNoPdfLink: 'No link',
plagiarismPreviewClose: 'Close',
plagiarismPreviewOpenTab: 'Open in new tab',
},
menu: {
main: 'Personal Center',
@@ -276,7 +302,12 @@ const en = {
mailboxManagement: 'Mailbox Management',
mailboxConfig: 'Mailbox Management',
scholarCrawlers: 'Scholar Crawlers',
crawlerKeywords: 'Crawl Keywords',
expertDatabase: 'Expert Database',
keywordManagement: 'Keyword Management',
crawlTasks: 'Crawl Tasks',
expertList: 'Expert List',
countryManagement: 'Country Management',
autoPromotion: 'Auto Promotion',
ReArticles: 'Rejected Manuscripts', // 被拒稿件
editorialBoard: 'Boss System',
@@ -294,6 +325,7 @@ const en = {
expertDatabase: {
fieldSelectPlaceholder: 'Please select field',
keywordPlaceholder: 'Name / Email / Affiliation',
fieldPlaceholder: 'Please enter field',
searchBtn: 'Search',
resetBtn: 'Reset',
downloadExcelBtn: 'Download Excel',
@@ -302,16 +334,109 @@ const en = {
},
columns: {
baseInfo: 'Base Information',
country: 'Country',
affiliation: 'Affiliation',
researchAreas: 'Research areas'
researchAreas: 'Research areas',
unsubscribeStatus: 'Subscription status'
},
emptyMark: '-',
fields: {
nameLabel: 'Name:',
emailLabel: 'Email:',
acquisitionTimeLabel: 'Acquisition Time:'
},
exportWarn: 'Please select a research area or enter a keyword before exporting.',
exportFailed: 'Export failed'
viewAllInfo: 'View all details',
detailDialogTitle: 'Research areas & Article',
detailColField: 'Research areas',
detailColPaper: 'Article title',
detailColJournal: 'Journal',
detailClose: 'Close',
detailCellEmpty: 'N/A',
noFieldDetail: 'No publication details for these fields',
exportWarn: 'Please select a research area, enter a keyword, or enter a field before exporting.',
exportFailed: 'Export failed',
unsubscribeNormal: 'Subscribed',
unsubscribeUnsubscribed: 'Unsubscribed',
unsubscribeSwitchOn: 'Unsub',
unsubscribeSwitchOff: 'Subscribed',
unsubscribeMissingId: 'Missing expert ID, unable to switch unsubscribe status',
unsubscribeUpdateSuccess: 'Unsubscribe status updated',
unsubscribeUpdateFailed: 'Failed to update unsubscribe status'
},
countryManagement: {
title: 'Country Management',
keywordPlaceholder: 'Chinese / English / Code',
partitionLabel: 'Partition',
partitionAll: 'All partitions',
partition1: 'Partition 1',
partition2: 'Partition 2',
partition3: 'Partition 3',
searchBtn: 'Search',
resetBtn: 'Reset',
table: {
no: 'No.',
zhName: 'Chinese name',
enName: 'English name',
code: 'Code',
partition: 'Partition',
actions: 'Actions'
},
edit: 'Edit',
delete: 'Delete',
editTitle: 'Edit country',
form: {
zhName: 'Chinese name',
enName: 'English name',
code: 'Code',
partition: 'Partition'
},
cancel: 'Cancel',
save: 'Save',
confirm: 'OK',
deleteTitle: 'Confirm delete',
deleteConfirm: 'Delete this country record? This cannot be undone.',
saveSuccess: 'Saved',
deleteSuccess: 'Deleted',
opFailed: 'Operation failed',
loadFailed: 'Failed to load list',
missingId: 'Missing country id',
ruleZhName: 'Enter Chinese name',
ruleEnName: 'Enter English name',
ruleCode: 'Enter code',
rulePartition: 'Select a partition',
batchPartitionBtn: 'Batch update partition',
batchPartitionTitle: 'Batch update partition',
batchPartitionTargetLabel: 'Target partition',
batchPartitionTargetRequired: 'Please select target partition first',
batchPartitionHelp:
'One record per line: ISO code (3 letters) OR English OR Chinese name. Lines starting with # are comments. Example:\nDNK\nDenmark',
batchPartitionPlaceholder: 'Paste data, e.g.:\nDNK\nISR',
batchPartitionPreview: 'Preview match',
batchPartitionApply: 'Apply partition updates',
batchPartitionEmpty: 'Enter valid data (one country key per line)',
batchPartitionLoadListFailed: 'Failed to load full country list',
batchPartitionPreviewEmpty: 'Nothing to match',
batchPartitionColKey: 'Key from input',
batchPartitionColCurrentPartition: 'Current partition',
batchPartitionColPartition: 'Target partition',
batchPartitionColMatch: 'Match',
batchPartitionColId: 'country_id',
batchPartitionColName: 'English name',
batchPartitionMatched: 'Matched',
batchPartitionMismatch: 'Partition mismatch',
batchPartitionMissing: 'Not found',
batchPartitionSkipSame: 'Same partition',
batchPartitionApplyConfirm: 'Update partition only for {n} row(s). Continue?',
batchPartitionFilterPlaceholder: 'Filter by key / English / code / Chinese (use ; or , for multiple)',
batchPartitionSelectAllFiltered: 'Select all (filtered)',
batchPartitionClearSelection: 'Clear selection',
batchPartitionFilterCount: 'Showing {show} of {total}',
batchPartitionSelectionHint:
'Filter the table below, then tick rows to update. "Select all" only selects filtered rows that need a partition change. If nothing is ticked, all rows that need an update are submitted.',
batchPartitionNoSelection: 'Selection mode is on but no rows are ticked. Tick at least one row, or click Clear selection to submit all.',
batchPartitionDone: 'Done: {input} line(s) in batch; {ok} updated, {fail} failed, {miss} not found.',
batchPartitionSummaryLine:
'Summary: {input} line(s) parsed; {matched} matched; {miss} not found; {same} unchanged (same partition); {will} will be updated.'
},
mailboxConfig: {
mailSystem: 'Mailbox system',
@@ -397,7 +522,7 @@ const en = {
languagePlaceholder: 'Language',
searchBtn: 'Search',
createTemplate: 'Create Template',
colTitle: 'Template title',
colTitle: 'Template title',
colSubject: 'Email subject',
colScene: 'Scene',
colLanguage: 'Language',
@@ -413,6 +538,23 @@ colTitle: 'Template title',
deleteFail: 'Delete failed',
previewTitle: 'Template preview',
previewClose: 'Close',
batchImportBtn: 'Batch import',
batchImportTitle: 'Batch import templates (JSON)',
batchImportHint:
'Paste a JSON array. Each item is saved via the same API as the editor (omit template_id to create; include template_id to update). Fields: title, subject, scene, language (or lang), version, body_html (or body), variables_json (or variables), is_active.',
batchImportCommonTip: 'Journal ID is set in the field below; when non-empty it overrides journal_id / journalId on every row.',
batchImportJournalId: 'Journal ID',
batchImportJournalPlaceholder: 'Match list filter or type manually',
batchImportRun: 'Run import',
batchImportBadJson: 'Invalid JSON',
batchImportEmpty: 'Array must contain at least one object',
batchImportMissingJournal: 'Row {index}: missing journal_id (use the input above or put journal_id in JSON)',
batchImportMissingField: 'Row {index}: missing {field}',
batchImportRowFail: 'Row {index} failed: {msg}',
batchImportRowNetwork: 'Row {index}: request error',
batchImportDone: 'Done: {ok} succeeded, {fail} failed',
batchImportErrorsTitle: 'Errors (first 8)',
batchImportSaveFail: 'Save failed',
},
mailboxStyle: {
title: 'Email Styles',
@@ -530,7 +672,207 @@ colTitle: 'Template title',
loadingMore: 'Loading more...',
noMore: 'No more',
yesterday: 'Yesterday',
selectAccountTip: 'Please select an email account first.'
selectAccountTip: 'Please select an email account first.',
attachmentCount: ' attachment(s)',
downloadFail: 'Download failed',
accountNotBelong: 'This account does not belong to you. Please select another one.',
totalAttachments: '{count} attachment(s) in total',
etcSuffix: '... etc.',
viewAttachments: 'View attachments',
newMailArrived: '{count} new mail(s) received, click to view',
downloadAllZip: 'Download all',
packingAttachments: 'Packing attachments, please wait...',
downloadBtn: 'Download',
printBtn: 'Print',
previewNotSupported: 'This file format cannot be previewed online',
downloadToView: 'Download to view locally',
registerAuthorBtn: 'Auto submit',
registerAuthorConfirm:
'Create an account via the same admin API as User Management: login name "{account}", display name "{realname}", email "{email}", initial password 123456qwe (no captcha). Continue?',
registerAuthorSuccess: 'Author account created.',
registerAuthorSuccessWithEmail: 'Created: {email}, password: 123456qwe',
registerAuthorFail: 'Creation failed. Try again later or add the user manually in User Management.',
registerAuthorExistsEmail: 'This email is already registered.',
registerAuthorExistsAccount: 'This login name is already taken. Edit the sender display name or add the user manually.',
registerAuthorNeedEmail: 'Sender email is missing; cannot create an account.',
registerAuthorNoQq: 'QQ Mail is not supported for author accounts. Please add the user manually.',
registerAuthorConfirmShort: 'Email: {email}\nPassword: {password}',
registerAuthorPickEmailFail: 'Could not allocate an available email after several attempts. Try again later or add the user manually.',
autoSubmitBtn: 'Submit first .docx as manuscript',
autoSubmitTitle: 'Log in as author and upload the first .docx attachment',
autoSubmitSessionTip:
'Author checkLogin does not change your U_* local account. The server cookie is the author during upload. After finishing, enter your editor password below and log in again to restore the editor server session.',
autoSubmitUsername: 'Username',
autoSubmitPassword: 'Password',
autoSubmitSenderEmailLabel: 'Sender email',
autoSubmitSenderEmailPlaceholder: '(No sender email)',
autoSubmitCode: 'Captcha',
autoSubmitCodePh: 'Fill only if the server requires captcha; often leave empty',
autoSubmitCancel: 'Cancel',
autoSubmitDialogTitle: 'Auto submit',
autoSubmitConfirm: 'Auto submit',
autoSubmitExistingAccountTip:
'This sender email may already have an account. Enter your password. Re-selecting a local file replaces the previous one.',
autoSubmitNotifyMailSubject: '[{journal}] Please complete your submission',
autoSubmitNotifyMailFail: 'Could not send the notification email. You can resend from the compose page.',
autoSubmitNotifyMailSkipped: 'No sender mailbox (j_email_id) found; skipped automatic email.',
autoSubmitNoDocx: 'No .docx attachment in this message (only .docx is supported, same as new submission).',
autoSubmitDownloadFail: 'Could not download the attachment. Try again later.',
autoSubmitSuccessTitle: 'Manuscript created',
autoSubmitDialogClose: 'Close',
autoSubmitSuccessLineAccount: 'Account: {account}',
autoSubmitSuccessLinePassword: 'Password: {password}',
autoSubmitSuccessLineDraft: 'Manuscript ID: {id}. Draft created in staging.',
autoSubmitSuccessLineLinkPrefix: 'Submission link: ',
autoSubmitSuccessMailSent: 'Notification email sent',
autoSubmitSuccessMailSkipped: 'No notification email (journal sender mailbox not configured).',
autoSubmitSuccessMailSkippedRecipient: 'No notification email (no valid From address on this message).',
autoSubmitSuccessMailFailed: 'Notification email was not sent; try again from the compose page.',
autoSubmitSuccessBodyLocalOnly:
'Article ID: {id}. The UI still shows your editor account; the server session may still be the author after author login. Refresh or log in again as editor.',
autoSubmitSuccessBodyServerRestored:
'Article ID: {id}. Logged in again as editor; both local storage and server session should match your editor account.',
autoSubmitSuccessNotify:
'Article ID: {id}<br/><a href="{link}" target="_blank" rel="noopener noreferrer">Open articleAdd</a>',
autoSubmitEditorRestorePwd: 'Editor password (restore session)',
autoSubmitEditorRestorePwdPh:
'Optional: password for the current editor account shown in the header, used after success to restore the server session',
autoSubmitEditorReloginFail:
'Could not restore the editor server session; local values were restored where possible. Refresh the page or log in again as editor.',
autoSubmitFail: 'Submission failed. Check credentials or network and try again.',
autoSubmitUsernameRequired: 'Username is required',
autoSubmitPasswordRequired: 'Password is required',
autoSubmitJournalLabel: 'Journal',
autoSubmitJournalUnknown: 'No journal (switch to a mailbox account that is bound to a journal)',
autoSubmitNeedJournal:
'This mailbox has no journal ID; staging backfill cannot match the submission page. Switch mailbox account first.',
autoSubmitFailPartial: '(If contribute succeeded, article ID may be {id}; please verify in admin.)',
autoSubmitManuscriptSource: 'Manuscript file',
autoSubmitPickLocalDocx: 'Upload .docx from disk',
autoSubmitSourceHint: 'Optional: upload a file; otherwise the first .docx in the email is used. Choosing again replaces the current local file.',
autoSubmitLocalPicked: 'Local file: {name}',
autoSubmitNeedDocxSource: 'Upload a .docx file, or ensure the email has a .docx attachment.',
},
crawlerKeywords: {
pageTitle: 'Keyword Configuration',
pageDesc: 'Manage core search terms for discovering experts.',
exportData: 'Export Data',
startCrawl: 'Start Crawl',
inputPlaceholder: 'Enter new keyword, e.g. deep learning, semiconductor expert...',
addKeyword: 'Add Keyword',
searchPlaceholder: 'Search keywords...',
searchBtn: 'Search',
totalCount: '{count} keyword(s) in total',
colKeyword: 'Keyword',
colCategory: 'Category',
colStatus: 'Status',
colExpertCount: 'Expert Count',
colLastCrawl: 'Last Crawl',
colAction: 'Action',
save: 'Save',
cancel: 'Cancel',
stateRunning: 'Running',
statePaused: 'Paused',
stateError: 'Error',
stateDone: 'Done',
emptyKeywordWarn: 'Please enter a keyword',
addSuccess: 'Added successfully',
addFail: 'Failed to add',
updateSuccess: 'Updated successfully',
updateFail: 'Failed to update',
deleteConfirm: 'Are you sure to delete this keyword? This action cannot be undone.',
confirmTitle: 'Confirm',
deleteSuccess: 'Deleted successfully',
deleteFail: 'Failed to delete',
refreshSuccess: 'Refreshed successfully',
refreshFail: 'Failed to refresh',
crawlStarted: 'Crawl task started',
crawlFail: 'Failed to start crawl',
exportFail: 'Failed to export',
},
crawlTask: {
pageTitle: 'Crawl Monitor',
pageDesc: 'Review crawl jobs and indexing progress for expert discovery.',
exportData: 'Export Data',
startCrawl: 'Start Crawl',
searchPlaceholder: 'Search by keyword or phrase…',
searchBtn: 'Search',
allKeywords: 'All keywords',
enabled: 'Active',
disabled: 'Paused',
allStatus: 'All Status',
statusCrawling: 'Crawling',
statusDone: 'Done',
statusPaused: 'Paused',
statusError: 'Error',
source: 'Source',
startTime: 'Start Time',
endTime: 'End Time',
progress: 'Progress',
totalPages: 'Total Pages',
crawledPages: 'Crawled Pages',
expertCountLabel: 'Experts Stored',
expertCount: 'Expert Count',
created: 'Created',
completed: 'Completed',
updated: 'Updated',
viewDetail: 'View Detail',
viewLogs: 'View Logs',
pause: 'Pause',
resume: 'Resume',
restart: 'Restart',
delete: 'Delete',
deleteConfirm: 'Are you sure to delete this task? This cannot be undone.',
confirmTitle: 'Confirm',
confirmOk: 'OK',
confirmCancel: 'Cancel',
operationSuccess: 'Operation succeeded',
operationFail: 'Operation failed',
operationRetry: 'Operation failed, please try again later',
crawlStarted: 'Crawl task started',
crawlFail: 'Failed to start crawl',
exportFail: 'Failed to export',
detailTitle: 'Task Detail',
basicInfo: 'Basic Information',
taskName: 'Task Name',
status: 'Status',
resultSummary: 'Result Summary',
totalFound: 'Total Found',
newAdded: 'New Added',
duplicates: 'Duplicates',
failed: 'Failed',
logsTitle: 'Run Logs',
noLogs: 'No logs yet',
noData: 'No tasks',
emptyResult: 'No tasks match your filters. Try adjusting keywords or status.',
addKeyword: 'Add keyword',
keyword: 'Keyword',
keywordPlaceholder: 'Enter a search term (e.g. infectious diseases)',
runOnce: 'Run once',
runOnceBtn: 'Run once',
runOnceLoading: 'Crawling...',
yes: 'Yes',
no: 'No',
cancel: 'Cancel',
confirm: 'Save',
enterKeyword: 'Please enter a keyword.',
addKeywordSuccess: 'Keyword added.',
addKeywordFailed: 'Could not add keyword.',
runOnceSuccess: 'One-off crawl requested.',
runOnceFailed: 'Could not start a one-off crawl.',
disabledMsg: 'Disabled',
enabledMsg: 'Enabled',
pauseFailed: 'Failed to pause',
resumeFailed: 'Failed to resume',
missingKeyword: 'Enter a keyword before running a one-off crawl.',
restartSuccess: 'Sync restarted (local mock)',
metricExperts: 'Experts indexed',
metricPages: 'Pages crawled',
stateRunning: 'Running',
stateStopped: 'Stopped',
taskRunningMsg: 'Crawl is now enabled.',
taskStoppedMsg: 'Crawl has been paused.',
runOnceQueued: 'A one-off crawl has been queued.',
},
mailboxSend: {
title: 'Write mail',
@@ -755,8 +1097,8 @@ colTitle: 'Template title',
step3: 'References',
step: 'step',
Information: 'Fill in information',
startPreAccept: 'Start the pre-acceptance process',
startPreAcceptWithPayment: 'Start the pre-acceptance process and complete your payment',
},
Formula: {
FormulaTemplate: 'Formula Template'
@@ -877,6 +1219,8 @@ colTitle: 'Template title',
autoSolicit: 'Auto Solicitation',
editConfig: 'Edit Configuration',
running: 'Running',
stopped: 'Stopped',
configure: 'Configure',
emailTemplate: 'Email Template',
emailStyle: 'Email Style',
notStarted: 'Auto solicitation plan is not enabled',
@@ -904,13 +1248,156 @@ colTitle: 'Template title',
styleName: 'Style Name',
defaultStyle: 'Default',
changeTemplate: 'Change Template',
selectPromotionFields: 'Select Promotion Fields',
choosePromotionFields: 'Choose Fields',
selectPromotionCountry: 'Select Country',
choosePromotionCountry: 'Choose Countries',
selectedCount: 'Selected {count}',
selectAll: 'Select All',
clearAll: 'Clear All',
selectPromotionFieldsTip: 'Multiple selection supported; leave empty for no field restriction.',
selectPromotionCountryTip: 'Multiple selection supported; leave empty for no country restriction. Uses the same API as fields until a dedicated country list is available.',
fieldSearchPlaceholder: 'Search promotion fields',
countrySearchPlaceholder: 'Search countries',
countryQuickZone1: 'Partition 1',
countryQuickZone2: 'Partition 2',
countryQuickZone3: 'Partition 3',
countryQuickChina: 'China',
countryQuickIndia: 'India',
noFieldMatch: 'No matching fields',
noCountryMatch: 'No matching countries',
confirm: 'Confirm',
fieldsSaved: 'Promotion fields saved',
countriesSaved: 'Promotion countries saved',
confirmAndEnable: 'Confirm and Enable',
onlySaveConfig: 'Save configuration only',
enableNowNextDay: 'Enable auto promotion now (starts next day)'
enableNowNextDay: 'Enable auto promotion now (starts next day)',
factoryCreateBtn: 'Create automated promotion task',
factoryBatchImportBtn: 'Batch import (JSON)',
factoryBatchImportTitle: 'Batch create tasks (JSON)',
factoryBatchImportHintShort: 'Submit a JSON array; non-empty fields merge into each row. You can still edit JSON.',
factoryBatchImportHint:
'Paste a JSON array… Pick journal (getAllJournal) or type ID; template/style/fetch_ids below override JSON when non-empty. Load promotion fields to tick IDs or type comma-separated fetch_ids; load accounts for j_email_id. expert_type "5" needs partitions/countries. Shorthand: zones, countries, email_id_list.',
factoryBatchImportCommonTip: 'Journal from cover or ID field; non-empty template ID, style ID, or fetch_ids override JSON on every row.',
factoryBatchImportJournalId: 'Journal ID',
factoryBatchImportJournalPick: 'Journal',
factoryBatchImportJournalEmpty: 'No journals returned. Check api/Journal/getAllJournal.',
factoryBatchImportJournalManualPlaceholder: 'Filled when you pick a cover, or type manually',
factoryBatchImportTemplateId: 'Template ID',
factoryBatchImportStyleId: 'Style ID',
factoryBatchImportJournalPlaceholder: 'Merged into payload',
factoryBatchImportTemplatePlaceholder: 'Merged into payload',
factoryBatchImportStylePlaceholder: 'Merged into payload',
factoryBatchImportFetchIdsLabel: 'Promotion fields (fetch_ids)',
factoryBatchImportLoadFields: 'Load available fields',
factoryBatchImportFetchTip: 'Uses current journal: pick journal, then load. Search by name or ID (multiple tokens: space or comma). “Select all” selects the filtered list when search is set, otherwise all fields. Checkboxes sync with the comma text; when non-empty, overrides fetch_ids on every row.',
factoryBatchImportFetchIdsManual: 'Merged IDs (comma-separated, editable)',
factoryBatchImportFetchIdsPlaceholder: 'e.g. 1,2,3 or use checkboxes',
factoryBatchImportNeedJournalForFields: 'Select or enter journal ID first',
factoryBatchImportLoadAccounts: 'Load accounts for journal',
factoryBatchImportAccountsApiTip: 'POST api/email_client/getAccounts with journal_id',
factoryBatchImportColEmailId: 'j_email_id',
factoryBatchImportColAddress: 'Sender address',
factoryBatchImportColQuota: 'Remaining / daily limit',
factoryBatchImportCopyEmailIds: 'Copy email_ids (comma)',
factoryBatchImportNeedJournalForAccounts: 'Enter journal ID first',
factoryBatchImportNoAccounts: 'No mailbox accounts for this journal',
factoryBatchImportAccountsFail: 'Failed to load accounts',
factoryBatchImportCopyEmailIdsEmpty: 'Load the account list first',
factoryBatchImportIdsCopied: 'Copied j_email_id list to clipboard',
factoryBatchImportCopyFail: 'Copy failed; select and copy manually',
factoryBatchImportSyncToJson: 'Apply top form to JSON',
factoryBatchImportSyncFromJson: 'Load first row into form',
factoryBatchImportSyncIncludeEmails: 'When applying, set each row email_ids from loaded accounts',
factoryBatchImportSyncTip: 'You can still edit JSON manually; non-empty top fields are merged again on submit.',
factoryBatchImportJsonFromUiOk: 'JSON updated from the form',
factoryBatchImportUiFromJsonOk: 'Form updated from the first JSON row',
factoryBatchImportRun: 'Run batch create',
factoryBatchImportBadJson: 'Invalid JSON; check brackets and quotes',
factoryBatchImportEmpty: 'Array must contain at least one object',
factoryBatchImportMissing: 'Row {index}: missing field {field}',
factoryBatchImportNeedFetch: 'Row {index}: expert database requires fetch_ids',
factoryBatchImportNeedZone: 'Row {index}: expert database requires target_partitions or target_country_ids',
factoryBatchImportRowFail: 'Row {index} failed: {msg}',
factoryBatchImportRowNetwork: 'Row {index}: request error',
factoryBatchImportDone: 'Done: {ok} succeeded, {fail} failed',
factoryBatchImportErrorsTitle: 'Errors (first 8)',
factoryDialogTitle: 'Create task',
factoryJournal: 'Journal',
factoryJournalPlaceholder: 'Select a journal',
factorySendSettings: 'Sending & scenario',
factoryEmails: 'Sender accounts',
factoryEmailsPlaceholder: 'Select one or more sender accounts',
factorySendCount: 'Send count',
factoryType: 'Scenario',
factoryTypeEditor: 'Editor',
factoryTypeArticle: 'Promote article',
factoryExpertType: 'Expert type',
factoryExpertTypePlaceholder: 'Optional; follow backend rules',
factorySubmit: 'Submit task',
factorySubmitSuccess: 'Factory task created',
factorySubmitFailed: 'Create failed, please try again later',
factoryNeedJournal: 'Please select a journal first',
factoryNeedTemplate: 'Please select email template and style',
factoryNeedEmails: 'Please select at least one sender account',
factoryNeedExpertType: 'Please select target person type',
factoryEmailsPickJournal: 'Select a journal to load sender accounts',
factoryNoAccounts: 'No mailbox accounts for this journal',
factoryAccountRemaining: 'Remaining today',
factorySendMaxFromApi: 'limit: up to {max} per day',
factorySendMaxFallback: 'using mailbox quota sum ~{max} (or default cap)',
factoryStepNav1Title: 'Journal',
factoryStepNav1Desc: 'Select a journal first.',
factoryStepNav2Title: 'Email template and style',
factoryStepNav2Desc: 'Choose template and style.',
factoryStepNav3Title: 'Sending and scenario',
factoryStepNav3Desc: 'Choose accounts, send count, and target type.',
factoryStepNav4Title: 'Promotion fields',
factoryStepNav4Desc: 'Select at least one promotion field.',
factoryStepNav5Title: 'Country',
factoryStepNav5Desc: 'Select at least one country or partition.',
factoryStepNav6Title: 'Confirm and enable',
factoryStepNav6Desc: 'Choose save only or enable next day.',
factoryPromotionFieldsBlockTip: 'Open “Choose fields” and tick at least one item; do not submit with none selected.',
factoryPromotionCountryBlockTip: 'Tick at least one partition or country; do not submit with none selected.',
factoryNeedPromotionFields: 'Select at least one promotion field before submitting.',
factoryNeedPromotionCountry: 'Select at least one partition or country before submitting.',
factoryQuotaLabel: 'Quota',
factoryClickSelectTemplate: 'Click to select email template',
factoryClickConfigureFields: 'Click to configure subject fields',
factoryBtnModify: 'Edit',
factoryBtnReset: 'Reset',
factoryBtnCancel: 'Cancel',
factoryBtnSubmit: 'Submit task',
factoryFillRequired: 'Please complete journal, template, and at least one sender account',
factoryExpertChief: 'Editor-in-Chief',
factoryExpertBoard: 'Editorial board',
factoryExpertYoungBoard: 'Young editorial board',
factoryExpertAuthor: 'Author',
factoryExpertDb: 'Expert database',
factoryExpertYoungBoardBefore2025: 'Young board (before 2025)',
factoryExpertAuthorBefore2025: 'Author (before 2025)',
factoryExpertJump: 'View',
factoryOfficialEmailTip: 'For this type, the system uses the official sender email by default. No account selection is required.',
factoryScenario: 'Scenario',
factoryScenarioPlaceholder: 'Select scenario',
factoryScenarioSolicit: 'Invite Submission',
factoryScenarioPromoteCitation: 'Promote Citation',
factoryScenarioGeneralThanks: 'General Thanks',
createdAt: 'Created at',
noFactoryTask: 'No tasks',
factoryCreateNow: 'Create now',
emailClientCreateTaskBtn: 'Create task',
emailClientCreateTaskNeedFactory: 'Please select a promotion factory task in the dropdown first',
emailClientCreateTaskSuccess: 'Task created',
emailClientCreateTaskFailed: 'Failed to create task',
emailClientCreateTaskPreparingHint: 'Task created. Generating the mailing list may take a few minutes, please wait...'
}
,
autoPromotionLogs: {
detail: 'Auto Promotion Details',
pipelineHistory: 'PIPELINE HISTORY',
factoryTaskSelectPlaceholder: 'Select promotion task',
configured: 'Configured',
editConfig: 'Edit auto promotion configuration',
startConfig: 'Start auto promotion configuration',
@@ -951,6 +1438,8 @@ colTitle: 'Template title',
enable: 'Enable',
pause: 'Pause',
previewEditTitle: 'Preview and edit promotion email',
logDetailEditTitle: 'Edit promotion send log',
logDetailPreviewTitle: 'Preview promotion send log',
receiver: 'Receiver:',
receiverImmutablePlaceholder: 'Receiver email cannot be changed',
subject: 'Subject:',
@@ -975,6 +1464,7 @@ colTitle: 'Template title',
taskLogState2: 'Failed',
taskLogState3: 'Bounced',
taskLogState4: 'Cancelled',
logColIndex: 'No.',
logColExpert: 'Expert',
logColSendTime: 'Sent at',
logColPreparedAt: 'Prepared at',
@@ -1007,6 +1497,10 @@ colTitle: 'Template title',
},
tmrEmailEditor: {
preview: 'Preview',
previewWithVariables: 'Preview (showing variables)',
previewWithVariablesTitle: 'Preview (showing variables)',
previewWithVariablesHint: 'The expert data is an example, used for variable spelling check only.',
close: 'Close',
placeholder: 'Please enter email content'
}

View File

@@ -19,6 +19,13 @@ const zh = {
}, info: {
realname: '英文名字只能包含大小写英文字母、"-" 、和 空格'
},
articleAdd: {
qqEmailAlertLine1: '出版社暂不支持qq邮箱投稿。',
qqEmailAlertLine2Before: '请前往',
qqEmailDashboardLink: '个人中心Dashboard',
qqEmailAlertLine2After: '更换邮箱账号。',
qqEmailSubmitBlockedMsg: '出版社暂不支持qq邮箱投稿请去个人中心更换邮箱账号'
},
total: {
author: '作者',
editor: '编辑',
@@ -38,6 +45,25 @@ const zh = {
status: '状态',
delete: '删除',
deleteInfo: '您确定要删除该期刊分期吗?',
plagiarismNotChecked: '未检测',
plagiarismChecking: '正在检测…',
plagiarismRecheck: '重新查重',
plagiarismDuplicateCheck: '重复检查',
plagiarismCheckFailed: '查重任务启动失败。',
plagiarismStatusFailed: '获取查重状态失败。',
plagiarismNoReportUrl: '报告链接暂不可用。',
plagiarismReportDetailFailed: '获取稿件详情失败,请稍后重试。',
plagiarismListTitle: '自动查重记录',
plagiarismAutoCheck: '自动查重',
plagiarismRefresh: '刷新',
plagiarismEmptyList: '暂无查重记录',
plagiarismSimilarity: '相似度',
plagiarismFile: '文件',
plagiarismPreviewPdf: '预览报告',
plagiarismReportLink: '报告',
plagiarismNoPdfLink: '无链接',
plagiarismPreviewClose: '关闭',
plagiarismPreviewOpenTab: '新窗口打开',
},
menu: {
main: '个人中心',
@@ -264,7 +290,12 @@ const zh = {
scholarCrawlers: '学者数据库',
crawlerKeywords: '抓取关键词配置',
expertDatabase: '专家库',
keywordManagement: '关键词管理',
crawlTasks: '抓取任务',
expertList: '专家列表',
countryManagement: '国家信息',
autoPromotion: '自动推广',
ReArticles: '被拒稿件', // 被拒稿件
editorialBoard: '编委管理',
@@ -283,6 +314,7 @@ const zh = {
expertDatabase: {
fieldSelectPlaceholder: '请选择研究领域',
keywordPlaceholder: '姓名 / 邮箱 / 单位',
fieldPlaceholder: '请输入领域 field',
searchBtn: '搜索',
resetBtn: '重置',
downloadExcelBtn: '下载 Excel',
@@ -291,16 +323,109 @@ const zh = {
},
columns: {
baseInfo: '基础信息',
country: '国家',
affiliation: '单位',
researchAreas: '研究领域'
researchAreas: '研究领域',
unsubscribeStatus: '订阅状态'
},
emptyMark: '-',
fields: {
nameLabel: '姓名:',
emailLabel: '邮箱:',
acquisitionTimeLabel: '采集时间:'
},
exportWarn: '请选择研究领域或输入关键词后再导出。',
exportFailed: '导出失败'
viewAllInfo: '查看全部信息',
detailDialogTitle: '领域与文章',
detailColField: '研究领域',
detailColPaper: '文章标题',
detailColJournal: '所属期刊',
detailClose: '关闭',
detailCellEmpty: '暂无',
noFieldDetail: '暂无领域对应的文献信息',
exportWarn: '请选择研究领域或输入关键词或领域 field 后再导出。',
exportFailed: '导出失败',
unsubscribeNormal: '已订阅',
unsubscribeUnsubscribed: '已退订',
unsubscribeSwitchOn: '退订',
unsubscribeSwitchOff: '已订阅',
unsubscribeMissingId: '缺少专家 ID无法切换退订状态',
unsubscribeUpdateSuccess: '退订状态更新成功',
unsubscribeUpdateFailed: '退订状态更新失败'
},
countryManagement: {
title: '国家信息维护',
keywordPlaceholder: '中文名 / 英文名 / 代码',
partitionLabel: '分区',
partitionAll: '全部分区',
partition1: '分区 1',
partition2: '分区 2',
partition3: '分区 3',
searchBtn: '搜索',
resetBtn: '重置',
table: {
no: '序号',
zhName: '中文名称',
enName: '英文名称',
code: '代码',
partition: '分区',
actions: '操作'
},
edit: '修改',
delete: '删除',
editTitle: '编辑国家信息',
form: {
zhName: '中文名称',
enName: '英文名称',
code: '代码',
partition: '分区'
},
cancel: '取消',
save: '保存',
confirm: '确定',
deleteTitle: '删除确认',
deleteConfirm: '确定删除该国家信息?删除后不可恢复。',
saveSuccess: '保存成功',
deleteSuccess: '删除成功',
opFailed: '操作失败',
loadFailed: '加载列表失败',
missingId: '缺少国家编号,无法删除',
ruleZhName: '请输入中文名称',
ruleEnName: '请输入英文名称',
ruleCode: '请输入代码',
rulePartition: '请选择分区',
batchPartitionBtn: '批量修改分区',
batchPartitionTitle: '批量修改分区',
batchPartitionTargetLabel: '目标分区',
batchPartitionTargetRequired: '请先选择目标分区',
batchPartitionHelp:
'每行一条:国家代码(3位) 或 英文名 或 中文名。# 开头为注释。示例:\nDNK\nDenmark\n丹麦',
batchPartitionPlaceholder: '粘贴数据,例如:\nDNK\nISR',
batchPartitionPreview: '预览匹配',
batchPartitionApply: '确认写入分区',
batchPartitionEmpty: '请先填写有效数据(每行一个国家标识)',
batchPartitionLoadListFailed: '拉取全量国家列表失败',
batchPartitionPreviewEmpty: '没有可匹配的行',
batchPartitionColKey: '输入标识',
batchPartitionColCurrentPartition: '当前分区',
batchPartitionColPartition: '目标分区',
batchPartitionColMatch: '匹配结果',
batchPartitionColId: 'country_id',
batchPartitionColName: '英文名',
batchPartitionMatched: '已匹配',
batchPartitionMismatch: '分区不一致',
batchPartitionMissing: '未匹配',
batchPartitionSkipSame: '分区相同',
batchPartitionApplyConfirm: '将按当前规则仅更新分区字段,共 {n} 条。是否继续?',
batchPartitionFilterPlaceholder: '筛选:输入标识 / 英文名 / 代码 / 中文名(可用分号、逗号分隔多个关键词)',
batchPartitionSelectAllFiltered: '全选当前筛选',
batchPartitionClearSelection: '取消全选',
batchPartitionFilterCount: '当前显示 {show} / 共 {total} 条',
batchPartitionSelectionHint:
'说明:可先在下方筛选,再勾选要提交的行。「全选」只勾选当前筛选结果中、且分区有变化的行。未勾选任何行时,将提交全部「将提交更新」的行。',
batchPartitionNoSelection: '当前为「仅勾选」模式,请至少勾选一行,或点「取消全选」恢复为提交全部。',
batchPartitionDone: '批量完成:本次录入 {input} 条;更新成功 {ok} 条,失败 {fail} 条;未匹配 {miss} 条。',
batchPartitionSummaryLine:
'统计:录入 {input} 条;已匹配 {matched} 条;未匹配 {miss} 条;分区相同跳过 {same} 条;将提交更新 {will} 条。'
},
mailboxConfig: {
mailSystem: '邮件系统',
@@ -402,6 +527,23 @@ const zh = {
deleteFail: '删除失败',
previewTitle: '模板预览',
previewClose: '关闭',
batchImportBtn: '批量导入',
batchImportTitle: '批量导入邮件模板JSON',
batchImportHint:
'粘贴 JSON 数组,每条对应一次保存接口(新建不传 template_id更新可带 template_id。字段与编辑页一致title、subject、scene、language可用 lang、version、body_html可用 body、variables_json可用 variables、is_active。',
batchImportCommonTip: '期刊 ID 在下方单独填写;若填写非空,会覆盖每条 JSON 中的 journal_id / journalId。',
batchImportJournalId: '期刊 ID',
batchImportJournalPlaceholder: '可与列表筛选一致,或手填',
batchImportRun: '开始导入',
batchImportBadJson: 'JSON 解析失败',
batchImportEmpty: '请至少包含一条对象',
batchImportMissingJournal: '第 {index} 条:缺少期刊 ID请填写上方输入框或在 JSON 中提供 journal_id',
batchImportMissingField: '第 {index} 条:缺少字段 {field}',
batchImportRowFail: '第 {index} 条保存失败:{msg}',
batchImportRowNetwork: '第 {index} 条请求异常',
batchImportDone: '完成:成功 {ok},失败 {fail}',
batchImportErrorsTitle: '失败明细(最多 8 条)',
batchImportSaveFail: '保存失败',
},
mailboxStyle: {
title: '邮件风格',
@@ -520,6 +662,202 @@ const zh = {
noMore: '没有更多了',
yesterday: '昨天',
selectAccountTip: '请先选择一个邮箱账号.',
attachmentCount: '个附件',
downloadFail: '下载失败',
accountNotBelong: '该邮箱账号不属于当前用户,请重新选择',
totalAttachments: '共{count}个附件',
etcSuffix: '... 等',
viewAttachments: '查看附件',
newMailArrived: '收到 {count} 封新邮件,点击查看',
downloadAllZip: '打包下载',
packingAttachments: '正在打包附件,请稍后...',
downloadBtn: '下载',
printBtn: '打印',
previewNotSupported: '该文件格式无法在线预览',
downloadToView: '下载到本地查看',
registerAuthorBtn: '自动投稿',
registerAuthorConfirm:
'将使用推广后台「添加用户」接口创建账号:登录名「{account}」,显示名「{realname}」,邮箱「{email}」,初始密码 123456qwe无需验证码。是否继续',
registerAuthorSuccess: '作者账号已创建。',
registerAuthorSuccessWithEmail: '已创建:{email}密码123456qwe',
registerAuthorFail: '创建失败,请稍后重试或到用户管理中手动添加。',
registerAuthorExistsEmail: '该邮箱已被注册。',
registerAuthorExistsAccount: '该登录名已被占用,请人工处理或修改发件人显示名后重试。',
registerAuthorNeedEmail: '缺少发件人邮箱,无法创建账号。',
registerAuthorNoQq: '本站不支持 QQ 邮箱作为作者账号,请在用户管理中手动添加。',
registerAuthorConfirmShort: '邮箱:{email}\n密码{password}',
registerAuthorPickEmailFail: '多次尝试后仍无法分配到可用邮箱,请稍后重试或手动添加用户。',
autoSubmitBtn: '附件一键建稿',
autoSubmitTitle: '用作者账号登录并上传首份 .docx 附件',
autoSubmitSessionTip:
'作者 checkLogin 不会修改顶部 U_* 本地账号;上传期间服务端 Cookie 为作者。完成后请填写「编辑密码」再登录一次以恢复编辑服务端会话。',
autoSubmitUsername: '登录名',
autoSubmitPassword: '密码',
autoSubmitSenderEmailLabel: '发件人邮箱',
autoSubmitSenderEmailPlaceholder: '(未识别发件人邮箱)',
autoSubmitCode: '验证码',
autoSubmitCodePh: '若后台要求验证码则填写,一般可留空',
autoSubmitCancel: '取消',
autoSubmitDialogTitle: '自动投稿',
autoSubmitConfirm: '自动投稿',
autoSubmitExistingAccountTip:
'该发件邮箱可能已有账号。请填写密码;本地文件可多次重选,新文件会替换上一份。',
autoSubmitNotifyMailSubject: '【{journal}】投稿完善提醒',
autoSubmitNotifyMailFail: '通知邮件发送失败,可稍后在发件页手动补发。',
autoSubmitNotifyMailSkipped: '未识别发件邮箱账号,已跳过自动发信。',
autoSubmitNoDocx: '邮件中无 .docx 附件(仅支持 docx与新增稿件一致。',
autoSubmitDownloadFail: '无法下载附件,请稍后重试。',
autoSubmitSuccessTitle: '建稿成功',
autoSubmitDialogClose: '关闭',
autoSubmitSuccessLineAccount: '账号{account}',
autoSubmitSuccessLinePassword: '密码{password}',
autoSubmitSuccessLineDraft: '稿号id{id} 已创建草稿箱',
autoSubmitSuccessLineLinkPrefix: '稿件链接地址是:',
autoSubmitSuccessMailSent: '邮件通知已发送',
autoSubmitSuccessMailSkipped: '未发送通知邮件(未配置期刊发件邮箱)',
autoSubmitSuccessMailSkippedRecipient: '未发送通知邮件(邮件中缺少有效发件人邮箱)',
autoSubmitSuccessMailFailed: '邮件通知未发送,请稍后在发件页补发',
autoSubmitSuccessBodyLocalOnly:
'文章 ID{id}。界面仍为当前编辑账号;服务端在作者登录后可能仍为作者会话,请刷新或重新登录编辑账号。',
autoSubmitSuccessBodyServerRestored: '文章 ID{id}。已通过编辑账号重新登录,本地与服务端会话均已恢复为编辑。',
autoSubmitSuccessNotify:
'文章 ID{id}<br/><a href="{link}" target="_blank" rel="noopener noreferrer">打开 articleAdd 继续编辑</a>',
autoSubmitEditorRestorePwd: '编辑密码(恢复会话)',
autoSubmitEditorRestorePwdPh: '选填:与顶部当前登录名对应的编辑密码,用于上传成功后恢复服务端会话',
autoSubmitEditorReloginFail: '恢复编辑服务端会话失败,已尽量恢复本地信息,请刷新页面或重新登录。',
autoSubmitFail: '建稿失败,请检查账号密码或网络后重试。',
autoSubmitUsernameRequired: '请填写登录名',
autoSubmitPasswordRequired: '请填写密码',
autoSubmitJournalLabel: '目标期刊',
autoSubmitJournalUnknown: '未识别期刊(请先在邮件列表切换绑定期刊的邮箱账号)',
autoSubmitNeedJournal: '当前邮箱账号没有期刊信息,无法与投稿页一致回填。请通过「切换邮箱账号」选择绑定期刊的账号。',
autoSubmitFailPartial: '(若 contribute 已成功,文章 ID 可能为:{id},请到后台核对)',
autoSubmitManuscriptSource: '稿件文件',
autoSubmitPickLocalDocx: '本地上传 .docx',
autoSubmitSourceHint: '可选本地上传;否则使用邮件中第一份 .docx。再次选择会替换当前本机文件。',
autoSubmitLocalPicked: '当前本机文件:{name}',
autoSubmitNeedDocxSource: '请在本地上传 .docx或确保邮件中带有 .docx 附件。',
},
crawlerKeywords: {
pageTitle: '关键词配置',
pageDesc: '管理用于发现专家的核心搜索词。',
exportData: '导出数据',
startCrawl: '开始抓取',
inputPlaceholder: '输入新关键词,例如:深度学习、半导体专家...',
addKeyword: '添加关键词',
searchPlaceholder: '搜索关键词...',
searchBtn: '搜索',
totalCount: '共 {count} 个词条',
colKeyword: '关键词',
colCategory: '分类',
colStatus: '状态',
colExpertCount: '专家数量',
colLastCrawl: '最后抓取',
colAction: '操作',
save: '保存',
cancel: '取消',
stateRunning: '运行中',
statePaused: '已暂停',
stateError: '异常',
stateDone: '已完成',
emptyKeywordWarn: '请输入关键词',
addSuccess: '添加成功',
addFail: '添加失败',
updateSuccess: '更新成功',
updateFail: '更新失败',
deleteConfirm: '确定删除该关键词吗?删除后无法恢复。',
confirmTitle: '提示',
deleteSuccess: '删除成功',
deleteFail: '删除失败',
refreshSuccess: '刷新成功',
refreshFail: '刷新失败',
crawlStarted: '抓取任务已启动',
crawlFail: '启动抓取失败',
exportFail: '导出失败',
},
crawlTask: {
pageTitle: '任务监控',
pageDesc: '查看当前正在运行和已完成的抓取任务。',
exportData: '导出数据',
startCrawl: '开始抓取',
searchPlaceholder: '按关键词搜索...',
searchBtn: '搜索',
allKeywords: '全部关键词',
enabled: '启用',
disabled: '停用',
allStatus: '全部状态',
statusCrawling: '抓取中',
statusDone: '已完成',
statusPaused: '已暂停',
statusError: '异常',
source: '来源',
startTime: '开始时间',
endTime: '结束时间',
progress: '进度',
totalPages: '总页数',
crawledPages: '已抓取页数',
expertCountLabel: '入库专家数',
expertCount: '专家数量',
created: '创建',
completed: '完成',
updated: '更新',
viewDetail: '查看详情',
viewLogs: '查看日志',
pause: '暂停',
resume: '继续',
restart: '重新抓取',
delete: '删除',
deleteConfirm: '确定删除该任务吗?删除后无法恢复。',
confirmTitle: '提示',
confirmOk: '确定',
confirmCancel: '取消',
operationSuccess: '操作成功',
operationFail: '操作失败',
operationRetry: '操作失败,请稍后重试',
crawlStarted: '抓取任务已启动',
crawlFail: '启动抓取失败',
exportFail: '导出失败',
detailTitle: '任务详情',
basicInfo: '基本信息',
taskName: '任务名称',
status: '状态',
resultSummary: '抓取结果',
totalFound: '发现总数',
newAdded: '新增',
duplicates: '重复',
failed: '失败',
logsTitle: '运行日志',
noLogs: '暂无日志',
noData: '暂无任务',
emptyResult: '未发现匹配的任务记录',
addKeyword: '新建关键词',
keyword: '关键词',
keywordPlaceholder: '请输入关键词Infectious Diseases',
runOnce: '单次抓取',
runOnceBtn: '单次抓取',
runOnceLoading: '抓取中...',
yes: '是',
no: '否',
cancel: '取消',
confirm: '确定',
enterKeyword: '请输入关键词',
addKeywordSuccess: '新增关键词成功',
addKeywordFailed: '新增关键词失败',
runOnceSuccess: '已触发单次抓取',
runOnceFailed: '单次抓取触发失败',
disabledMsg: '已停用',
enabledMsg: '已启用',
pauseFailed: '暂停失败',
resumeFailed: '恢复失败',
missingKeyword: '缺少关键词,无法执行单次抓取',
restartSuccess: '已重启同步(本地模拟)',
metricExperts: '入库专家',
metricPages: '采集页数',
stateRunning: '运行中',
stateStopped: '停止',
taskRunningMsg: '任务已启动',
taskStoppedMsg: '任务已禁用',
runOnceQueued: '单次采集指令已下发',
},
mailboxSend: {
title: '写邮件',
@@ -744,8 +1082,8 @@ const zh = {
step3: '参考',
step: 'step',
Information: 'Fill in information',
startPreAccept: '开始预接收流程',
startPreAcceptWithPayment: '开始预接收流程并完成支付',
},
Formula:{
FormulaTemplate:'公式模版'
@@ -862,6 +1200,8 @@ const zh = {
autoSolicit: '自动约稿',
editConfig: '修改配置',
running: '运行中',
stopped: '已停止',
configure: '配置',
emailTemplate: '邮件模板',
emailStyle: '邮件风格',
notStarted: '未开启自动约稿计划',
@@ -889,13 +1229,156 @@ const zh = {
styleName: '风格名称',
defaultStyle: '默认风格',
changeTemplate: '更换模版',
selectPromotionFields: '选择推广领域',
choosePromotionFields: '选择领域',
selectPromotionCountry: '选择国家',
choosePromotionCountry: '选择国家',
selectedCount: '已选 {count} 项',
selectAll: '全选',
clearAll: '取消全选',
selectPromotionFieldsTip: '可多选;未选择则不限制推广领域。',
selectPromotionCountryTip: '可多选;未选择则不限制国家。与领域接口一致,后续可对接独立国家数据。',
fieldSearchPlaceholder: '搜索推广领域',
countrySearchPlaceholder: '搜索国家',
countryQuickZone1: '1区',
countryQuickZone2: '2区',
countryQuickZone3: '3区',
countryQuickChina: 'China',
countryQuickIndia: 'India',
noFieldMatch: '没有匹配的领域',
noCountryMatch: '没有匹配的国家',
confirm: '确定',
fieldsSaved: '推广领域已保存',
countriesSaved: '推广国家已保存',
confirmAndEnable: '确认并开启',
onlySaveConfig: '仅保存配置',
enableNowNextDay: '立即激活自动推广(次日开始自动推广)'
enableNowNextDay: '立即激活自动推广(次日开始自动推广)',
factoryCreateBtn: '创建自动化推广任务',
factoryBatchImportBtn: '临时批量导入',
factoryBatchImportTitle: '批量创建推广任务JSON',
factoryBatchImportHintShort: '数组提交;上方非空项会合并进每条请求,仍可直接改 JSON。',
factoryBatchImportHint:
'粘贴 JSON 数组…期刊用封面getAllJournal或手填 ID模板/样式/推广领域 fetch_ids 在下方非空则覆盖 JSON。推广领域可「加载可选领域」勾选或手改逗号 ID填期刊后可查邮箱 j_email_idgetAccounts。expert_type 为 5 时须分区或国家。简写zones、countries、email_id_list。',
factoryBatchImportCommonTip: '期刊以封面或下方 ID 为准;模板 ID、样式 ID、推广领域 fetch_ids 任一非空则覆盖每条 JSON 中对应字段后再请求接口。',
factoryBatchImportJournalId: '期刊 ID',
factoryBatchImportJournalPick: '选择期刊',
factoryBatchImportJournalEmpty: '未获取到期刊列表,请检查接口或稍后重试',
factoryBatchImportJournalManualPlaceholder: '点击上方封面自动填入,也可手改',
factoryBatchImportTemplateId: '模板 ID',
factoryBatchImportStyleId: '样式 ID',
factoryBatchImportJournalPlaceholder: '与 JSON 合并',
factoryBatchImportTemplatePlaceholder: '与 JSON 合并',
factoryBatchImportStylePlaceholder: '与 JSON 合并',
factoryBatchImportFetchIdsLabel: '推广领域 fetch_ids',
factoryBatchImportLoadFields: '加载可选领域',
factoryBatchImportFetchTip: '依赖当前期刊:先选期刊再加载。上方可搜索名称或 ID多关键词用空格或逗号有搜索时「全选」勾选当前筛选结果无搜索时「全选」为全部。勾选与下方逗号文本同步非空则覆盖每条 JSON 的 fetch_ids。',
factoryBatchImportFetchIdsManual: '合并用 ID逗号分隔可手改',
factoryBatchImportFetchIdsPlaceholder: '例1,2,3或与勾选联动',
factoryBatchImportNeedJournalForFields: '请先选择或填写期刊 ID',
factoryBatchImportLoadAccounts: '查询该期刊邮箱',
factoryBatchImportAccountsApiTip: 'POST api/email_client/getAccounts参数 journal_id',
factoryBatchImportColEmailId: 'j_email_id',
factoryBatchImportColAddress: '发件地址',
factoryBatchImportColQuota: '今日剩余 / 日上限',
factoryBatchImportCopyEmailIds: '复制 email_ids逗号',
factoryBatchImportNeedJournalForAccounts: '请先填写期刊 ID',
factoryBatchImportNoAccounts: '该期刊下暂无邮箱账号',
factoryBatchImportAccountsFail: '拉取邮箱列表失败',
factoryBatchImportCopyEmailIdsEmpty: '请先查询出账号列表',
factoryBatchImportIdsCopied: '已复制 j_email_id 列表到剪贴板',
factoryBatchImportCopyFail: '复制失败,请手动选中复制',
factoryBatchImportSyncToJson: '上方选项写入 JSON',
factoryBatchImportSyncFromJson: '首条 JSON 回显到上方',
factoryBatchImportSyncIncludeEmails: '写入时用当前邮箱列表覆盖每条 email_ids',
factoryBatchImportSyncTip: '写入后仍可单独改 JSON提交时若上方输入框非空仍会再合并覆盖。',
factoryBatchImportJsonFromUiOk: '已根据上方选项更新 JSON',
factoryBatchImportUiFromJsonOk: '已用首条 JSON 更新上方表单',
factoryBatchImportRun: '开始批量创建',
factoryBatchImportBadJson: 'JSON 解析失败,请检查括号与引号',
factoryBatchImportEmpty: '请至少包含一条对象',
factoryBatchImportMissing: '第 {index} 条缺少字段:{field}',
factoryBatchImportNeedFetch: '第 {index} 条:专家库需填写 fetch_ids推广领域',
factoryBatchImportNeedZone: '第 {index} 条专家库需至少填写分区或国家target_partitions / target_country_ids',
factoryBatchImportRowFail: '第 {index} 条创建失败:{msg}',
factoryBatchImportRowNetwork: '第 {index} 条请求异常',
factoryBatchImportDone: '完成:成功 {ok},失败 {fail}',
factoryBatchImportErrorsTitle: '失败明细(最多显示 8 条)',
factoryDialogTitle: '创建任务',
factoryJournal: '期刊',
factoryJournalPlaceholder: '请选择期刊',
factorySendSettings: '发送与场景',
factoryEmails: '发送邮箱',
factoryEmailsPlaceholder: '请选择发送账号(可多选)',
factorySendCount: '发送数量',
factoryType: '场景类型',
factoryTypeEditor: '编辑',
factoryTypeArticle: '推广文章',
factoryExpertType: '专家类型',
factoryExpertTypePlaceholder: '可选,按后端要求填写',
factorySubmit: '提交任务',
factorySubmitSuccess: '工厂任务已创建',
factorySubmitFailed: '创建失败,请稍后重试',
factoryNeedJournal: '请先选择期刊',
factoryNeedTemplate: '请先选择邮件模板与样式',
factoryNeedEmails: '请至少选择一个发送邮箱',
factoryNeedExpertType: '请选择目标人类型',
factoryEmailsPickJournal: '请先选择期刊以加载邮箱列表',
factoryNoAccounts: '该期刊下暂无可用邮箱账号',
factoryAccountRemaining: '今日剩余',
factorySendMaxFromApi: '接口限制:单日最多 {max} 封',
factorySendMaxFallback: '未返回接口上限,当前按邮箱额度合计约 {max} 封(或默认上限)',
factoryStepNav1Title: '期刊',
factoryStepNav1Desc: '先选期刊,未选不能提交。',
factoryStepNav2Title: '邮件模版与样式',
factoryStepNav2Desc: '选好邮件模板和样式。',
factoryStepNav3Title: '发送与场景',
factoryStepNav3Desc: '选账号,填发送数量和目标人类型。',
factoryStepNav4Title: '推广领域',
factoryStepNav4Desc: '至少选择一个推广领域。',
factoryStepNav5Title: '国家',
factoryStepNav5Desc: '至少选择一个国家或分区。',
factoryStepNav6Title: '确认并开启',
factoryStepNav6Desc: '选择仅保存或次日自动开启。',
factoryPromotionFieldsBlockTip: '请打开「选择领域」,在列表中至少勾选一项;不得留空提交。',
factoryPromotionCountryBlockTip: '请至少勾选一项分区或国家;不得留空提交。',
factoryNeedPromotionFields: '请至少选择一项推广领域后再提交。',
factoryNeedPromotionCountry: '请至少选择一项分区或国家后再提交。',
factoryQuotaLabel: '额度',
factoryClickSelectTemplate: '点击选择邮件模板',
factoryClickConfigureFields: '点击配置学科字段',
factoryBtnModify: '修改',
factoryBtnReset: '重置',
factoryBtnCancel: '取消',
factoryBtnSubmit: '立即提交任务',
factoryFillRequired: '请完善必填信息(期刊、模板、账号)',
factoryExpertChief: '主编',
factoryExpertBoard: '编委',
factoryExpertYoungBoard: '青年编委',
factoryExpertAuthor: '作者',
factoryExpertDb: 'expert库',
factoryExpertYoungBoardBefore2025: '2025前青年编委',
factoryExpertAuthorBefore2025: '2025前作者',
factoryExpertJump: '查看',
factoryOfficialEmailTip: '此类型默认使用系统官方邮箱发送,无需选择邮箱账号。',
factoryScenario: '场景',
factoryScenarioPlaceholder: '请选择场景',
factoryScenarioSolicit: '约稿',
factoryScenarioPromoteCitation: '推广引用',
factoryScenarioGeneralThanks: '常规感谢',
createdAt: '创建时间',
noFactoryTask: '没有任务',
factoryCreateNow: '立即创建',
emailClientCreateTaskBtn: '创建任务',
emailClientCreateTaskNeedFactory: '请先在下拉框中选择推广工厂任务',
emailClientCreateTaskSuccess: '创建任务成功',
emailClientCreateTaskFailed: '创建任务失败',
emailClientCreateTaskPreparingHint: '创建任务成功,生成发送邮件列表需要几分钟,请耐心等候...'
}
,
autoPromotionLogs: {
detail: '自动推广详情',
pipelineHistory: '流水线历史',
factoryTaskSelectPlaceholder: '选择推广任务',
configured: '已配置',
editConfig: '修改期刊自动推广配置',
startConfig: '立即开始期刊自动推广配置',
@@ -936,6 +1419,8 @@ const zh = {
enable: '开启',
pause: '暂停',
previewEditTitle: '预览并修改推广邮件',
logDetailEditTitle: '编辑推广发送记录',
logDetailPreviewTitle: '预览推广发送记录',
receiver: '收件人:',
receiverImmutablePlaceholder: '收件人邮箱不可更改',
subject: '主题:',
@@ -960,6 +1445,7 @@ const zh = {
taskLogState2: '失败',
taskLogState3: '退信',
taskLogState4: '取消',
logColIndex: '序号',
logColExpert: '专家信息',
logColSendTime: '发送时间',
logColPreparedAt: '预处理完成时间',
@@ -992,6 +1478,10 @@ const zh = {
},
tmrEmailEditor: {
preview: '预览效果',
previewWithVariables: '预览(示例变量)',
previewWithVariablesTitle: '预览效果(已替换示例变量)',
previewWithVariablesHint: '专家数据仅为示例,仅用于变量拼写检查。',
close: '关闭',
placeholder: '请输入邮件内容'
}

View File

@@ -859,8 +859,8 @@
<el-form-item label="Account :" prop="account">
<span>{{ coreForm.account }}</span>
</el-form-item>
<el-form-item label="Email :">
<span>{{ coreForm.email }}</span>
<el-form-item label="Email :" prop="email">
<el-input type="text" placeholder="Please enter email..." v-model="coreForm.email" style="width: 320px" clearable />
</el-form-item>
<el-form-item label="Real name :" prop="realname">
@@ -1610,6 +1610,14 @@ this.applyCvitaTable = data;
.then((res) => {
if (res.code == 0) {
this.coreTable = res.data.baseInfo;
try {
if (this.coreTable && this.coreTable.email != null && this.coreTable.email !== undefined) {
localStorage.setItem('U_email', String(this.coreTable.email));
}
if (this.coreTable && this.coreTable.realname != null && this.coreTable.realname !== undefined) {
localStorage.setItem('U_relname', String(this.coreTable.realname));
}
} catch (e) {}
this.majorsList=res.data.baseInfo.majors;
this.cvitaForm.user_id = res.data.baseInfo.user_id;
this.reviewForm.user_id = res.data.baseInfo.user_id;
@@ -1901,6 +1909,14 @@ this.applyCvitaTable = data;
.then((res) => {
if (res.code == 0) {
this.$message.success('Personal information modified successfully!');
try {
if (this.coreForm.email != null && this.coreForm.email !== undefined) {
localStorage.setItem('U_email', String(this.coreForm.email));
}
if (this.coreForm.realname != null && this.coreForm.realname !== undefined) {
localStorage.setItem('U_relname', String(this.coreForm.realname));
}
} catch (e) {}
this.coreVisible = false;
this.tipVisible = false;
this.getPersonData();

View File

@@ -1679,13 +1679,13 @@ export default {
},
async onDrop(event, dataId) {
const loading = this.$loading({
if (event.dataTransfer.getData('image')) {
const loading = this.$loading({
lock: true,
text: 'Loading...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
});
if (event.dataTransfer.getData('image')) {
const draggedImage = JSON.parse(event.dataTransfer.getData('image'));
const draggedImageIndex = JSON.parse(event.dataTransfer.getData('imageIndex'));
this.$nextTick(async () => {
@@ -1712,7 +1712,13 @@ export default {
this.$message.error(err.msg);
});
});
} else {
} else if(event.dataTransfer.getData('table')) {
const loading = this.$loading({
lock: true,
text: 'Loading...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
});
const draggedtable = JSON.parse(event.dataTransfer.getData('table'));
this.$nextTick(async () => {
@@ -1739,6 +1745,8 @@ export default {
this.$message.error(err.msg);
});
});
}else{
}
},
getCommentList() {

View File

@@ -263,7 +263,39 @@
<i v-else class="el-icon-plus avatar-uploader-icon" style="line-height: 120px"></i>
</el-upload>
</div>
</el-form-item>
<el-form-item label="Contact Editor WeChat QR Code :" prop="wechat_yboard_qrcode">
<div class="wechat-name-split__right">
<div class="yboard-qrcode-side__label"></div>
<div class="portrait WeChatCode">
<el-upload
class="avatar-uploader"
ref="upIconIMgYboard"
:action="baseUrl + 'api/Journal/uploadYboardQrcode'"
:show-file-list="false"
name="qrcode_url"
:on-success="handleAvatarSuccessYboard"
:on-error="handleAvatarErrorYboard"
:before-upload="beforeAvatarUpload2"
>
<img
v-if="detailForm.wechat_yboard_qrcode"
:src="
/^https?:\/\//.test(detailForm.wechat_yboard_qrcode)
? detailForm.wechat_yboard_qrcode
: mediaUrl + 'journalyboardqrcode/' + detailForm.wechat_yboard_qrcode
"
class="avatar"
accept=".png,.jpg"
/>
<i v-else class="el-icon-plus avatar-uploader-icon" style="line-height: 120px"></i>
</el-upload>
</div>
</div>
</el-form-item>
<el-form-item label="Journal Topic :" prop="ResearchAreas">
<el-button @click="addArea" size="mini" type="primary" plain style="position: absolute; right: 0; margin-left: 10px"
>+ Add</el-button
@@ -283,8 +315,13 @@
</div>
</div>
</el-form-item>
<el-form-item label="Wechat Name :" prop="wechat_name">
<el-input v-model="detailForm.wechat_name" placeholder="eg:公众号名称"></el-input>
<el-form-item label="Wechat Name :" prop="wechat_name" class="wechat-name-split">
<div class="wechat-name-split__inner">
<div class="wechat-name-split__left">
<el-input v-model="detailForm.wechat_name" placeholder="eg:公众号名称"></el-input>
</div>
</div>
</el-form-item>
<el-form-item label="Wechat APP ID :" prop="wechat_app_id">
<el-input v-model="detailForm.wechat_app_id" placeholder="eg:公众号app_id"></el-input>
@@ -657,6 +694,17 @@ export default {
this.$message.error(res.msg);
}
},
async handleAvatarSuccessYboard(res, file) {
if (res.code == 0) {
this.detailForm.wechat_yboard_qrcode = res.upurl;
this.$forceUpdate();
} else {
this.$message.error(res.msg);
}
},
handleAvatarErrorYboard(res, file) {
// no-op
},
handleAvatarError2(res, file) {
// this.$message.error(res);
},
@@ -883,7 +931,9 @@ export default {
epassword: data.epassword,
kfen: data.kfen,
fee: data.fee,
databases: data.databases || data.database_inclusion || '',
editor_qrcode: data.editor_qrcode,
wechat_yboard_qrcode: data.wechat_yboard_qrcode || data.yboard_qrcode,
scope: data.scope,
abstract_chinese: data.abstract_chinese,
publish_author: data.publish_author,
@@ -1369,6 +1419,29 @@ export default {
border-radius: 110px;
}
/* Wechat Name + Apply Youth Board QR两列对齐到右侧 */
.wechat-name-split__inner {
display: flex;
align-items: flex-start;
gap: 16px;
}
.wechat-name-split__left {
flex: 1;
min-width: 200px;
}
.wechat-name-split__right {
width: 160px;
}
.yboard-qrcode-side__label {
font-size: 12px;
color: #666;
text-align: center;
margin-bottom: 8px;
}
.portrait .ptmark {
position: absolute;
top: 0;

View File

@@ -215,6 +215,8 @@
localStorage.setItem('U_role', 'superadmin');
localStorage.setItem('U_name', res.userinfo.account);
localStorage.setItem('U_id', res.userinfo.user_id);
localStorage.setItem('U_email', '');
this.$router.push('/');
} else if (res.data.roles.includes('editor')) {
@@ -222,6 +224,7 @@
localStorage.setItem('U_role', res.data.roles);
localStorage.setItem('U_name', res.data.userinfo.account);
localStorage.setItem('U_id', res.data.userinfo.user_id);
localStorage.setItem('U_email', res.data.userinfo.email);
this.$router.push('/');
} else {
localStorage.setItem('U_status', '2'); //其余的身份
@@ -229,6 +232,7 @@
localStorage.setItem('U_name', res.data.userinfo.account);
localStorage.setItem('U_id', res.data.userinfo.user_id);
localStorage.setItem('U_relname', res.data.userinfo.realname);
localStorage.setItem('U_email', res.data.userinfo.email);
this.$router.push('/');
// this.roleVisible = true;
// this.user_cap = res.data.roles;

View File

@@ -0,0 +1,238 @@
<template>
<div class="submission-page">
<div class="container">
<div class="success-icon"></div>
<h2 class="success-title">Application submitted successfully!</h2>
<p class="success-desc" v-if="isFromChina">
We have a WeChat group for academic discussions. Please feel free to scan the QR code below to join.
</p>
<p class="success-desc" v-else>
Your application is currently under review. We appreciate your patience, and our team will notify you of the final decision via email as soon as possible.
</p>
<div v-if="isFromChina" class="qr-section">
<div v-if="qrLoading" class="qr-loading">Loading...</div>
<div v-else class="qr-code-box" v-if="qrCodeUrl">
<img :src="qrCodeUrl" alt="WeChat Group QR" />
</div>
<!-- <p class="remark-tip">Please use the following format for group remark:</p>
<div class="remark-box">Name - Research Field - Affiliation</div> -->
</div>
<router-link to="/login" replace class="back-btn"> Login to the Submission System Now </router-link>
</div>
</div>
</template>
<script>
export default {
name: 'YouthBoardSubmitSuccess',
data() {
return {
qrCodeUrl: '',
qrLoading: false
};
},
computed: {
isFromChina() {
const s = String(this.$route.query.country || '').trim();
if (!s) return false;
// 兼容1 / China / 中国 / CN / CHN
if (s === '1') return true;
if (s === 'China' || s === 'CN' || s === 'CHN') return true;
if (s === '中国') return true;
if (s === 'US' || s === 'United States' || s === '美国') return false;
return s === 'China';
}
},
mounted() {
if (this.isFromChina) this.loadApplyBaseInfo();
},
methods: {
normalizeCountry(v) {
const s = String(v || '').trim();
if (!s) return '';
if (s === '中国' || s === 'CN' || s === 'CHN') return 'China';
if (s === '美国' || s === 'USA' || s === 'US') return 'United States';
if (s === '英国' || s === 'UK' || s === 'GBR') return 'United Kingdom';
return s;
},
buildMediaUrl(raw) {
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
const mediaBase = (this.Common && this.Common.mediaUrl ? this.Common.mediaUrl : '').replace(/\/+$/, '');
if (!mediaBase) return raw;
const cleanPath = String(raw).replace(/^\/+/, '');
// 与后台上传目录保持一致JournalManagement/common.vue
return `${mediaBase}/journalyboardqrcode/${cleanPath}`;
},
loadApplyBaseInfo() {
const journal_id = this.$route.query.journal_id || this.$route.query.journalId;
const expert_id = this.$route.query.expert_id || this.$route.query.expertId;
if (!journal_id || !expert_id) {
return;
}
this.qrLoading = true;
this.$api
.post('api/Ucenter/getApplyYboardForExpertBaseInfo', {
journal_id,
expert_id
})
.then((res) => {
const data = (res && res.data) || {};
const journal = data.journal || data.journal_info || data.journalInfo || null;
const raw =
(journal && (journal.wechat_yboard_qrcode || journal.yboard_qrcode || journal.qrcode_url)) || '';
this.qrCodeUrl = this.buildMediaUrl(raw);
})
.catch(() => {
this.qrCodeUrl = '';
})
.finally(() => {
this.qrLoading = false;
});
}
}
};
</script>
<style scoped>
.submission-page {
--primary-blue: #3a91d9;
--text-main: #2c3e50;
--bg-light: #f7f9fc;
--success-green: #48bb78;
--danger: #e53e3e;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-light);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
margin: 0;
box-sizing: border-box;
}
.container {
background: #ffffff;
width: 100%;
max-width: 520px;
height: fit-content;
padding: 60px 40px;
border-radius: 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.04);
text-align: center;
}
.success-icon {
font-size: 60px;
color: var(--success-green);
margin-bottom: 20px;
}
.success-title {
font-size: 24px;
color: var(--text-main);
font-weight: 700;
margin-bottom: 15px;
}
.success-desc {
font-size: 16px;
color: #718096;
margin-bottom: 40px;
line-height: 1.6;
}
.qr-section {
background-color: #f0f7ff;
border: 1px solid #c3dafe;
border-radius: 16px;
padding: 30px;
margin-top: 20px;
}
.qr-header {
color: #2b6cb0;
font-weight: bold;
font-size: 16px;
}
.qr-code-box {
width: 200px;
height: 200px;
background: #fff;
margin: 15px auto;
border: 1px solid #eee;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.qr-code-box img {
width: 100%;
height: 100%;
object-fit: contain;
}
.remark-tip {
font-size: 14px;
color: #4a5568;
margin-top: 10px;
}
.remark-box {
background: #fff;
border: 1px solid #feb2b2;
color: var(--danger);
font-weight: bold;
padding: 10px 15px;
border-radius: 8px;
display: inline-block;
margin-top: 5px;
font-size: 14px;
}
.back-btn {
display: inline-block;
margin-top: 40px;
color: #006699;
text-decoration: none;
font-size: 14px;
transition: color 0.2s;
}
.back-btn:hover {
color: var(--primary-blue);
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 220px;
}
.loading-text {
color: #718096;
font-size: 14px;
}
.qr-loading {
width: 200px;
height: 200px;
background: #fff;
margin: 15px auto;
border: 1px solid #eee;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
color: #718096;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,612 @@
<template>
<div class="registration-container">
<div class="card">
<h1>Youth Editorial Board Registration</h1>
<div v-if="journalInfoLoaded" class="journal-hero">
<div class="journal-cover-wrap" v-if="journalCoverUrl">
<img :src="journalCoverUrl" :alt="journalTitle || 'Journal Cover'" class="journal-cover" />
</div>
<div class="journal-title">{{ journalTitle || 'Journal Information' }}</div>
</div>
<!-- <p v-if="journalId && expertId" class="link-meta">
Journal ID: <strong>{{ journalId }}</strong> · Expert ID: <strong>{{ expertId }}</strong>
</p> -->
<!-- <p v-else class="link-meta link-meta--warn">
Missing <code>journal_id</code> and/or <code>expert_id</code> in the URL. Please use the full invitation link.
</p> -->
<div v-if="submitSuccess" class="success-panel">
<h2>Thank you</h2>
<p>Your registration has been submitted. We will contact you by email.</p>
</div>
<form v-else @submit.prevent="handleSubmit" autocomplete="off">
<div class="form-group">
<label><span class="required-star">*</span> English Name</label>
<input
type="text"
v-model="formData.engName"
placeholder=""
autocomplete="off"
/>
</div>
<div class="form-group">
<label><span class="required-star">*</span> Email (QQ mail is not allowed)</label>
<input type="email" v-model="formData.email" placeholder="" autocomplete="off" />
</div>
<div class="form-group">
<label><span class="required-star">*</span> Password</label>
<input
type="text"
v-model="formData.password"
placeholder=""
autocomplete="off"
/>
</div>
<div class="form-group">
<label><span class="required-star">*</span> Upload CV</label>
<div v-if="!selectedFile" class="upload-trigger" @click="$refs.fileInput.click()">
<div class="icon">📤</div>
<div class="hint">Click to upload PDF CV</div>
<input type="file" ref="fileInput" hidden accept=".pdf" @change="onFileChange" />
</div>
<div v-else class="file-display-box">
<span class="file-icon">📄</span>
<span class="file-name">{{ selectedFile.name }}</span>
<span class="remove-btn" @click.prevent="removeFile">×</span>
</div>
</div>
<!-- <transition name="fade">
<div v-if="formData.country === 'China'" class="qr-area">
<div class="qr-title">Mandatory for Youth Scientists in China</div>
<div v-if="wechatQrUrl" class="qr-box">
<img :src="wechatQrUrl" alt="Group QR Code" @error="onQrImgError" />
</div>
<p v-else class="qr-fallback">Please add image file <code>public/youth-board-wechat-qr.png</code> for the group QR code.</p>
<p class="qr-hint">Please use the following format for the group remark:</p>
<div class="format-tag">Name - Research Field - Affiliation</div>
</div>
</transition> -->
<button type="submit" class="submit-btn" :disabled="submitting">
{{ submitting ? 'Submitting...' : 'Register Now' }}
</button>
</form>
</div>
</div>
</template>
<script>
import axios from 'axios';
/** 公开邀请注册:后端实现 multipart 接口后填写此路径(与 Dashboard 青年编委申请字段对齐可再改) */
const SUBMIT_URL = '/api/User/youthBoardInviteRegister';
export default {
name: 'YouthEditorialBoardRegistration',
data() {
return {
baseUrl: this.Common.baseUrl,
formData: {
engName: '',
email: '',
country: '',
password: ''
},
countryList: [],
selectedFile: null,
submitting: false,
submitSuccess: false,
wechatQrUrl: '',
journalInfoLoaded: false,
journalDetail: null,
uploadedCvPath: ''
};
},
computed: {
journalId() {
const q = this.$route.query || {};
return String(q.journal_id || q.journalId || '').trim();
},
expertId() {
const q = this.$route.query || {};
return String(q.expert_id || q.expertId || '').trim();
},
journalTitle() {
const j = this.journalDetail || {};
return j.full_name || j.journal_name || j.title || j.abbr || '';
},
journalCoverUrl() {
const j = this.journalDetail || {};
const raw =
j.journal_icon ||
j.cover ||
j.img ||
j.image ||
j.icon ||
j.logo ||
j.thumb ||
j.picture ||
j.photo ||
j.journal_cover ||
j.journal_img ||
'';
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
const mediaBase = (this.Common.mediaUrl || '').replace(/\/+$/, '');
const cleanPath = String(raw).replace(/^\/+/, '');
if (!mediaBase) return `/${cleanPath}`;
if (/^journal\//i.test(cleanPath) || /^reviewer\//i.test(cleanPath)) {
return `${mediaBase}/${cleanPath}`;
}
return `${mediaBase}/journal/${cleanPath}`;
}
},
mounted() {
const base = process.env.BASE_URL || '/';
const prefix = base.endsWith('/') ? base : `${base}/`;
this.wechatQrUrl = `${prefix}youth-board-wechat-qr.png`;
this.fetchCountries();
this.fetchApplyBaseInfo();
},
methods: {
countryOptionKey(item) {
return item.name || item.id || item.country || JSON.stringify(item);
},
countryOptionValue(item) {
return item.name != null ? item.name : item.country || item.title || '';
},
countryOptionLabel(item) {
return item.name != null ? item.name : item.country || item.title || '';
},
fetchCountries() {
// this.$api
// .post('api/Reviewer/getCountrys')
// .then((res) => {
// const list = res.countrys || res.data || [];
// this.countryList = Array.isArray(list) ? list : [];
// })
// .catch(() => {
// this.countryList = [{ name: 'China' }, { name: 'United States' }, { name: 'United Kingdom' }];
// });
},
fetchApplyBaseInfo() {
if (!this.journalId || !this.expertId) return;
this.$api
.post('api/Ucenter/getApplyYboardForExpertBaseInfo', {
journal_id: this.journalId,
expert_id: this.expertId
})
.then((res) => {
const data = (res && res.data) || {};
const expertCandidate =
data.expert_info ||{}
const journal =
data.journal ||
data.journal_info ||
data.journalInfo ||
data.journal_detail ||
data.journalDetail ||
null;
// 为了兼容后端返回字段名,这里做多字段兜底取值
const pickFirst = (obj, keys) => {
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (obj && obj[k] !== undefined && obj[k] !== null && String(obj[k]).trim() !== '') {
return obj[k];
}
}
return '';
};
const pickFromCandidateOrData = (keys) => {
const v1 = pickFirst(expertCandidate, keys);
if (v1) return v1;
return pickFirst(data, keys);
};
const normalizeCountry = (v) => {
const s = String(v || '').trim();
if (!s) return '';
// 常见中文/英文归一化(确保和下拉 options.value 匹配)
if (s === '中国' || s === 'CN' || s === 'CHN') return 'China';
if (s === '美国' || s === 'USA' || s === 'US') return 'United States';
if (s === '英国' || s === 'UK' || s === 'GBR') return 'United Kingdom';
return s;
};
this.formData.engName = pickFromCandidateOrData([
'eng_name',
'english_name',
'u_eng_name',
'U_eng_name',
'U_engName',
'U_name_en',
'realname',
'u_relname',
'U_relname',
'name',
'U_name'
]);
this.formData.email = pickFromCandidateOrData([
'email',
'u_email',
'U_email',
'U_email_address',
'user_email',
'username',
'U_username'
]);
this.formData.country = normalizeCountry(
pickFromCandidateOrData([
'country',
'country_name',
'u_country',
'U_country',
'countryName',
'U_country_name'
])
);
if (journal) {
this.journalDetail = journal;
this.journalInfoLoaded = true;
}
if (!journal) {
this.journalDetail = null;
this.journalInfoLoaded = true;
}
})
.catch(() => {
this.journalDetail = null;
this.journalInfoLoaded = true;
});
},
async onFileChange(e) {
const file = e.target.files && e.target.files[0];
if (file && file.type === 'application/pdf') {
this.selectedFile = file;
const fd = new FormData();
fd.append('reviewerCV', file);
try {
const resp = await axios.post(`${this.baseUrl}api/Ucenter/up_cv_file`, fd, {
headers: { 'Content-Type': 'multipart/form-data' }
});
const body = resp && resp.data ? resp.data : {};
if (Number(body.code) === 0 && body.upurl) {
this.uploadedCvPath = body.upurl;
} else {
this.selectedFile = null;
this.uploadedCvPath = '';
this.alertError((body && body.msg) || 'CV upload failed.');
}
} catch (err) {
this.selectedFile = null;
this.uploadedCvPath = '';
this.alertError('CV upload failed. Please try again.');
}
} else if (file) {
this.alertError('Please upload a valid PDF file.');
}
},
removeFile() {
this.selectedFile = null;
this.uploadedCvPath = '';
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
},
onQrImgError() {
this.wechatQrUrl = '';
},
alertError(msg) {
this.$message.error(msg);
},
async handleSubmit() {
if (!this.journalId || !this.expertId) {
this.alertError('Invalid link: journal_id and expert_id are required in the URL.');
return;
}
const engName = (this.formData.engName || '').trim();
if (!engName) {
this.alertError('Please enter your English name.');
return;
}
const emailRaw = (this.formData.email || '').trim();
if (!emailRaw) {
this.alertError('Please enter your email address.');
return;
}
const email = emailRaw.toLowerCase();
if (!/^[-._A-Za-z0-9]+@[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+$/.test(email)) {
this.alertError('Please enter a valid email address.');
return;
}
if (email.endsWith('@qq.com')) {
this.alertError('Registration failed: QQ email addresses are not accepted.');
return;
}
if (!this.formData.password) {
this.alertError('Please enter your password.');
return;
}
if (!this.selectedFile) {
this.alertError('Please upload your CV.');
return;
}
if (!this.uploadedCvPath) {
this.alertError('CV upload is not complete. Please upload again.');
return;
}
this.submitting = true;
this.$api
.post('api/Ucenter/submitApplyYboardForExpert', {
journal_id: this.journalId,
expert_id: this.expertId,
name: engName,
email: emailRaw,
cv: this.uploadedCvPath,
password: this.formData.password
})
.then((res) => {
if (res && res.code == 0) {
this.$router.replace({
path: '/youthBoardSubmitSuccess',
query: {
country: this.formData.country == 'China' ? '1' : '0',
journal_id: this.journalId,
expert_id: this.expertId
}
});
} else {
this.alertError((res && res.msg) || 'Submission failed.');
}
})
.catch((err) => {
this.alertError((err && err.msg) || 'Submission failed.');
})
.finally(() => {
this.submitting = false;
});
}
}
};
</script>
<style scoped>
.registration-container {
--primary: #3a91d9;
--text: #2c3e50;
--border: #e2e8f0;
--danger: #e53e3e;
--bg: #f7f9fc;
min-height: 100vh;
background-color: var(--bg);
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
font-family: sans-serif;
}
.card {
background: #ffffff;
width: 100%;
max-width: 520px;
padding: 40px;
border-radius: 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.04);
}
.journal-hero {
text-align: center;
margin: 6px 0 18px;
}
.journal-cover-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 112px;
height: 150px;
border-radius: 6px;
overflow: hidden;
background: transparent;
}
.journal-cover {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
.journal-title {
margin-top: 10px;
color: #1d4f8c;
font-size: 16px;
line-height: 1.4;
font-weight: 700;
word-break: break-word;
}
h1 {
text-align: center;
color: var(--text);
font-size: 24px;
margin-bottom: 16px;
}
.link-meta {
font-size: 13px;
color: #4a5568;
text-align: center;
margin-bottom: 24px;
line-height: 1.5;
}
.link-meta--warn {
color: #c05621;
}
.success-panel {
text-align: center;
padding: 24px 0;
color: var(--text);
}
.success-panel h2 {
font-size: 20px;
margin-bottom: 12px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 14px;
font-weight: 600;
color: #4a5568;
margin-bottom: 8px;
}
.required-star {
color: #e53e3e;
}
input,
select {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: 12px;
font-size: 14px;
box-sizing: border-box;
}
.upload-trigger {
border: 1px dashed #cbd5e0;
border-radius: 12px;
padding: 30px;
text-align: center;
cursor: pointer;
background: #fff;
}
.upload-trigger:hover {
background: #f8fafc;
}
.upload-trigger .icon {
font-size: 28px;
}
.upload-trigger .hint {
font-size: 14px;
color: #718096;
margin-top: 5px;
}
.file-display-box {
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: 12px;
}
.file-name {
flex: 1;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-btn {
color: var(--danger);
cursor: pointer;
font-size: 20px;
margin-left: 10px;
}
.qr-area {
background-color: #f0f7ff;
border: 1px solid #dbeafe;
border-radius: 16px;
padding: 25px;
margin: 20px 0;
text-align: center;
}
.qr-title {
color: #2b6cb0;
font-weight: bold;
font-size: 15px;
margin-bottom: 15px;
}
.qr-box {
width: 180px;
height: 180px;
background: #fff;
margin: 0 auto 15px;
padding: 8px;
border-radius: 8px;
}
.qr-box img {
width: 100%;
height: 100%;
object-fit: contain;
}
.qr-fallback {
font-size: 13px;
color: #718096;
margin-bottom: 12px;
}
.qr-hint {
font-size: 13px;
color: #4a5568;
margin-bottom: 10px;
}
.format-tag {
display: inline-block;
padding: 8px 16px;
border: 1px solid #feb2b2;
border-radius: 8px;
color: var(--danger);
font-weight: bold;
background: #fff;
}
.submit-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #4da1e6 0%, #3588d1 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
.submit-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -810,9 +810,37 @@
>
</div>
<el-alert
v-if="articleAddQqEmailSubmitBlocked"
type="warning"
:closable="false"
show-icon
style="max-width: 720px; margin: 24px auto 0"
>
<template slot="title">
<div class="qq-email-alert-body">
<div class="qq-email-alert-line1">{{ $t('articleAdd.qqEmailAlertLine1') }}</div>
<div class="qq-email-alert-line2">
<span>{{ $t('articleAdd.qqEmailAlertLine2Before') }}</span>
<router-link to="/dashboard" class="qq-email-dashboard-link">
<i class="el-icon-user-solid"></i>
{{ $t('articleAdd.qqEmailDashboardLink') }}
</router-link>
<span>{{ $t('articleAdd.qqEmailAlertLine2After') }}</span>
</div>
</div>
</template>
</el-alert>
<div style="text-align: center; margin: 40px 0 0 0">
<el-button type="warning" @click="onStagingSave(4)" class="pro_stage">Save as draft </el-button>
<el-button type="primary" @click="onSubmit(1)" class="pro_ceed">Confirm Submit </el-button>
<el-button
type="primary"
@click="onSubmit(1)"
class="pro_ceed"
:disabled="articleAddQqEmailSubmitBlocked"
>
Confirm Submit
</el-button>
</div>
</div>
</div>
@@ -1612,6 +1640,17 @@ export default {
},
upload_qualifications: function () {
return this.baseUrl + 'api/Admin/up_file';
},
/** 投稿页第四步:登录邮箱为 QQ 邮箱时不允许正式提交 */
articleAddQqEmailSubmitBlocked() {
try {
const raw = localStorage.getItem('U_email') || '';
const email = String(raw).trim().toLowerCase();
if (!email || !email.includes('@')) return false;
return /@qq\.com$/i.test(email) || /@vip\.qq\.com$/i.test(email);
} catch (e) {
return false;
}
}
},
methods: {
@@ -1907,6 +1946,10 @@ export default {
}
},
async onSubmit() {
if (this.articleAddQqEmailSubmitBlocked) {
this.$message.warning(this.$t('articleAdd.qqEmailSubmitBlockedMsg'));
return false;
}
// 1. 校验协议勾选
if (!this.agreechecked) {
this.$message.error(
@@ -4063,6 +4106,28 @@ export default {
</script>
<style scoped>
.qq-email-alert-body {
line-height: 1.55;
}
.qq-email-alert-line1 {
display: block;
margin-bottom: 6px;
}
.qq-email-alert-line2 {
display: block;
}
.qq-email-dashboard-link {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 4px;
font-weight: 600;
color: #cf9236;
text-decoration: underline;
}
.qq-email-dashboard-link:hover {
color: #b8822f;
}
/deep/.apc_content a{
color: rgb(81, 127, 213) !important; cursor: pointer !important; text-decoration: underline !important;
}

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="article-detail-editor-page">
<div class="crumbs">
<div class="art_state_message_id" style="padding-left: 18px">
<font
@@ -915,9 +915,38 @@
}}</b>
<!-- <el-button type="text" @click="testedit" icon="el-icon-edit">Change</el-button> -->
</div>
<div>
<span>Repetition : </span>
<b>{{ form.repetition }}%</b>
<div class="detail-plagiarism-row">
<span class="detail-plagiarism-main">
<span class="detail-plagiarism-lbl">Repetition :</span>
<template v-if="detailPlagiarismUiShowProcessing()">
<span class="detail-plagiarism-loading">
<i class="el-icon-loading"></i>
{{ $t('articleListEditor.plagiarismChecking') }}
</span>
</template>
<template v-else-if="detailPlagiarismUiShowResult()">
<span
class="detail-plagiarism-pct-text"
:style="detailPlagiarismSimilarityStyle()"
@click.stop="detailOpenPlagiarismReport"
>
{{ detailPlagiarismSimilarityNumber() }}%
</span>
</template>
<template v-else>
<span class="detail-plagiarism-not-checked-text" @click.stop="detailTriggerCrossrefPlagiarismCheck">
{{ $t('articleListEditor.plagiarismNotChecked') }}
</span>
</template>
<el-button
v-if="detailPlagiarismShowRecheck()"
type="text"
class="detail-plagiarism-recheck-btn detail-plagiarism-recheck-inline"
@click.stop="detailTriggerCrossrefPlagiarismCheck"
>
{{ $t('articleListEditor.plagiarismRecheck') }}
</el-button>
</span>
</div>
<!--<br clear="both">
<el-button type="primary" @click="showResubmit" style="margin: 15px 0 0 0;">Resubmit the manuscript
@@ -937,9 +966,38 @@
}}</b>
<el-button style="padding: 0" type="text" @click="testedit" icon="el-icon-edit">Change</el-button>
</div>
<div>
<span>Repetition : </span>
<b>{{ form.repetition }}%</b>
<div class="detail-plagiarism-row detail-plagiarism-row--with-actions">
<span class="detail-plagiarism-main">
<span class="detail-plagiarism-lbl">Repetition :</span>
<template v-if="detailPlagiarismUiShowProcessing()">
<span class="detail-plagiarism-loading">
<i class="el-icon-loading"></i>
{{ $t('articleListEditor.plagiarismChecking') }}
</span>
</template>
<template v-else-if="detailPlagiarismUiShowResult()">
<span
class="detail-plagiarism-pct-text"
:style="detailPlagiarismSimilarityStyle()"
@click.stop="detailOpenPlagiarismReport"
>
{{ detailPlagiarismSimilarityNumber() }}%
</span>
</template>
<template v-else>
<span class="detail-plagiarism-not-checked-text" @click.stop="detailTriggerCrossrefPlagiarismCheck">
{{ $t('articleListEditor.plagiarismNotChecked') }}
</span>
</template>
<el-button
v-if="detailPlagiarismShowRecheck()"
type="text"
class="detail-plagiarism-recheck-btn detail-plagiarism-recheck-inline"
@click.stop="detailTriggerCrossrefPlagiarismCheck"
>
{{ $t('articleListEditor.plagiarismRecheck') }}
</el-button>
</span>
<a :href="mediaUrl + form.repeurl" v-if="form.repeurl" class="zip_load" target="_blank">
<img src="../../assets/img/icon_0.png" />
<span>Duplicate check file</span>
@@ -947,6 +1005,77 @@
</a>
<el-button type="text" @click="changeRepe" icon="el-icon-edit">Change</el-button>
</div>
<div v-if="plagiarismListReady" class="plagiarism-check-shell">
<el-button
v-if="!plagiarismList.length"
class="plagiarism-auto-check-btn"
icon="el-icon-search"
:loading="plagiarismSubmitLoading"
@click="submitPlagiarismCheck"
>
{{ $t('articleListEditor.plagiarismAutoCheck') }}
</el-button>
<div
v-else
class="plagiarism-check-block"
v-loading="plagiarismListLoading"
element-loading-background="transparent"
>
<div class="plagiarism-check-header">
<span class="plagiarism-check-title">{{ $t('articleListEditor.plagiarismListTitle') }}</span>
<div class="plagiarism-check-actions">
<!-- <el-button
type="text"
size="mini"
icon="el-icon-document-checked"
:loading="plagiarismSubmitLoading"
@click="submitPlagiarismCheck"
>
{{ $t('articleListEditor.plagiarismDuplicateCheck') }}
</el-button> -->
<el-button
type="text"
size="mini"
icon="el-icon-refresh"
:loading="plagiarismListLoading"
@click="fetchPlagiarismList(true)"
>
{{ $t('articleListEditor.plagiarismRefresh') }}
</el-button>
</div>
</div>
<div class="plagiarism-check-list-wrap">
<div
v-for="row in plagiarismList"
:key="row.check_id || row.id"
class="plagiarism-check-row"
:title="row.source_file_name || ''"
>
<span class="plagiarism-sim-dot" :class="getPlagiarismSimilarityLevel(row)"></span>
<span class="plagiarism-sim-pct" :class="getPlagiarismSimilarityLevel(row)">
{{ formatPlagiarismSimilarity(row) }}
</span>
<span class="plagiarism-sim-date">{{ formatPlagiarismDate(row) }}</span>
<span class="plagiarism-sim-state" :class="getPlagiarismStateClass(row)">
{{ formatPlagiarismStateLabel(row) }}
</span>
<span class="plagiarism-sim-report">
<a
v-if="hasPlagiarismPdf(row)"
href="javascript:;"
class="plagiarism-report-preview"
:title="$t('articleListEditor.plagiarismPreviewOpenTab')"
@click.prevent="openPlagiarismReportPage(row)"
>
{{ $t('articleListEditor.plagiarismPreviewPdf') }}
<i class="el-icon-link"></i>
</a>
<span v-else class="plagiarism-check-no-pdf">{{ $t('articleListEditor.plagiarismNoPdfLink') }}</span>
</span>
</div>
</div>
</div>
</div>
<div>
<span style="display: inline-block; vertical-align: top; margin-top: 7px">Manuscript : </span>
<el-upload
@@ -1142,6 +1271,31 @@
<el-button type="primary" @click="saveRepe">Save</el-button>
</span>
</el-dialog>
<el-dialog
:title="plagiarismPdfPreviewTitle"
:visible.sync="plagiarismPdfPreviewVisible"
width="90%"
top="4vh"
append-to-body
custom-class="plagiarism-pdf-preview-dialog"
@closed="onPlagiarismPdfPreviewClosed"
>
<div v-loading="plagiarismPdfPreviewLoading" class="plagiarism-pdf-preview-body">
<iframe
v-if="plagiarismPdfPreviewUrl"
:src="plagiarismPdfPreviewUrl"
class="plagiarism-pdf-preview-iframe"
frameborder="0"
@load="plagiarismPdfPreviewLoading = false"
></iframe>
</div>
<span slot="footer" class="dialog-footer">
<el-button size="small" @click="plagiarismPdfPreviewVisible = false">{{ $t('articleListEditor.plagiarismPreviewClose') }}</el-button>
<el-button size="small" type="primary" v-if="plagiarismPdfPreviewUrl" @click="openPlagiarismPdfInNewTab">
{{ $t('articleListEditor.plagiarismPreviewOpenTab') }}
</el-button>
</span>
</el-dialog>
<el-dialog
title="Resubmit the manuscript"
:visible.sync="resubmitVisible"
@@ -1426,6 +1580,8 @@
import timetalk from './time_talk';
import reviewerDetail from '../../components/page/components/articleDetail/reviewerdetail.vue';
import FigureCopyright from '../../components/page/components/articleDetail/FigureCopyright.vue';
import axios from 'axios';
import { getSimilarityStyle } from '../../utils/ithenticateSimilarityStyle';
export default {
components: {
timetalk,
@@ -1549,6 +1705,9 @@ export default {
approval_content: '',
is_figure_copyright: '',
repetition: '',
plagiarism_similarity: '',
plagiarism_report_url: '',
plagiarism_job_state: '',
manuscirpt: '',
remarks: '',
state: '',
@@ -1743,7 +1902,18 @@ export default {
underReview: ['1'],
finalDecision: ['1'],
is_figure_copyright: '',
figurecopyright_file: ''
figurecopyright_file: '',
plagiarismDetailPollTimer: null,
plagiarismDetailPending: false,
plagiarismList: [],
plagiarismListLoading: false,
plagiarismListReady: false,
plagiarismPollTimer: null,
plagiarismSubmitLoading: false,
plagiarismPdfPreviewVisible: false,
plagiarismPdfPreviewUrl: '',
plagiarismPdfPreviewTitle: '',
plagiarismPdfPreviewLoading: false
};
},
async created() {
@@ -1753,6 +1923,17 @@ export default {
this.getWordimgList();
this.getWordTablesList();
this.getFinalList();
this.startPlagiarismPolling();
},
activated() {
this.startPlagiarismPolling(false);
},
deactivated() {
this.stopPlagiarismPolling();
},
beforeDestroy() {
this.stopPlagiarismPolling();
this.detailStopPlagiarismPolling();
},
computed: {
// coverLetterUrl: function() {
@@ -2462,6 +2643,7 @@ export default {
background: 'rgba(0, 0, 0, 0.7)'
});
this.$api.post('api/Article/changeRepetition', this.repeform).then((res) => {
this.repebox = false;
load.close();
this.$message.success('success');
this.initarticle();
@@ -2541,6 +2723,26 @@ export default {
this.form.approval_file = res.article.approval_file;
this.form.approval_content = res.article.approval_content;
this.form.repetition = res.article.repetition;
this.$set(
this.form,
'plagiarism_similarity',
res.article.plagiarism_similarity != null && res.article.plagiarism_similarity !== ''
? res.article.plagiarism_similarity
: res.article.crossref_similarity != null && res.article.crossref_similarity !== ''
? res.article.crossref_similarity
: ''
);
this.$set(
this.form,
'plagiarism_report_url',
res.article.plagiarism_report_url || res.article.crossref_report_url || ''
);
this.$set(
this.form,
'plagiarism_job_state',
res.article.plagiarism_job_state || res.article.crossref_status || ''
);
this.detailInitPlagiarismAfterLoad();
this.form.remarks = res.article.remarks;
this.form.repeurl = res.article.repeurl;
this.repeform.repefen = res.article.repetition;
@@ -2856,6 +3058,364 @@ export default {
// 关闭弹窗
closeResubmit() {
(this.resubmitVisible = false), this.$refs['resubmitJournal'].resetFields();
},
/* ---------- Crossref plagiarism详情页 Repetition 行) ---------- */
detailPlagiarismArticleKey() {
return String(this.form.articleId || this.editform.articleId || this.$route.query.id || '');
},
detailNormalizePlagiarismStatusPayload(body) {
const root = body && typeof body === 'object' ? body : {};
if (root.code != null && Number(root.code) !== 0) {
return { status: 'api_error', similarity: null, reportUrl: '' };
}
let d = root.data != null ? root.data : root;
if (typeof d === 'string') {
try {
d = JSON.parse(d);
} catch (e) {
d = {};
}
}
if (!d || typeof d !== 'object') d = {};
const statusRaw = d.status || d.state || d.job_status || d.plagiarism_status || '';
const status = String(statusRaw).toLowerCase();
let sim =
d.similarity != null
? d.similarity
: d.percent != null
? d.percent
: d.similarity_percent != null
? d.similarity_percent
: d.crossref_similarity != null
? d.crossref_similarity
: null;
if (sim === '' || sim === undefined) sim = null;
const reportUrl = d.report_url || d.reportUrl || d.url || d.report_link || '';
return { status, similarity: sim, reportUrl: String(reportUrl || '') };
},
detailPlagiarismSimilarityRaw() {
const f = this.form;
if (!f) return null;
if (f.plagiarism_similarity != null && f.plagiarism_similarity !== '') return f.plagiarism_similarity;
if (f.crossref_similarity != null && f.crossref_similarity !== '') return f.crossref_similarity;
const st = String(f.plagiarism_job_state || f.crossref_status || '').toLowerCase();
if (['completed', 'done', 'success', 'complete'].includes(st) && (f.plagiarism_similarity === 0 || f.plagiarism_similarity === '0')) {
return 0;
}
const rep = f.repetition;
if (rep != null && rep !== '' && Number(rep) > 0) return rep;
return null;
},
detailPlagiarismSimilarityNumber() {
const n = this.detailPlagiarismSimilarityRaw();
if (n == null || n === '') return 0;
const x = Number(n);
return isNaN(x) ? 0 : Math.round(x * 10) / 10;
},
detailPlagiarismUiShowProcessing() {
if (this.form._plagiarismLocalLoading) return true;
if (this.plagiarismDetailPending) return true;
const st = String(this.form.plagiarism_job_state || this.form.crossref_status || '').toLowerCase();
return ['pending', 'processing', 'queued', 'running', 'submitted'].includes(st);
},
detailPlagiarismUiShowResult() {
if (this.detailPlagiarismUiShowProcessing()) return false;
const st = String(this.form.plagiarism_job_state || this.form.crossref_status || '').toLowerCase();
const done = ['completed', 'done', 'success', 'complete'].includes(st);
const raw = this.detailPlagiarismSimilarityRaw();
if (raw != null && raw !== '' && !isNaN(Number(raw))) {
if (Number(raw) === 0 && !done && !(this.form.plagiarism_report_url || this.form.crossref_report_url)) {
return false;
}
return true;
}
if (done && (this.form.plagiarism_similarity === 0 || this.form.plagiarism_similarity === '0')) return true;
return !!(this.form.plagiarism_report_url || this.form.crossref_report_url);
},
detailPlagiarismSimilarityStyle() {
const n = Number(this.detailPlagiarismSimilarityNumber());
return { color: getSimilarityStyle(n).color };
},
detailPlagiarismShowRecheck() {
if (this.detailPlagiarismUiShowProcessing()) return false;
return Number(this.form.state) === 6;
},
detailInitPlagiarismAfterLoad() {
const st = String(this.form.plagiarism_job_state || '').toLowerCase();
if (['pending', 'processing', 'queued', 'running', 'submitted'].includes(st)) {
this.plagiarismDetailPending = true;
this.detailEnsurePlagiarismPoll();
}
},
detailEnsurePlagiarismPoll() {
if (!this.plagiarismDetailPending) {
this.detailStopPlagiarismPolling();
return;
}
if (!this.plagiarismDetailPollTimer) {
this.detailPollPlagiarismOnce();
this.plagiarismDetailPollTimer = setInterval(() => this.detailPollPlagiarismOnce(), 60000);
}
},
detailStopPlagiarismPolling() {
if (this.plagiarismDetailPollTimer) {
clearInterval(this.plagiarismDetailPollTimer);
this.plagiarismDetailPollTimer = null;
}
},
async detailPollPlagiarismOnce() {
const key = this.detailPlagiarismArticleKey();
if (!key || !this.plagiarismDetailPending) return;
try {
const res = await axios.get('/api/plagiarism/status', { params: { article_id: key } });
const body = res && res.data;
const norm = this.detailNormalizePlagiarismStatusPayload(body);
if (norm.status === 'api_error') {
this.plagiarismDetailPending = false;
this.detailStopPlagiarismPolling();
return;
}
if (norm.similarity != null && norm.similarity !== '') {
this.$set(this.form, 'plagiarism_similarity', norm.similarity);
}
if (norm.reportUrl) this.$set(this.form, 'plagiarism_report_url', norm.reportUrl);
if (norm.status) this.$set(this.form, 'plagiarism_job_state', norm.status);
const active = ['pending', 'processing', 'queued', 'running', 'submitted'];
const terminal = ['completed', 'done', 'success', 'complete', 'failed', 'error', 'fail', 'cancelled'];
const isActive = active.includes(norm.status);
let clearPending =
terminal.includes(norm.status) ||
norm.status === 'error' ||
(!isActive && norm.reportUrl && norm.similarity != null && norm.similarity !== '');
if (clearPending) {
this.plagiarismDetailPending = false;
}
this.detailEnsurePlagiarismPoll();
} catch (e) {
console.error(e);
this.$message.error(this.$t('articleListEditor.plagiarismStatusFailed'));
}
},
async detailTriggerCrossrefPlagiarismCheck() {
const key = this.detailPlagiarismArticleKey();
if (!key) return;
if (this.detailPlagiarismUiShowProcessing()) return;
this.$set(this.form, '_plagiarismLocalLoading', true);
try {
const res = await this.$api.post('api/plagiarism/check', { article_id: key });
if (res && Number(res.code) === 0) {
this.plagiarismDetailPending = true;
this.$set(this.form, 'plagiarism_job_state', 'pending');
this.detailEnsurePlagiarismPoll();
await this.detailPollPlagiarismOnce();
} else {
this.$message.error((res && res.msg) || this.$t('articleListEditor.plagiarismCheckFailed'));
}
} catch (e) {
console.error(e);
this.$message.error(this.$t('articleListEditor.plagiarismCheckFailed'));
} finally {
this.$set(this.form, '_plagiarismLocalLoading', false);
}
},
detailOpenPlagiarismReport() {
const raw = (this.form.plagiarism_report_url || this.form.crossref_report_url || '').trim();
if (!raw) {
this.$message.warning(this.$t('articleListEditor.plagiarismNoReportUrl'));
return;
}
let full = raw;
if (!/^https?:\/\//i.test(raw)) {
const base = (this.mediaUrl || '').replace(/\/+$/, '');
const path = raw.replace(/^\/+/, '');
full = base ? `${base}/${path}` : `/${path}`;
}
window.open(full, '_blank');
},
/* ---------- 自动查重列表 ---------- */
startPlagiarismPolling(resetList) {
this.stopPlagiarismPolling();
if (resetList !== false) {
this.plagiarismListReady = false;
this.plagiarismList = [];
}
this.fetchPlagiarismList(false);
this.plagiarismPollTimer = setInterval(() => {
this.fetchPlagiarismList(false);
}, 3 * 60 * 1000);
},
stopPlagiarismPolling() {
if (this.plagiarismPollTimer) {
clearInterval(this.plagiarismPollTimer);
this.plagiarismPollTimer = null;
}
},
async submitPlagiarismCheck() {
const articleId = String((this.editform && this.editform.articleId) || this.$route.query.id || '').trim();
if (!articleId) {
this.$message.warning(this.$t('articleListEditor.plagiarismReportDetailFailed'));
return;
}
this.plagiarismSubmitLoading = true;
try {
const res = await this.$api.post('api/Plagiarism/submit', { article_id: articleId });
if (res && Number(res.code) === 0) {
this.$message.success((res && res.msg) || this.$t('articleListEditor.plagiarismChecking'));
await this.fetchPlagiarismList(true);
} else {
this.$message.error((res && res.msg) || this.$t('articleListEditor.plagiarismCheckFailed'));
}
} catch (e) {
this.$message.error(this.$t('articleListEditor.plagiarismCheckFailed'));
} finally {
this.plagiarismSubmitLoading = false;
}
},
async fetchPlagiarismList(manual) {
const articleId = String((this.editform && this.editform.articleId) || this.$route.query.id || '').trim();
if (!articleId) {
this.plagiarismListReady = true;
return;
}
if (manual && this.plagiarismList.length) {
this.plagiarismListLoading = true;
}
try {
const res = await this.$api.post('api/Plagiarism/getList', { article_id: articleId });
if (res && Number(res.code) === 0) {
const payload = res.data || {};
const list = Array.isArray(payload.list) ? payload.list : Array.isArray(payload) ? payload : [];
this.plagiarismList = list;
} else if (manual) {
this.$message.error((res && res.msg) || this.$t('articleListEditor.plagiarismStatusFailed'));
}
} catch (e) {
if (manual) {
this.$message.error(this.$t('articleListEditor.plagiarismStatusFailed'));
}
} finally {
this.plagiarismListLoading = false;
this.plagiarismListReady = true;
}
},
formatPlagiarismState(state) {
const s = String(state != null ? state : '').trim();
if (!s) return '-';
return s;
},
formatPlagiarismStateLabel(row) {
if (!row || typeof row !== 'object') return '';
const label = String(row.state_label || row.stateLabel || '').trim();
if (label) return label;
const s = row.state;
if (s == null || String(s).trim() === '') return '';
return this.formatPlagiarismState(s);
},
getPlagiarismStateClass(row) {
const s = Number(row && row.state);
if (s === 1) return 'state-uploading';
if (s === 2 || s === 3) return 'state-done';
if (s === 4 || s === 5) return 'state-fail';
return '';
},
getPlagiarismSimilarityScore(row) {
if (!row || typeof row !== 'object') return null;
const raw = row.similarity_score != null ? row.similarity_score : row.similarity;
if (raw == null || String(raw).trim() === '') return null;
const n = Number(raw);
return isNaN(n) ? null : n;
},
formatPlagiarismSimilarity(row) {
const n = this.getPlagiarismSimilarityScore(row);
return n == null ? '' : n + '%';
},
/** Crossref 相似度色块0 绿、129 蓝、30+ 橙 */
getPlagiarismSimilarityLevel(row) {
const n = this.getPlagiarismSimilarityScore(row);
if (n == null) return 'sim-unknown';
if (n <= 0) return 'sim-zero';
if (n < 30) return 'sim-low';
return 'sim-high';
},
formatPlagiarismDate(row) {
if (!row || typeof row !== 'object') return '';
const raw =
row.finish_time ||
row.finished_at ||
row.update_time ||
row.ctime ||
row.create_time ||
row.created_at ||
'';
if (raw == null || String(raw).trim() === '') return '';
const s = String(raw).trim();
if (/^\d+$/.test(s)) {
const num = Number(s);
const ts = num > 1e12 ? num : num * 1000;
try {
return this.formatDate(Math.floor(ts / 1000));
} catch (e) {
return s;
}
}
const d = new Date(s.replace(/-/g, '/'));
if (!isNaN(d.getTime())) {
const pad = (v) => String(v).padStart(2, '0');
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
return s.length > 10 ? s.slice(0, 10) : s;
},
resolvePlagiarismPdfUrl(row) {
if (!row || typeof row !== 'object') return '';
const raw = String(row.viewer_url || row.local_pdf_url || row.localPdfUrl || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
let path = raw.replace(/^\/+/, '');
if (!/^public\//i.test(path)) {
const media = String(this.mediaUrl || '/public/').replace(/\/+$/, '');
if (/^https?:\/\//i.test(media)) {
return media + '/' + path;
}
path = (media.startsWith('/') ? media : '/' + media) + '/' + path;
} else {
path = '/' + path;
}
path = path.replace(/\/+/g, '/');
if (typeof window !== 'undefined' && window.location && window.location.origin && path.startsWith('/')) {
return window.location.origin + path;
}
return path;
},
hasPlagiarismPdf(row) {
return !!this.resolvePlagiarismPdfUrl(row);
},
openPlagiarismReportPage(row) {
const url = this.resolvePlagiarismPdfUrl(row);
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
},
openPlagiarismPdfPreview(row) {
const url = this.resolvePlagiarismPdfUrl(row);
if (!url) return;
const name = row && row.source_file_name ? String(row.source_file_name) : 'report.pdf';
const id = row && row.check_id != null ? row.check_id : '';
this.plagiarismPdfPreviewTitle =
this.$t('articleListEditor.plagiarismPreviewPdf') + (id ? ' #' + id : '') + ' - ' + name;
this.plagiarismPdfPreviewUrl = url;
this.plagiarismPdfPreviewLoading = true;
this.plagiarismPdfPreviewVisible = true;
},
onPlagiarismPdfPreviewClosed() {
this.plagiarismPdfPreviewUrl = '';
this.plagiarismPdfPreviewTitle = '';
this.plagiarismPdfPreviewLoading = false;
},
openPlagiarismPdfInNewTab() {
if (!this.plagiarismPdfPreviewUrl) return;
window.open(this.plagiarismPdfPreviewUrl, '_blank', 'noopener');
}
},
mounted() {
@@ -2920,6 +3480,169 @@ export default {
text-decoration: underline;
}
.plagiarism-check-shell {
margin: 0 0 10px 0;
}
.plagiarism-check-block >>> .el-loading-mask {
background-color: transparent !important;
}
.plagiarism-auto-check-btn.el-button {
display: block;
width: 200px;
border: none;
color: #fff;
font-size: 13px;
margin-left: 75px;
font-weight: 500;
letter-spacing: 0.3px;
padding: 11px 20px;
border-radius: 4px;
background: linear-gradient(135deg, #2ec4b6 0%, #0d9b8f 45%, #0a7f76 100%);
box-shadow: 0 2px 8px rgba(13, 155, 143, 0.35);
transition: opacity 0.2s ease, box-shadow 0.2s ease;
}
.plagiarism-auto-check-btn.el-button:hover,
.plagiarism-auto-check-btn.el-button:focus {
color: #fff;
background: linear-gradient(135deg, #3dd4c6 0%, #14b0a3 45%, #0e948a 100%);
box-shadow: 0 4px 12px rgba(13, 155, 143, 0.45);
}
.plagiarism-auto-check-btn.el-button.is-loading {
background: linear-gradient(135deg, #2ec4b6 0%, #0d9b8f 45%, #0a7f76 100%);
}
.plagiarism-check-block {
margin: 0 0 10px 0;
padding: 10px 12px;
background: #f8fafc;
border: 1px solid #e8edf3;
border-radius: 4px;
}
.plagiarism-check-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.plagiarism-check-actions {
display: flex;
align-items: center;
gap: 4px;
}
.plagiarism-check-actions .el-button {
padding: 0 6px;
}
.plagiarism-check-title {
font-size: 13px;
color: #606266;
font-weight: 500;
}
.plagiarism-check-list-wrap {
min-height: 32px;
}
.plagiarism-check-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-top: 1px solid #eef1f5;
font-size: 12px;
line-height: 20px;
}
.plagiarism-check-row:first-child {
border-top: none;
padding-top: 0;
}
.plagiarism-sim-dot {
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 1px;
}
.plagiarism-sim-dot.sim-zero {
background: #00a99d;
}
.plagiarism-sim-dot.sim-low {
background: #0070c0;
}
.plagiarism-sim-dot.sim-high {
background: #c87f0a;
}
.plagiarism-sim-dot.sim-unknown {
background: #c0c4cc;
}
.plagiarism-sim-pct {
flex-shrink: 0;
min-width: 36px;
font-weight: 600;
}
.plagiarism-sim-pct.sim-zero {
color: #00a99d;
}
.plagiarism-sim-pct.sim-low {
color: #0070c0;
}
.plagiarism-sim-pct.sim-high {
color: #c87f0a;
}
.plagiarism-sim-pct.sim-unknown {
color: #909399;
}
.plagiarism-sim-date {
flex-shrink: 0;
color: #909399;
}
.plagiarism-sim-state {
flex: 1;
min-width: 48px;
color: #909399;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plagiarism-sim-state.state-uploading {
color: #e6a23c;
}
.plagiarism-sim-state.state-done {
color: #67c23a;
}
.plagiarism-sim-state.state-fail {
color: #f56c6c;
}
.plagiarism-sim-report {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.plagiarism-report-preview {
color: #409eff;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.plagiarism-report-preview:hover {
text-decoration: underline;
color: #66b1ff;
}
.plagiarism-report-preview .el-icon-link {
font-size: 14px;
}
.plagiarism-check-no-pdf {
color: #f56c6c;
font-size: 12px;
}
.plagiarism-pdf-preview-body {
min-height: 70vh;
}
.plagiarism-pdf-preview-iframe {
width: 100%;
height: 70vh;
border: none;
background: #f5f7fa;
}
.el-upload__tip {
display: inline-block;
line-height: 32px;
@@ -3345,4 +4068,80 @@ td {
.copyright-declaration-wrapper :deep(.el-radio.is-checked .el-radio__label) {
font-weight: 500;
}
/* 详情页:去掉全局 main.css 中 .art_caozuo_ 的浅蓝底;查重行无额外底色 */
.article-detail-editor-page .art_caozuo_ {
background-color: #fff !important;
border: 1px solid #ebeef5 !important;
}
.detail-plagiarism-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
background: transparent !important;
padding: 4px 0 8px 0;
margin: 0 0 10px 0;
}
/* 合并「Repetition :」与数值,避免命中 .art_caozuo_ > div > span 的 min-width:65px 造成巨大空隙 */
.article-detail-editor-page .art_caozuo_ > div > span.detail-plagiarism-main {
min-width: 0 !important;
width: auto !important;
max-width: 100%;
margin-right: 8px !important;
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 2px 6px;
vertical-align: middle;
}
.article-detail-editor-page .art_caozuo_ .detail-plagiarism-lbl {
color: #777;
margin: 0 2px 0 0 !important;
min-width: 0 !important;
width: auto !important;
white-space: nowrap;
}
.detail-plagiarism-loading {
color: #409eff;
font-size: 13px;
font-weight: 600;
}
.detail-plagiarism-loading .el-icon-loading {
margin-right: 6px;
}
.detail-plagiarism-pct-text {
cursor: pointer;
font-size: 14px;
font-weight: 700;
background: none !important;
border: none !important;
padding: 0;
line-height: 1.2;
}
.detail-plagiarism-pct-text:hover {
opacity: 0.92;
}
.detail-plagiarism-not-checked-text {
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: #409eff;
text-decoration: underline;
text-underline-offset: 2px;
}
.detail-plagiarism-not-checked-text:hover {
color: #66b1ff;
}
.detail-plagiarism-recheck-btn {
margin-left: 4px !important;
font-weight: 600 !important;
}
/* 紧跟在百分比 / 未检测 文案后,避免继承整行大间距 */
.detail-plagiarism-main .detail-plagiarism-recheck-inline.el-button--text {
margin-left: 0 !important;
padding: 0 2px !important;
vertical-align: baseline;
line-height: inherit;
}
</style>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -198,8 +198,8 @@
<el-form-item label="Account :" prop="account">
<span>{{coreForm.account}}</span>
</el-form-item>
<el-form-item label="Email :">
<span>{{coreForm.email}}</span>
<el-form-item label="Email :" prop="email">
<el-input type="text" placeholder="Please enter email..." v-model="coreForm.email" clearable></el-input>
</el-form-item>
<el-form-item label="head portrait :">
<el-upload class="avatar-uploader" :action="baseUrl+'master/Journal/up_topic_file'"

View File

@@ -3,7 +3,7 @@
v-if="mode === 'dialog'"
:visible.sync="dialogVisible"
:close-on-click-modal="false"
width="1000px"
width="1200px"
top="5vh"
destroy-on-close
:title="title"
@@ -16,7 +16,15 @@
:selectedTemplateThumbHtml="selectedTemplateThumbHtml"
:selectedTemplateName="selectedTemplateName"
:selectedStyleName="selectedStyleName"
:availableFields="availableFields"
:availableCountries="availableCountries"
:fieldsLoading="fieldsLoading"
:fieldsSaving="fieldsSaving"
:selectedFieldIds.sync="selectedFieldIdsProxy"
:selectedCountryIds.sync="selectedCountryIdsProxy"
@open-template-selector="emitOpenTemplateSelector"
@confirm-fields="emitConfirmFields"
@confirm-countries="emitConfirmCountries"
@update:wizardStartDate="onWizardStartDateUpdate"
/>
@@ -43,7 +51,15 @@
:selectedTemplateThumbHtml="selectedTemplateThumbHtml"
:selectedTemplateName="selectedTemplateName"
:selectedStyleName="selectedStyleName"
:availableFields="availableFields"
:availableCountries="availableCountries"
:fieldsLoading="fieldsLoading"
:fieldsSaving="fieldsSaving"
:selectedFieldIds.sync="selectedFieldIdsProxy"
:selectedCountryIds.sync="selectedCountryIdsProxy"
@open-template-selector="emitOpenTemplateSelector"
@confirm-fields="emitConfirmFields"
@confirm-countries="emitConfirmCountries"
@update:wizardStartDate="onWizardStartDateUpdate"
/>
<div class="dialog-footer">
@@ -77,7 +93,13 @@ export default {
selectedTemplateThumbHtml: { type: String, default: '' },
selectedTemplateName: { type: String, default: '' },
selectedStyleName: { type: String, default: '' },
saving: { type: Boolean, default: false }
saving: { type: Boolean, default: false },
availableFields: { type: Array, default: () => [] },
availableCountries: { type: Array, default: () => [] },
fieldsLoading: { type: Boolean, default: false },
fieldsSaving: { type: Boolean, default: false },
selectedFieldIds: { type: Array, default: () => [] },
selectedCountryIds: { type: Array, default: () => [] }
},
computed: {
dialogVisible: {
@@ -96,6 +118,22 @@ export default {
this.$emit('update:wizardStartDate', val);
}
},
selectedFieldIdsProxy: {
get() {
return this.selectedFieldIds;
},
set(val) {
this.$emit('update:selectedFieldIds', val);
}
},
selectedCountryIdsProxy: {
get() {
return this.selectedCountryIds;
},
set(val) {
this.$emit('update:selectedCountryIds', val);
}
},
canConfirm() {
const id = this.config && this.config.defaultTemplateId != null ? String(this.config.defaultTemplateId) : '';
return id !== '' && id !== '0';
@@ -105,6 +143,12 @@ export default {
emitOpenTemplateSelector() {
this.$emit('open-template-selector');
},
emitConfirmFields() {
this.$emit('confirm-fields');
},
emitConfirmCountries() {
this.$emit('confirm-countries');
},
onWizardStartDateUpdate(val) {
// 由内容组件回传日期,继续走父组件的 .sync 链路
this.wizardStartDateProxy = val;

View File

@@ -76,7 +76,71 @@
<section class="form-section">
<h4 class="section-title">
<i class="el-icon-finished"></i> 2. {{ $t('autoPromotion.confirmAndEnable') }}
<i class="el-icon-collection-tag"></i> 2. {{ $t('autoPromotion.selectPromotionFields') }}
<span class="selected-count">
{{ $t('autoPromotion.selectedCount', { count: selectedFieldIdsProxy.length }) }}
</span>
<el-button
size="small"
type="primary"
plain
icon="el-icon-edit-outline"
class="section-action-btn"
@click="fieldDialogVisible = true"
>
{{ $t('autoPromotion.choosePromotionFields') }}
</el-button>
</h4>
<div class="status-confirm-box">
<div v-if="selectedFieldLabels.length" class="selected-tags">
<el-tag v-for="label in selectedFieldLabels" :key="label" size="mini" type="info" effect="plain">{{ label }}</el-tag>
</div>
<div class="field-tip">{{ $t('autoPromotion.selectPromotionFieldsTip') }}</div>
</div>
</section>
<el-divider></el-divider>
<section class="form-section">
<h4 class="section-title">
<i class="el-icon-location-outline"></i> 3. {{ $t('autoPromotion.selectPromotionCountry') }}
<!-- <span class="selected-count">
{{ $t('autoPromotion.selectedCount', { count: selectedCountryIdsProxy.length }) }}
</span> -->
<!-- <el-button
size="small"
type="primary"
plain
icon="el-icon-edit-outline"
class="section-action-btn"
@click="countryDialogVisible = true"
>
{{ $t('autoPromotion.choosePromotionCountry') }}
</el-button> -->
</h4>
<div class="status-confirm-box">
<div class="country-quick-checks">
<div class="field-tip" style="margin-bottom: 10px;">{{ $t('autoPromotion.selectPromotionCountryTip') }}</div>
<el-checkbox-group v-model="selectedCountryIdsProxy" size="small">
<el-checkbox label="Partition1">{{ $t('autoPromotion.countryQuickZone1') }}</el-checkbox>
<el-checkbox label="Partition2">{{ $t('autoPromotion.countryQuickZone2') }}</el-checkbox>
<el-checkbox label="Partition3">{{ $t('autoPromotion.countryQuickZone3') }}</el-checkbox>
<el-checkbox label="country_china" value="239">{{ $t('autoPromotion.countryQuickChina') }}</el-checkbox>
<el-checkbox label="country_india" value="228">{{ $t('autoPromotion.countryQuickIndia') }}</el-checkbox>
</el-checkbox-group>
</div>
<!-- <div v-if="selectedCountryTagRows.length" class="selected-tags">
<el-tag v-for="row in selectedCountryTagRows" :key="'c-' + row.id" size="mini" type="info" effect="plain">{{ row.text }}</el-tag>
</div> -->
</div>
</section>
<el-divider></el-divider>
<section class="form-section">
<h4 class="section-title">
<i class="el-icon-finished"></i> 4. {{ $t('autoPromotion.confirmAndEnable') }}
</h4>
<div class="status-confirm-box">
@@ -88,19 +152,103 @@
</div>
</section>
</div>
<el-dialog
:title="$t('autoPromotion.selectPromotionFields')"
:visible.sync="fieldDialogVisible"
width="1200px"
append-to-body
:close-on-click-modal="false"
>
<div class="field-dialog-toolbar">
<el-input
v-model="fieldSearchText"
size="small"
clearable
class="field-search-input"
prefix-icon="el-icon-search"
:placeholder="$t('autoPromotion.fieldSearchPlaceholder')"
/>
<el-button size="mini" @click="selectAllFields">{{ $t('autoPromotion.selectAll') }}</el-button>
<el-button size="mini" @click="clearAllFields">{{ $t('autoPromotion.clearAll') }}</el-button>
</div>
<div class="field-dialog-body" v-loading="fieldsLoading">
<el-checkbox-group v-model="selectedFieldIdsProxy" class="field-check-group">
<el-checkbox v-for="f in sortedFilteredFields" :key="String(f.id)" :label="String(f.id)">
{{ f.label }}
</el-checkbox>
</el-checkbox-group>
<div v-if="!fieldsLoading && sortedFilteredFields.length === 0" class="field-empty-tip">
{{ $t('autoPromotion.noFieldMatch') }}
</div>
</div>
<span slot="footer">
<el-button size="small" @click="fieldDialogVisible = false">{{ $t('autoPromotion.cancel') }}</el-button>
<el-button size="small" type="primary" :loading="fieldsSaving" @click="emitConfirmFields">{{ $t('autoPromotion.confirm') }}</el-button>
</span>
</el-dialog>
<el-dialog
:title="$t('autoPromotion.selectPromotionCountry')"
:visible.sync="countryDialogVisible"
width="1200px"
append-to-body
:close-on-click-modal="false"
>
<div class="field-dialog-toolbar">
<el-input
v-model="countrySearchText"
size="small"
clearable
class="field-search-input"
prefix-icon="el-icon-search"
:placeholder="$t('autoPromotion.countrySearchPlaceholder')"
/>
<el-button size="mini" @click="selectAllCountries">{{ $t('autoPromotion.selectAll') }}</el-button>
<el-button size="mini" @click="clearAllCountries">{{ $t('autoPromotion.clearAll') }}</el-button>
</div>
<div class="field-dialog-body" v-loading="fieldsLoading">
<el-checkbox-group v-model="selectedCountryIdsProxy" class="field-check-group">
<el-checkbox v-for="c in sortedFilteredCountries" :key="'country-' + String(c.id)" :label="String(c.id)">
{{ c.label }}
</el-checkbox>
</el-checkbox-group>
<div v-if="!fieldsLoading && sortedFilteredCountries.length === 0" class="field-empty-tip">
{{ $t('autoPromotion.noCountryMatch') }}
</div>
</div>
<span slot="footer">
<el-button size="small" @click="countryDialogVisible = false">{{ $t('autoPromotion.cancel') }}</el-button>
<el-button size="small" type="primary" :loading="fieldsSaving" @click="emitConfirmCountries">{{ $t('autoPromotion.confirm') }}</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
export default {
name: 'AutoPromotionWizardContent',
data() {
return {
fieldSearchText: '',
fieldDialogVisible: false,
countrySearchText: '',
countryDialogVisible: false
};
},
props: {
config: { type: Object, required: true },
wizardStartDate: { type: [String, Date], default: '' },
currentJournalName: { type: String, default: '' },
selectedTemplateThumbHtml: { type: String, default: '' },
selectedTemplateName: { type: String, default: '' },
selectedStyleName: { type: String, default: '' }
selectedStyleName: { type: String, default: '' },
availableFields: { type: Array, default: () => [] },
availableCountries: { type: Array, default: () => [] },
fieldsLoading: { type: Boolean, default: false },
fieldsSaving: { type: Boolean, default: false },
selectedFieldIds: { type: Array, default: () => [] },
selectedCountryIds: { type: Array, default: () => [] }
},
computed: {
hasSelectedTemplate() {
@@ -109,11 +257,119 @@ export default {
},
displayTemplateName() {
return this.selectedTemplateName || (this.hasSelectedTemplate ? String(this.config.defaultTemplateId) : '');
},
selectedFieldIdsProxy: {
get() {
return this.selectedFieldIds;
},
set(val) {
this.$emit('update:selectedFieldIds', val);
}
},
selectedCountryIdsProxy: {
get() {
return this.selectedCountryIds;
},
set(val) {
this.$emit('update:selectedCountryIds', val);
}
},
sortedFilteredFields() {
const kwRaw = String(this.fieldSearchText || '');
const normalize = (s) =>
String(s || '')
.trim()
.replace(/\s+/g, ' ')
.toLowerCase();
const tokens = kwRaw
? kwRaw
.split(/[\r\n,;]+/g)
.map((s) => normalize(s))
.filter(Boolean)
: [];
const list = (this.availableFields || []).filter((item) => {
if (!tokens.length) return true;
const label = normalize(item.label || '');
// 严格匹配:必须与字段名完全一致(忽略大小写与空白差异)
return tokens.some((t) => t === label);
});
return list.slice().sort((a, b) => String(a.label || '').localeCompare(String(b.label || '')));
},
sortedFilteredCountries() {
const kwRaw = String(this.countrySearchText || '');
const normalize = (s) =>
String(s || '')
.trim()
.replace(/\s+/g, ' ')
.toLowerCase();
const tokens = kwRaw
? kwRaw
.split(/[\r\n,;]+/g)
.map((s) => normalize(s))
.filter(Boolean)
: [];
const list = (this.availableCountries || []).filter((item) => {
if (!tokens.length) return true;
const label = normalize(item.label || '');
return tokens.some((t) => t === label);
});
return list.slice().sort((a, b) => String(a.label || '').localeCompare(String(b.label || '')));
},
selectedFieldLabels() {
const map = {};
(this.availableFields || []).forEach((i) => { map[String(i.id)] = i.label; });
return (this.selectedFieldIdsProxy || [])
.map((id) => map[String(id)])
.filter(Boolean);
},
selectedCountryTagRows() {
const map = {};
(this.availableCountries || []).forEach((i) => {
map[String(i.id)] = i.label;
});
const quick = {
zone_1: this.$t('autoPromotion.countryQuickZone1'),
zone_2: this.$t('autoPromotion.countryQuickZone2'),
zone_3: this.$t('autoPromotion.countryQuickZone3'),
country_china: this.$t('autoPromotion.countryQuickChina'),
country_india: this.$t('autoPromotion.countryQuickIndia')
};
return (this.selectedCountryIdsProxy || []).map((id) => {
const sid = String(id);
const fromList = map[sid];
const text =
fromList != null && fromList !== ''
? fromList
: quick[sid] != null
? quick[sid]
: sid;
return { id: sid, text };
});
}
},
methods: {
emitOpenTemplateSelector() {
this.$emit('open-template-selector');
},
selectAllFields() {
this.selectedFieldIdsProxy = (this.availableFields || []).map((f) => String(f.id));
},
clearAllFields() {
this.selectedFieldIdsProxy = [];
},
emitConfirmFields() {
this.$emit('confirm-fields');
this.fieldDialogVisible = false;
},
selectAllCountries() {
this.selectedCountryIdsProxy = (this.availableCountries || []).map((c) => String(c.id));
},
clearAllCountries() {
this.selectedCountryIdsProxy = [];
},
emitConfirmCountries() {
this.$emit('confirm-countries');
this.countryDialogVisible = false;
}
}
};
@@ -192,6 +448,9 @@ export default {
color: #409EFF;
font-size: 18px;
}
.section-action-btn {
margin-left: 0;
}
/* 优化后的模板选择框 */
.template-placeholder.mini-mode {
@@ -406,5 +665,70 @@ export default {
display: flex;
gap: 30px;
}
.field-check-group {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: 8px 14px;
margin-bottom: 10px;
}
.field-dialog-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.field-search-input {
width: 320px;
}
.field-choose-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 10px;
}
.selected-count {
font-size: 13px;
color: #606266;
margin-left: auto;
margin-right: 10px;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.field-dialog-body {
max-height: none;
overflow: visible;
}
.field-empty-tip {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.field-check-group >>> .el-checkbox {
margin-right: 0;
line-height: 20px;
}
.field-check-group >>> .el-checkbox__label {
font-size: 13px;
}
.field-tip {
font-size: 12px;
color: #909399;
}
.country-quick-checks {
margin-bottom: 12px;
}
.country-quick-checks >>> .el-checkbox-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
}
.country-quick-checks >>> .el-checkbox {
margin-right: 0;
}
</style>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
<template>
<el-dialog
:visible.sync="visible"
fullscreen
append-to-body
:show-close="false"
custom-class="mail-preview-dialog"
>
<div slot="title" class="preview-navbar">
<div class="navbar-left">
<div class="file-icon-box" :class="getFileTypeClass(file.name)">
<i :class="getFileIcon(file.name)"></i>
</div>
<span class="file-name" :title="file.name">{{ file.name }}</span>
</div>
<div class="navbar-right">
<div class="action-item" @click="handleDownload" :title="$t('mailboxCollect.downloadBtn')">
<i class="el-icon-download"></i>
</div>
<div class="action-item" @click="handlePrint" v-if="isPdf" :title="$t('mailboxCollect.printBtn')">
<i class="el-icon-printer"></i>
</div>
<div class="nav-divider"></div>
<div class="action-item close-btn" @click="visible = false">
<i class="el-icon-close"></i>
</div>
</div>
</div>
<div class="preview-body" v-loading="loading" element-loading-background="rgba(0, 0, 0, 0.7)">
<iframe
v-if="previewUrl"
:src="previewUrl"
class="preview-iframe"
frameborder="0"
@load="loading = false"
></iframe>
<div v-else-if="isImage" class="image-viewer">
<img :src="rawUrl" alt="preview" @load="loading = false" />
</div>
<div v-else-if="!loading" class="error-state">
<el-empty :description="$t('mailboxCollect.previewNotSupported')">
<el-button type="primary" size="small" @click="handleDownload">{{ $t('mailboxCollect.downloadToView') }}</el-button>
</el-empty>
</div>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'FilePreviewDialog',
data() {
return {
visible: false,
loading: false,
file: {},
rawUrl: '', // 原始地址
previewUrl: '', // 拼接后的预览地址
}
},
computed: {
fileExt() {
return this.file.name ? this.file.name.split('.').pop().toLowerCase() : '';
},
isImage() {
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(this.fileExt);
},
isPdf() {
return this.fileExt === 'pdf';
}
},
methods: {
/**
* 父组件调用此方法打开弹窗
* @param file 附件对象 {name: '', ...}
* @param url 文件的公网绝对路径
*/
init(file, url) {
this.file = file;
this.rawUrl = url;
this.loading = true;
this.visible = true;
const ext = this.fileExt;
// 策略选择
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) {
// Office系列使用微软接口
this.previewUrl = `https://view.officeapps.live.com/op/view.aspx?src=${url}`;
} else if (['pdf', 'txt'].includes(ext) || this.isImage) {
// 浏览器原生支持
this.previewUrl = url;
} else {
this.previewUrl = '';
this.loading = false;
}
},
handleDownload() {
this.$emit('download', this.file);
},
handlePrint() {
window.print();
},
// 复用你之前的图标逻辑
getFileIcon(name) {
if (!name) return 'el-icon-files';
var ext = name.split('.').pop().toLowerCase();
if (['doc', 'docx'].includes(ext)) return 'el-icon-document';
if (['jpg', 'png', 'jpeg'].includes(ext)) return 'el-icon-picture';
return 'el-icon-files';
},
getFileTypeClass(name) {
if (!name) return 'default';
var ext = name.split('.').pop().toLowerCase();
if (['doc', 'docx'].includes(ext)) return 'word';
if (['jpg', 'png', 'jpeg'].includes(ext)) return 'img';
return 'default';
}
}
}
</script>
<style lang="css">
/* 弹窗全屏覆盖与背景 */
.mail-preview-dialog {
background: #000 !important;
}
.mail-preview-dialog .el-dialog__header {
padding: 0;
}
.mail-preview-dialog .el-dialog__body {
padding: 0;
height: calc(100vh - 56px);
background: #333;
}
/* 顶部导航栏 */
.preview-navbar {
height: 56px;
background: #1a1a1a;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
}
.navbar-left {
display: flex;
align-items: center;
color: #fff;
max-width: 70%;
}
.file-icon-box {
width: 30px;
height: 30px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
font-size: 18px;
}
.file-icon-box.word { background: #0052d9; }
.file-icon-box.img { background: #e34d59; }
.file-icon-box.default { background: #737b85; }
.navbar-left .file-name {
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 右侧操作栏 */
.navbar-right {
display: flex;
align-items: center;
}
.action-item {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: #ccc;
cursor: pointer;
font-size: 20px;
transition: all 0.2s;
border-radius: 4px;
}
.action-item:hover {
background: #333;
color: #fff;
}
.close-btn:hover {
background: #e34d59;
}
.nav-divider {
width: 1px;
height: 20px;
background: #444;
margin: 0 10px;
}
/* 内容区域 */
.preview-body {
width: 100%;
height: 100%;
}
.preview-iframe {
width: 100%;
height: 100%;
background: #fff;
}
.image-viewer {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.image-viewer img {
max-width: 90%;
max-height: 90%;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
}
.error-state {
padding-top: 100px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
:close-on-click-modal="false"
width="90%"
top="5vh"
append-to-body
destroy-on-close
:before-close="handleClose"
custom-class="template-modal"

View File

@@ -1,174 +1,378 @@
<template>
<div class="tmr-editor-container">
<div class="editor-header">
<span class="title"></span>
<button @click="showModal = true" class="preview-trigger-btn">
<div class="tmr-editor-container">
<div class="editor-header">
<div class="title-slot">
<slot name="title"></slot>
</div>
<div class="header-actions">
<!-- <button type="button" @click="openPreview(false)" class="preview-trigger-btn">
<i class="icon-eye"></i> {{ $t('tmrEmailEditor.preview') }}
</button> -->
<button type="button" @click="openPreview(true)" class="preview-trigger-btn preview-with-vars-btn">
<i class="icon-eye"></i> {{ $t('tmrEmailEditor.previewWithVariables') }}
</button>
</div>
<textarea
:value="plainText"
@input="handleInput"
:placeholder="resolvedPlaceholder"
class="tmr-textarea"
></textarea>
<transition name="fade">
<div v-if="showModal" class="tmr-modal-mask" @click.self="showModal = false">
<div class="tmr-modal-container">
<div class="modal-header">
<span>{{ $t('tmrEmailEditor.preview') }}</span>
<button class="close-btn" @click="showModal = false">&times;</button>
</div>
<!-- <textarea
ref="editorRef"
class="tmr-textarea"
:value="plainText"
@input="handleInput"
:placeholder="resolvedPlaceholder"
></textarea> -->
<transition name="fade">
<div v-if="showModal" class="tmr-modal-mask" @click.self="closePreviewModal">
<div class="tmr-modal-container">
<div class="modal-header">
<span>{{ modalPreviewTitle }}</span>
<button type="button" class="close-btn" @click="closePreviewModal">&times;</button>
</div>
<div class="modal-body">
<p v-if="previewWithMockVariables" class="mock-preview-hint">{{ $t('tmrEmailEditor.previewWithVariablesHint') }}</p>
<div class="common_tmr_email_box" v-html="htmlContentForPreview"></div>
</div>
<div class="modal-body">
<div class="common_tmr_email_box" v-html="htmlContentForPreview"></div>
</div>
<div class="modal-footer">
<button type="button" class="confirm-btn" @click="closePreviewModal">{{ $t('tmrEmailEditor.close') }}</button>
</div>
</div>
</transition>
</div>
</template>
<script>
export default {
name: 'TmrEmailEditor',
props: {
value: { type: String, default: '' },
placeholder: { type: String, default: '' }
</div>
</transition>
</div>
</template>
<script>
export default {
name: 'TmrEmailEditor',
props: {
// 预期格式: <div class="common_tmr_email_box">内容...</div>
value: {
type: String,
default: ''
},
data() {
return {
showModal: false // 控制弹窗显示
}
journalList: {
type: Array,
default: []
},
computed: {
resolvedPlaceholder() {
return this.placeholder || this.$t('tmrEmailEditor.placeholder');
},
// 剥离外壳给 textarea 显示
plainText() {
if (!this.value) return '';
const regex = /<div class="common_tmr_email_box">([\s\S]*?)<\/div>/i;
const match = this.value.match(regex);
const content = (match && match[1]) ? match[1] : this.value;
return content.replace(/<br\s*\/?>/gi, '\n');
},
// 提取内部内容用于预览
htmlContentForPreview() {
const regex = /<div class="common_tmr_email_box">([\s\S]*?)<\/div>/i;
const match = this.value.match(regex);
return match ? match[1] : this.value;
}
journalId: {
default: ''
},
methods: {
handleInput(e) {
const rawValue = e.target.value;
const htmlContent = rawValue.replace(/\n/g, '<br>');
const finalResult = `<div class="common_tmr_email_box">${htmlContent}</div>`;
this.$emit('input', finalResult);
language: {
type: String,
default: 'en'
},
placeholder: {
type: String,
default: ''
}
},
data() {
return {
showModal: false,
/** 为 true 时预览会用 variableMockData 替换正文中的 {{变量名}} */
previewWithMockVariables: false,
/**
* 预览「带变量」时的假数据。键名须与模板里 {{键名}} 完全一致(含中文键)。
* 可对照本地变量清单维护,例如:约稿变量清单表格.docx
*/
variableMockData: {
// 以下为示例占位,请按实际模板增删改:
submission_url: "https://submission.tmrjournals.com/", // 投稿系统链接
eic_name: "Zhang San", // 主编姓名
editor_name: "Alice Wong" ,// 责任编辑姓名
expert_title: "Prof", // 专家职称 (如 Prof./Dr.)
expert_name: "John Doe", // 专家姓名
expert_field: "Biomedical Engineering", // 专家研究领域
representative_work_title: "Advanced Applications of AI in Medical Imaging.", // 专家代表作标题
ai_content_analysis: "【AI分析文章一句话总结】", // AI solicitation rationale
ai_advised_topics: "Based on your research expertise, we would particularly welcome submissions on topics such as 【这里是AI针对学者领域给特定约稿主题】, or other closely related areas that align with your work.", // AI suggested directions
}
}
},
computed: {
isZhLanguage() {
return String(this.language || '').toLowerCase() === 'zh';
},
localizedAiMockData() {
if (this.isZhLanguage) {
return {
ai_content_analysis: '【AI分析这篇文章一句话总结】。【我们希望也关注个领域】',
ai_advised_topics: '我们尤其关注如【方向/题目建议1】、【方向/题目建议2】以及【方向/题目建议3】等相关议题的研究进展。'
};
}
return {
ai_content_analysis: '【AI分析文章一句话总结】',
ai_advised_topics: 'Based on your research expertise, we would particularly welcome submissions on topics such as 【这里是AI针对学者领域给特定约稿主题】, or other closely related areas that align with your work.'
};
},
resolvedPlaceholder() {
return this.placeholder || (this.$t && this.$t('tmrEmailEditor.placeholder')) || '请输入邮件内容...';
},
// 将传入的 HTML 还原为 textarea 可读的纯文本
plainText() {
if (!this.value) return '';
return this.extractContent(this.value);
},
modalPreviewTitle() {
if (this.previewWithMockVariables) {
return this.$t('tmrEmailEditor.previewWithVariablesTitle');
}
return this.$t('tmrEmailEditor.preview');
},
// 用于预览的 HTML带变量模式会先替换 {{...}}
htmlContentForPreview() {
let html = this.value || '';
if (this.previewWithMockVariables) {
html = this.applyVariableMocks(html);
}
return html;
}
},
methods: {
openPreview(withMockVariables) {
this.previewWithMockVariables = !!withMockVariables;
this.showModal = true;
},
closePreviewModal() {
this.showModal = false;
this.previewWithMockVariables = false;
},
/**
* 将 HTML 中的 {{ key }} 替换为 variableMockData[key];未配置的键保持原样。
*/
applyVariableMocks(html) {
if (!html) return '';
const oneMonthLater = new Date();
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
// 格式化为 YYYY-MM-DD
const deadlineStr = oneMonthLater.toISOString().split('T')[0];
const journal_info=this.journalList.find(e=>e.journal_id==this.journalId)
const map = {
...this.variableMockData,
...this.localizedAiMockData,
journal_abbr: journal_info.jabbr, // 期刊缩写
journal_name: journal_info.title,// 期刊全称
journal_url: journal_info.website, // 期刊官网链接
journal_email: journal_info.email, // 期刊官方邮箱
indexing_databases: "ESCI, Scopus, ROAD", // 收录数据库
special_support_deadline:deadlineStr
} || {};
return html.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (full, rawKey) => {
const key = String(rawKey).trim();
if (!key) return full;
if (Object.prototype.hasOwnProperty.call(map, key) && map[key] != null && map[key] !== '') {
const val = map[key];
return typeof val === 'string' ? val : String(val);
}
return full;
});
},
/**
* 核心:剥离外层容器和换行符
* 将 <div class="...">line1<br>line2</div> 还原为 line1\nline2
*/
extractContent(html) {
if (!html) return '';
// 1. 匹配最外层 div 内部的内容
const wrapperRegex = /<div class="common_tmr_email_box">([\s\S]*)<\/div>/i;
const match = html.match(wrapperRegex);
let content = match ? match[1] : html;
// 2. 将 <br> 或 <br /> 替换为换行符 \n
content = content.replace(/<br\s*\/?>/gi, '\n');
// 3. (可选) 如果还有其他标签可以进一步清洗,目前保持原样
return content;
},
/**
* 输入处理:实时包装并提交
*/
handleInput(e) {
const text = e.target.value;
// 将换行符转回 HTML 换行
const htmlContent = text.replace(/\n/g, '<br>');
// 包装外层容器
const finalResult = `<div class="common_tmr_email_box">${htmlContent}</div>`;
// 触发 v-model 更新
this.$emit('input', finalResult);
}
}
</script>
<style scoped>
/* 1. 基础布局 */
.tmr-editor-container { width: 100%; position: relative; }
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.preview-trigger-btn {
background: #f0f2f5;
border: 1px solid #dcdfe6;
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
color: #606266;
font-size: 13px;
}
.preview-trigger-btn:hover { color: #409eff; border-color: #c6e2ff; background: #ecf5ff; }
.tmr-textarea {
width: 100%;
min-height: 70vh;
padding: 15px;
border: 1px solid #e4e7ed;
border-radius: 4px;
font-size: 14px;
line-height: 1.6;
box-sizing: border-box;
resize: vertical;
}
/* 2. 弹窗动画 */
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter, .fade-leave-to { opacity: 0; }
/* 3. 弹窗样式 */
.tmr-modal-mask {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.tmr-modal-container {
width: 1000px;
max-width: 90%;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
overflow: hidden;
}
.modal-header {
padding: 15px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
font-weight: bold;
}
.close-btn { border: none; background: none; font-size: 20px; cursor: pointer; color: #909399; }
.modal-body {
padding: 20px;
min-height: 60vh;
overflow-y: auto;
text-align: left;
}
.modal-footer {
padding: 10px 20px;
border-top: 1px solid #eee;
text-align: right;
}
.confirm-btn {
background: #409eff;
color: #fff;
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
}
/* 确保预览区域样式正确渲染 */
.common_tmr_email_box {
word-break: break-all;
line-height: 1.6;
}
</style>
}
</script>
<style scoped>
/* 1. 基础布局 */
.tmr-editor-container {
width: 100%;
position: relative;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
}
.header-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
flex-shrink: 0;
}
.preview-trigger-btn {
background: #fff;
border: 1px solid #dcdfe6;
padding: 6px 15px;
border-radius: 4px;
cursor: pointer;
color: #606266;
font-size: 13px;
transition: all 0.2s;
}
.preview-trigger-btn:hover {
color: #409eff;
border-color: #c6e2ff;
background: #ecf5ff;
}
/* 2. 编辑器样式 */
.tmr-textarea {
width: 100%;
min-height: 400px;
padding: 15px;
border: 1px solid #e4e7ed;
border-radius: 4px;
font-size: 14px;
line-height: 1.6;
box-sizing: border-box;
resize: vertical;
outline: none;
color: #333;
}
.tmr-textarea:focus {
border-color: #409eff;
}
/* 3. 弹窗动画 */
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter, .fade-leave-to { opacity: 0; }
/* 4. 弹窗样式 */
.tmr-modal-mask {
position: fixed;
z-index: 9999;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.tmr-modal-container {
width: 800px;
max-width: 90%;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 15px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: #303133;
}
.close-btn {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: #909399;
line-height: 1;
}
.close-btn:hover { color: #f56c6c; }
.modal-body {
padding: 30px;
min-height: 300px;
max-height: 70vh;
overflow-y: auto;
background-color: #fafafa; /* 模拟邮件阅读背景 */
}
/* 模拟邮件内容容器样式 */
.common_tmr_email_box {
background: #fff;
padding: 20px;
border: 1px solid #eee;
word-break: break-word;
line-height: 1.6;
white-space: pre-wrap; /* 保证换行显示 */
min-height: 100px;
}
.modal-footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
.confirm-btn {
background: #409eff;
color: #fff;
border: none;
padding: 8px 24px;
border-radius: 4px;
cursor: pointer;
}
.confirm-btn:hover { background: #66b1ff; }
.preview-with-vars-btn {
border-style: dashed;
}
.mock-preview-hint {
margin: 0 0 12px;
font-size: 12px;
color: red;
line-height: 1.5;
}
</style>

View File

@@ -17,9 +17,6 @@
:form="baseQuestionform"
@update="(e) => (questionform = e)"
></newForm>
</el-form>
</div>
</template>
@@ -134,7 +131,6 @@ export default {
recommend: [{ required: true, message: 'please select', trigger: 'blur' }]
};
}
},
questionSubmit() {
if (this.questionform.is_anonymous == '' && this.questionform.is_anonymous != '0') {
@@ -161,30 +157,29 @@ export default {
}
let Char_Cter = null;
// 验证相加的字数
if (this.isNewForm) {
Char_Cter = [
this.questionform.qu5contents,
this.questionform.qu6contents,
this.questionform.qu7contents,
this.questionform.qu8contents,
this.questionform.qu9contents,
this.questionform.qu10contents,
this.questionform.qu11contents,
this.questionform.qu12contents,
this.questionform.qu13contents,
this.questionform.qu14contents,
this.questionform.comment
].join(' ');
} else {
Char_Cter = [
this.questionform.qu9contents,
this.questionform.qu10contents,
this.questionform.qu11contents,
this.questionform.qu12contents,
this.questionform.qu13contents,
this.questionform.comment
].join(' ');
}
const contents = this.isNewForm
? [
this.questionform.qu5contents,
this.questionform.qu6contents,
this.questionform.qu7contents,
this.questionform.qu8contents,
this.questionform.qu9contents,
this.questionform.qu10contents,
this.questionform.qu11contents,
this.questionform.qu12contents,
this.questionform.qu13contents,
this.questionform.qu14contents,
this.questionform.comment
]
: [
this.questionform.qu9contents,
this.questionform.qu10contents,
this.questionform.qu11contents,
this.questionform.qu12contents,
this.questionform.qu13contents,
this.questionform.comment
];
Char_Cter = this.$commonJS.getCleanTextForCount(contents.join(' '));
if (new RegExp('[\\u4E00-\\u9FFF]+', 'g').test(Char_Cter)) {
//中文

View File

@@ -342,6 +342,7 @@ export default {
return content;
},
async questionSubmit() {
if (this.questionform.is_anonymous == '' && this.questionform.is_anonymous != '0') {
@@ -368,30 +369,29 @@ export default {
}
let Char_Cter = null;
// 验证相加的字数
if (this.isNewForm) {
Char_Cter = [
this.questionform.qu5contents,
this.questionform.qu6contents,
this.questionform.qu7contents,
this.questionform.qu8contents,
this.questionform.qu9contents,
this.questionform.qu10contents,
this.questionform.qu11contents,
this.questionform.qu12contents,
this.questionform.qu13contents,
this.questionform.qu14contents,
this.questionform.comment
].join(' ');
} else {
Char_Cter = [
this.questionform.qu9contents,
this.questionform.qu10contents,
this.questionform.qu11contents,
this.questionform.qu12contents,
this.questionform.qu13contents,
this.questionform.comment
].join(' ');
}
const contents = this.isNewForm ? [
this.questionform.qu5contents,
this.questionform.qu6contents,
this.questionform.qu7contents,
this.questionform.qu8contents,
this.questionform.qu9contents,
this.questionform.qu10contents,
this.questionform.qu11contents,
this.questionform.qu12contents,
this.questionform.qu13contents,
this.questionform.qu14contents,
this.questionform.comment
] : [
this.questionform.qu9contents,
this.questionform.qu10contents,
this.questionform.qu11contents,
this.questionform.qu12contents,
this.questionform.qu13contents,
this.questionform.comment
];
Char_Cter = this.$commonJS.getCleanTextForCount(contents.join(' '));
if (new RegExp('[\\u4E00-\\u9FFF]+', 'g').test(Char_Cter)) {
//中文

View File

@@ -1208,10 +1208,16 @@ export default {
hasChange: false,
hasInit: false,
selectedIds: [],
isMouseSelecting: false,
_selectionSyncToCheckboxesTimer: null,
_onDocumentSelectionChange: null,
_onDocumentMouseUp: null,
_onManuscriptMouseDown: null,
displayList: [],
currentTypeText: '',
tinymceId: this.id || 'vue-tinymce-' + +new Date()
};
},
// this.$nextTick(() => window.tinymce.get(this.tinymceId).setContent(newVal));
@@ -1274,51 +1280,60 @@ export default {
});
this.$refs.scrollDiv.addEventListener('scroll', this.divOnScroll, { passive: true });
document.addEventListener('selectionchange', () => {
if(this.isPreview)return;
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
// document.addEventListener('selectionchange', () => {
// if(this.isPreview)return;
// const selection = window.getSelection();
// if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
// 依然保留 trim() 后的文本判断,用来决定是否显示气泡
const plainText = selection.toString().trim();
// const range = selection.getRangeAt(0);
// // 依然保留 trim() 后的文本判断,用来决定是否显示气泡
// const plainText = selection.toString().trim();
if (plainText !== '' && selection.rangeCount > 0) {
// --- 1. 获取包含标签的 HTML 内容 ---
const fragment = range.cloneContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
// 关键点:这个 label 变量现在包含了完整的 HTML 结构(如 <myfigure>
const htmlLabel = tempDiv.innerHTML;
const allPMainElements = this.getInvolvedPMain(range);
const allIds = [...new Set(allPMainElements.map((el) => el.getAttribute('data-id')))];
if (allIds.length > 0) {
this.updateBubblePosition(range);
const rootItem = this.wordList.find((item) => item.am_id == allIds[0]);
// if (plainText !== '' && selection.rangeCount > 0) {
// // --- 1. 获取包含标签的 HTML 内容 ---
// const fragment = range.cloneContents();
// const tempDiv = document.createElement('div');
// tempDiv.appendChild(fragment);
// // 关键点:这个 label 变量现在包含了完整的 HTML 结构(如 <myfigure>
// const htmlLabel = tempDiv.innerHTML;
// const allPMainElements = this.getInvolvedPMain(range);
// const allIds = [...new Set(allPMainElements.map((el) => el.getAttribute('data-id')))];
// if (allIds.length > 0) {
// this.updateBubblePosition(range);
// const rootItem = this.wordList.find((item) => item.am_id == allIds[0]);
this.currentSelection = {
// 将 label 设置为包含标签的 HTML 字符串
label: htmlLabel,
mainId: allIds[0],
index: this.wordList.indexOf(rootItem),
// this.currentSelection = {
// // 将 label 设置为包含标签的 HTML 字符串
// label: htmlLabel,
// mainId: allIds[0],
// index: this.wordList.indexOf(rootItem),
content: rootItem ? rootItem.content : ''
};
// content: rootItem ? rootItem.content : ''
// };
this.currentId = allIds[0];
this.currentData = rootItem;
}
} else {
this.currentTag = '';
this.currentTagData = {};
this.currentSelection = {
label: '',
mainId: '',
index: 0,
content: {}
};
}
});
// this.currentId = allIds[0];
// this.currentData = rootItem;
// }
// } else {
// this.currentTag = '';
// this.currentTagData = {};
// this.currentSelection = {
// label: '',
// mainId: '',
// index: 0,
// content: {}
// };
// }
// });
this._onDocumentSelectionChange = this.handleDocumentSelectionChange.bind(this);
this._onDocumentMouseUp = this.handleDocumentMouseUp.bind(this);
this._onManuscriptMouseDown = this.handleManuscriptMouseDown.bind(this);
document.addEventListener('selectionchange', this._onDocumentSelectionChange);
document.addEventListener('mouseup', this._onDocumentMouseUp);
if (this.$refs.scroll) {
this.$refs.scroll.addEventListener('mousedown', this._onManuscriptMouseDown);
}
},
activated() {
// 主动触发 MathJax 渲染
@@ -1378,6 +1393,115 @@ export default {
return rangePs;
},
handleManuscriptMouseDown(event) {
if (this.isPreview) return;
const root = this.$refs && this.$refs.scroll;
if (!root) return;
if (root.contains(event.target)) {
this.isMouseSelecting = true;
}
},
handleDocumentMouseUp() {
this.isMouseSelecting = false;
},
handleDocumentSelectionChange() {
if (this.isPreview) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const plainText = selection.toString().trim();
if (plainText !== '' && selection.rangeCount > 0) {
const fragment = range.cloneContents();
const tempDiv = document.createElement('div');
tempDiv.appendChild(fragment);
const htmlLabel = tempDiv.innerHTML;
const allPMainElements = this.getInvolvedPMain(range);
const allIds = [...new Set(allPMainElements.map((el) => el.getAttribute('data-id')))];
if (allIds.length > 0) {
this.updateBubblePosition(range);
const rootItem = this.wordList.find((item) => item.am_id == allIds[0]);
this.currentSelection = {
label: htmlLabel,
mainId: allIds[0],
index: this.wordList.indexOf(rootItem),
content: rootItem ? rootItem.content : ''
};
this.currentId = allIds[0];
this.currentData = rootItem;
}
} else {
this.currentTag = '';
this.currentTagData = {};
this.currentSelection = {
label: '',
mainId: '',
index: 0,
content: {}
};
}
this.scheduleSyncSelectedIdsFromRange();
},
scheduleSyncSelectedIdsFromRange() {
if (this._selectionSyncToCheckboxesTimer) {
clearTimeout(this._selectionSyncToCheckboxesTimer);
}
this._selectionSyncToCheckboxesTimer = setTimeout(() => {
this.syncSelectedIdsFromRangeInternal();
}, 80);
},
syncSelectedIdsFromRangeInternal() {
if (this.isPreview || this.isInternalAction) return;
const scrollRoot = this.$refs && this.$refs.scroll;
if (!scrollRoot) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const common = range.commonAncestorContainer;
const commonElement = common && common.nodeType === 1 ? common : common && common.parentElement;
if (!commonElement || !scrollRoot.contains(commonElement)) return;
const nodes = Array.from(scrollRoot.querySelectorAll('.drop-target[main-id]'));
const touchedMainIds = [];
nodes.forEach((node) => {
const id = node.getAttribute('main-id');
if (!id || id === 'References') return;
try {
if (range.intersectsNode(node)) {
touchedMainIds.push(id);
}
} catch (e) {}
});
if (!touchedMainIds.length) return;
const indexMap = {};
this.wordList.forEach((item, idx) => {
if (item && item.am_id != null) {
indexMap[String(item.am_id)] = idx;
}
});
const indices = [...new Set(touchedMainIds.map((id) => indexMap[String(id)]).filter((v) => Number.isInteger(v)))];
if (!indices.length) return;
const lo = Math.min(...indices);
const hi = Math.max(...indices);
const rangeIds = this.wordList
.slice(lo, hi + 1)
.map((item) => (item && item.am_id != null ? item.am_id : null))
.filter((id) => id != null);
if (!rangeIds.length) return;
const existingSet = new Set(this.selectedIds || []);
rangeIds.forEach((id) => existingSet.add(id));
const ordered = this.wordList
.map((item) => (item && item.am_id != null ? item.am_id : null))
.filter((id) => id != null && existingSet.has(id));
this.selectedIds = ordered;
this.$forceUpdate();
},
handleUnbindLink(type) {
const rootItem = this.wordList.find((item) => item.am_id == this.currentTagData.main_id);

View File

@@ -0,0 +1,932 @@
<template>
<div class="country-manage">
<div class="crumbs">
<el-breadcrumb separator="/">
<el-breadcrumb-item> <i class="el-icon-place"></i> {{ $t('countryManagement.title') }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="toolbar">
<el-form :inline="true" :model="query" size="small">
<el-form-item>
<el-input
v-model="query.keyword"
clearable
:placeholder="$t('countryManagement.keywordPlaceholder')"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-select
v-model="query.partition"
clearable
:placeholder="$t('countryManagement.partitionAll')"
style="width: 140px"
@change="onPartitionChange"
>
<el-option :label="$t('countryManagement.partitionAll')" value="" />
<el-option :label="$t('countryManagement.partition1')" value="1" />
<el-option :label="$t('countryManagement.partition2')" value="2" />
<el-option :label="$t('countryManagement.partition3')" value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" :loading="loading" @click="handleSearch">
{{ $t('countryManagement.searchBtn') }}
</el-button>
<el-button @click="handleReset">{{ $t('countryManagement.resetBtn') }}</el-button>
<!-- 批量修改分区按钮 -->
<!-- <el-button type="warning" plain icon="el-icon-upload2" @click="openBatchPartitionDialog">
{{ $t('countryManagement.batchPartitionBtn') }}
</el-button> -->
</el-form-item>
</el-form>
</div>
<el-card shadow="never" class="table-card">
<el-table :data="list" border stripe v-loading="loading" header-row-class-name="dark-table-header">
<el-table-column type="index" :label="$t('countryManagement.table.no')" width="70" align="center" />
<el-table-column prop="zh_name" :label="$t('countryManagement.table.zhName')" min-width="140" show-overflow-tooltip />
<el-table-column prop="en_name" :label="$t('countryManagement.table.enName')" min-width="160" show-overflow-tooltip />
<el-table-column prop="code" :label="$t('countryManagement.table.code')" width="100" align="center" />
<el-table-column prop="partition" :label="$t('countryManagement.table.partition')" width="100" align="center" />
<el-table-column :label="$t('countryManagement.table.actions')" width="220" align="center" fixed="right">
<template slot-scope="scope">
<div class="table-row-actions">
<el-button
type="primary"
plain
size="mini"
icon="el-icon-edit"
class="action-btn action-btn--edit"
@click="openEdit(scope.row)"
>
{{ $t('countryManagement.edit') }}
</el-button>
<el-button
type="danger"
plain
size="mini"
icon="el-icon-delete"
class="action-btn action-btn--delete"
@click="handleDelete(scope.row)"
>
{{ $t('countryManagement.delete') }}
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:current-page="query.page"
:page-size="query.per_page"
:page-sizes="[20, 50, 100]"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
<el-dialog
:title="$t('countryManagement.editTitle')"
:visible.sync="editVisible"
width="580px"
append-to-body
destroy-on-close
@closed="resetForm"
>
<el-form ref="editForm" :model="form" :rules="rules" label-width="150px" size="small" class="country-edit-form">
<el-form-item :label="$t('countryManagement.form.zhName')" prop="zh_name">
<el-input v-model="form.zh_name" />
</el-form-item>
<el-form-item :label="$t('countryManagement.form.enName')" prop="en_name">
<el-input v-model="form.en_name" />
</el-form-item>
<el-form-item :label="$t('countryManagement.form.code')" prop="code">
<el-input v-model="form.code" />
</el-form-item>
<el-form-item :label="$t('countryManagement.form.partition')" prop="partition">
<el-select v-model="form.partition" style="width: 100%">
<el-option :label="$t('countryManagement.partition1')" value="1" />
<el-option :label="$t('countryManagement.partition2')" value="2" />
<el-option :label="$t('countryManagement.partition3')" value="3" />
</el-select>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="editVisible = false">{{ $t('countryManagement.cancel') }}</el-button>
<el-button type="primary" :loading="saveLoading" @click="submitEdit">{{ $t('countryManagement.save') }}</el-button>
</span>
</el-dialog>
<el-dialog
:title="$t('countryManagement.batchPartitionTitle')"
:visible.sync="batchPartitionVisible"
width="80vw"
append-to-body
destroy-on-close
@closed="resetBatchPartitionDialog"
>
<div style="display: flex; align-items: center; justify-content: space-between">
<div class="batch-partition-content" style="width: 100%">
<el-form label-width="120px" size="small" class="batch-partition-target-form">
<el-form-item :label="$t('countryManagement.batchPartitionTargetLabel')">
<el-select
v-model="batchPartitionTargetPartition"
style="width: 220px"
@change="handleBatchPartitionDraftChange"
>
<el-option :label="$t('countryManagement.partition1')" value="1" />
<el-option :label="$t('countryManagement.partition2')" value="2" />
<el-option :label="$t('countryManagement.partition3')" value="3" />
</el-select>
</el-form-item>
</el-form>
<p class="batch-partition-help">{{ $t('countryManagement.batchPartitionHelp') }}</p>
<el-input
v-model="batchPartitionInput"
type="textarea"
:rows="20"
:placeholder="$t('countryManagement.batchPartitionPlaceholder')"
@input="handleBatchPartitionDraftChange"
/>
<div class="batch-partition-actions">
<el-button type="primary" plain :loading="batchPartitionPreviewLoading" @click="previewBatchPartition">
{{ $t('countryManagement.batchPartitionPreview') }}
</el-button>
<el-button
v-if="batchPartitionToApplyBaseRows.length > 0"
type="success"
:loading="batchPartitionApplyLoading"
:disabled="!batchPartitionPreviewRows.length"
@click="applyBatchPartition"
>
{{ $t('countryManagement.batchPartitionApply') }}
</el-button>
</div>
</div>
<div v-if="batchPartitionStats.input > 0" class="batch-partition-summary-container" style="margin-left: 20px">
<div v-if="batchPartitionStats.input > 0" class="batch-partition-summary">
{{
$t('countryManagement.batchPartitionSummaryLine', {
input: String(batchPartitionStats.input),
matched: String(batchPartitionStats.matched),
miss: String(batchPartitionStats.miss),
same: String(batchPartitionStats.skipSame),
will: String(batchPartitionStats.willUpdate)
})
}}
</div>
<p v-if="batchPartitionPreviewRows.length" class="batch-partition-selection-hint">
{{ $t('countryManagement.batchPartitionSelectionHint') }}
</p>
<el-table
v-if="batchPartitionPreviewRows.length"
:data="batchPartitionFilteredPreviewRows"
border
stripe
size="small"
max-height="400"
class="batch-partition-table"
:row-class-name="batchPartitionRowClassName"
>
<el-table-column type="index" :label="$t('countryManagement.table.no')" width="64" align="center" />
<el-table-column
prop="key"
:label="$t('countryManagement.batchPartitionColKey')"
min-width="120"
show-overflow-tooltip
/>
<el-table-column prop="country_id" :label="$t('countryManagement.batchPartitionColId')" width="90" align="center" />
<el-table-column
prop="en_name"
:label="$t('countryManagement.batchPartitionColName')"
min-width="140"
show-overflow-tooltip
/>
<el-table-column :label="$t('countryManagement.batchPartitionColCurrentPartition')" width="140" align="center">
<template slot-scope="scope">
<span :class="['partition-chip', scope.row.status === 'diff' ? 'partition-chip--warn' : '']">
{{ scope.row.currentPartition || '—' }}
</span>
</template>
</el-table-column>
<el-table-column :label="$t('countryManagement.batchPartitionColMatch')" width="150" align="center">
<template slot-scope="scope">
<span
:class="[
'match-chip',
scope.row.status === 'not_found'
? 'match-chip--danger'
: scope.row.status === 'diff'
? 'match-chip--warn'
: 'match-chip--ok'
]"
>
{{ scope.row.matchLabel }}
</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
export default {
name: 'countryManagement',
data() {
return {
query: {
keyword: '',
partition: '',
page: 1,
per_page: 20
},
list: [],
total: 0,
loading: false,
editVisible: false,
saveLoading: false,
form: {
country_id: '',
zh_name: '',
en_name: '',
code: '',
partition: '1'
},
rules: {},
batchPartitionVisible: false,
batchPartitionTargetPartition: '1',
batchPartitionInput: '',
batchPartitionPreviewRows: [],
batchPartitionPreviewLoading: false,
batchPartitionApplyLoading: false,
batchPartitionAllRows: [],
/** 预览后的统计:录入、匹配、将更新等 */
batchPartitionStats: {
input: 0,
matched: 0,
miss: 0,
skipSame: 0,
willUpdate: 0
},
/** 预览表筛选关键词(分号、逗号、换行分隔多词) */
batchPartitionTableFilter: '',
/** none未勾选视为提交全部可更新行explicit仅提交已勾选 */
batchPartitionSelectionMode: 'none',
batchPartitionSelectedIds: []
};
},
computed: {
batchPartitionFilteredPreviewRows() {
const rows = this.batchPartitionPreviewRows || [];
const tokens = this.batchPartitionFilterTokens(this.batchPartitionTableFilter);
if (!tokens.length) return rows;
return rows.filter((r) => this.batchPartitionRowMatchesTokens(r, tokens));
},
batchPartitionToApplyBaseRows() {
return (this.batchPartitionPreviewRows || []).filter(
(r) => r._row && String(r._row.partition != null ? r._row.partition : '') !== String(r.partition)
);
},
batchPartitionEffectiveToApplyRows() {
const base = this.batchPartitionToApplyBaseRows;
if (this.batchPartitionSelectionMode === 'none') return base;
const ids = new Set((this.batchPartitionSelectedIds || []).map(String));
return base.filter((r) => ids.has(this.countryRowId(r._row)));
}
},
created() {
this.rules = {
zh_name: [{ required: true, message: this.$t('countryManagement.ruleZhName'), trigger: 'blur' }],
en_name: [{ required: true, message: this.$t('countryManagement.ruleEnName'), trigger: 'blur' }],
code: [{ required: true, message: this.$t('countryManagement.ruleCode'), trigger: 'blur' }],
partition: [{ required: true, message: this.$t('countryManagement.rulePartition'), trigger: 'change' }]
};
this.fetchList();
},
methods: {
countryRowId(row) {
if (!row) return '';
return row.country_id != null ? String(row.country_id) : row.id != null ? String(row.id) : '';
},
normalizeListResponse(res) {
const d = res && res.data;
if (!d) return { list: [], total: 0 };
const list = d.list || d.data || d.rows || [];
const total = Number(d.total != null ? d.total : d.count != null ? d.count : list.length) || 0;
return { list: Array.isArray(list) ? list : [], total };
},
async fetchList() {
this.loading = true;
try {
const params = {
keyword: this.query.keyword || '',
partition: this.query.partition === '' || this.query.partition == null ? '' : String(this.query.partition),
page: this.query.page,
per_page: this.query.per_page
};
const res = await this.$api.post('api/Country/getList', params);
if (res && res.code === 0) {
const { list, total } = this.normalizeListResponse(res);
this.list = list;
this.total = total;
} else {
this.list = [];
this.total = 0;
if (res && res.msg) this.$message.warning(res.msg);
}
} catch (e) {
this.list = [];
this.total = 0;
this.$message.error(this.$t('countryManagement.loadFailed'));
} finally {
this.loading = false;
}
},
handleSearch() {
this.query.page = 1;
this.fetchList();
},
/** 切换分区即请求列表,无需再点搜索 */
onPartitionChange() {
this.query.page = 1;
this.fetchList();
},
handleReset() {
this.query = {
keyword: '',
partition: '',
page: 1,
per_page: 20
};
this.fetchList();
},
handleSizeChange(size) {
this.query.per_page = size;
this.query.page = 1;
this.fetchList();
},
handlePageChange(page) {
this.query.page = page;
this.fetchList();
},
openEdit(row) {
const id = this.countryRowId(row);
this.form = {
country_id: id,
zh_name: (row && row.zh_name) || '',
en_name: (row && row.en_name) || '',
code: (row && row.code) || '',
partition: row && row.partition != null && row.partition !== '' ? String(row.partition) : '1'
};
this.editVisible = true;
this.$nextTick(() => {
if (this.$refs.editForm) this.$refs.editForm.clearValidate();
});
},
resetForm() {
this.form = {
country_id: '',
zh_name: '',
en_name: '',
code: '',
partition: '1'
};
},
submitEdit() {
this.$refs.editForm.validate(async (valid) => {
if (!valid) return;
this.saveLoading = true;
try {
const params = {
country_id: String(this.form.country_id),
zh_name: this.form.zh_name,
en_name: this.form.en_name,
code: this.form.code,
partition: String(this.form.partition)
};
const res = await this.$api.post('api/Country/edit', params);
if (res && res.code === 0) {
this.$message.success(this.$t('countryManagement.saveSuccess'));
this.editVisible = false;
this.fetchList();
} else {
this.$message.error((res && res.msg) || this.$t('countryManagement.opFailed'));
}
} catch (e) {
this.$message.error(this.$t('countryManagement.opFailed'));
} finally {
this.saveLoading = false;
}
});
},
handleDelete(row) {
const id = this.countryRowId(row);
if (!id) {
this.$message.warning(this.$t('countryManagement.missingId'));
return;
}
this.$confirm(this.$t('countryManagement.deleteConfirm'), this.$t('countryManagement.deleteTitle'), {
type: 'warning',
confirmButtonText: this.$t('countryManagement.confirm'),
cancelButtonText: this.$t('countryManagement.cancel')
})
.then(async () => {
try {
const res = await this.$api.post('api/Country/delete', { country_id: String(id) });
if (res && res.code === 0) {
this.$message.success(this.$t('countryManagement.deleteSuccess'));
this.fetchList();
} else {
this.$message.error((res && res.msg) || this.$t('countryManagement.opFailed'));
}
} catch (e) {
this.$message.error(this.$t('countryManagement.opFailed'));
}
})
.catch(() => {});
},
openBatchPartitionDialog() {
this.batchPartitionVisible = true;
},
resetBatchPartitionDialog() {
this.batchPartitionTargetPartition = '1';
this.batchPartitionInput = '';
this.batchPartitionPreviewRows = [];
this.batchPartitionAllRows = [];
this.resetBatchPartitionStats();
this.batchPartitionTableFilter = '';
this.batchPartitionSelectionMode = 'none';
this.batchPartitionSelectedIds = [];
},
handleBatchPartitionDraftChange() {
if (!this.batchPartitionPreviewRows.length && !(this.batchPartitionStats && this.batchPartitionStats.input > 0)) return;
this.batchPartitionPreviewRows = [];
this.batchPartitionAllRows = [];
this.resetBatchPartitionStats();
this.batchPartitionTableFilter = '';
this.batchPartitionSelectionMode = 'none';
this.batchPartitionSelectedIds = [];
},
batchPartitionFilterTokens(text) {
return String(text || '')
.toLowerCase()
.split(/[;\n,]+/g)
.map((s) => s.trim())
.filter(Boolean);
},
batchPartitionRowMatchesTokens(pr, tokens) {
if (!tokens || !tokens.length) return true;
const parts = [
pr.key,
pr.en_name,
pr.country_id,
pr.matchLabel,
pr._row && pr._row.code,
pr._row && pr._row.zh_name,
pr._row && pr._row.en_name
]
.filter((x) => x != null && String(x).trim() !== '')
.map((x) => String(x).toLowerCase());
const hay = parts.join(' ');
return tokens.some((t) => hay.indexOf(t) !== -1);
},
isBatchPartitionRowUpdatable(pr) {
if (!pr || !pr._row) return false;
return String(pr._row.partition != null ? pr._row.partition : '') !== String(pr.partition);
},
isBatchPartitionRowSelected(pr) {
if (!pr._row || this.batchPartitionSelectionMode === 'none') return false;
const id = this.countryRowId(pr._row);
return (this.batchPartitionSelectedIds || []).map(String).includes(String(id));
},
onBatchPartitionNativeCheckboxChange(pr, ev) {
const checked = !!(ev && ev.target && ev.target.checked);
const id = pr._row ? this.countryRowId(pr._row) : '';
if (!id || !this.isBatchPartitionRowUpdatable(pr)) return;
let ids = (this.batchPartitionSelectedIds || []).map(String);
if (this.batchPartitionSelectionMode === 'none') {
if (checked) {
this.batchPartitionSelectionMode = 'explicit';
ids = [String(id)];
}
} else {
if (checked) {
if (!ids.includes(String(id))) ids.push(String(id));
} else {
ids = ids.filter((x) => x !== String(id));
}
}
if (this.batchPartitionSelectionMode === 'explicit' && ids.length === 0) {
this.batchPartitionSelectionMode = 'none';
}
this.batchPartitionSelectedIds = ids;
},
selectAllFilteredBatchPartitionRows() {
const ids = [];
(this.batchPartitionFilteredPreviewRows || []).forEach((r) => {
if (!this.isBatchPartitionRowUpdatable(r)) return;
const id = this.countryRowId(r._row);
if (id) ids.push(String(id));
});
if (!ids.length) {
this.$message.info(this.$t('countryManagement.batchPartitionSkipSame'));
return;
}
this.batchPartitionSelectionMode = 'explicit';
this.batchPartitionSelectedIds = [...new Set(ids)];
},
clearBatchPartitionRowSelection() {
this.batchPartitionSelectionMode = 'none';
this.batchPartitionSelectedIds = [];
},
batchPartitionRowClassName({ row }) {
if (!row) return '';
if (row.status === 'not_found') return 'batch-row-not-found';
if (row.status === 'diff') return 'batch-row-mismatch';
return '';
},
resetBatchPartitionStats() {
this.batchPartitionStats = {
input: 0,
matched: 0,
miss: 0,
skipSame: 0,
willUpdate: 0
};
},
recomputeBatchPartitionStats() {
const rows = this.batchPartitionPreviewRows || [];
let matched = 0;
let miss = 0;
let skipSame = 0;
let willUpdate = 0;
rows.forEach((r) => {
if (!r._row) {
miss += 1;
return;
}
matched += 1;
const cur = String(r._row.partition != null ? r._row.partition : '');
if (cur === String(r.partition)) {
skipSame += 1;
} else {
willUpdate += 1;
}
});
this.batchPartitionStats = {
input: rows.length,
matched,
miss,
skipSame,
willUpdate
};
},
parseBatchPartitionLines(text) {
const lines = String(text || '')
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'));
return lines.map((key) => ({ key }));
},
findCountryRowForBatch(rows, keyRaw) {
const key = String(keyRaw || '').trim();
if (!key || !rows || !rows.length) return null;
const upper = key.toUpperCase();
if (upper.length === 3) {
const byCode = rows.find((r) => String(r.code || '').toUpperCase() === upper);
if (byCode) return byCode;
}
const lower = key.toLowerCase();
return (
rows.find(
(r) =>
String(r.en_name || '')
.trim()
.toLowerCase() === lower
) ||
rows.find((r) => String(r.zh_name || '').trim() === key) ||
null
);
},
async fetchAllCountryRowsForBatch() {
const all = [];
let page = 1;
const per_page = 300;
while (true) {
const res = await this.$api.post('api/Country/getList', {
keyword: '',
partition: '',
page,
per_page
});
if (!res || res.code !== 0) {
throw new Error('getList');
}
const { list, total } = this.normalizeListResponse(res);
if (!Array.isArray(list) || list.length === 0) break;
all.push(...list);
if (all.length >= total) break;
page += 1;
if (page > 500) break;
}
return all;
},
async previewBatchPartition() {
const targetPartition = String(this.batchPartitionTargetPartition || '').trim();
if (!['1', '2', '3'].includes(targetPartition)) {
this.$message.warning(this.$t('countryManagement.batchPartitionTargetRequired'));
return;
}
const parsed = this.parseBatchPartitionLines(this.batchPartitionInput);
if (!parsed.length) {
this.$message.warning(this.$t('countryManagement.batchPartitionEmpty'));
this.batchPartitionPreviewRows = [];
this.resetBatchPartitionStats();
return;
}
this.batchPartitionPreviewLoading = true;
this.batchPartitionPreviewRows = [];
this.resetBatchPartitionStats();
try {
this.batchPartitionAllRows = await this.fetchAllCountryRowsForBatch();
} catch (e) {
this.batchPartitionAllRows = [];
this.batchPartitionPreviewRows = [];
this.resetBatchPartitionStats();
this.$message.error(this.$t('countryManagement.batchPartitionLoadListFailed'));
this.batchPartitionPreviewLoading = false;
return;
}
const rows = this.batchPartitionAllRows;
this.batchPartitionPreviewRows = parsed.map((item) => {
const row = this.findCountryRowForBatch(rows, item.key);
const id = row ? this.countryRowId(row) : '';
const curPart = row && row.partition != null && row.partition !== '' ? String(row.partition) : '';
let matchLabel = this.$t('countryManagement.batchPartitionMissing');
let status = 'not_found';
if (row) {
if (curPart === targetPartition) {
matchLabel = this.$t('countryManagement.batchPartitionSkipSame');
status = 'same';
} else {
matchLabel = this.$t('countryManagement.batchPartitionMismatch');
status = 'diff';
}
}
return {
key: item.key,
partition: targetPartition,
currentPartition: curPart || '—',
matchLabel,
status,
country_id: id || '—',
en_name: (row && row.en_name) || '—',
_row: row
};
});
if (!this.batchPartitionPreviewRows.length) {
this.$message.warning(this.$t('countryManagement.batchPartitionPreviewEmpty'));
}
this.recomputeBatchPartitionStats();
this.batchPartitionTableFilter = '';
this.batchPartitionSelectionMode = 'none';
this.batchPartitionSelectedIds = [];
this.batchPartitionPreviewLoading = false;
},
async applyBatchPartition() {
if (!this.batchPartitionPreviewRows.length) {
this.$message.warning(this.$t('countryManagement.batchPartitionPreviewEmpty'));
return;
}
const toApply = this.batchPartitionEffectiveToApplyRows.slice();
if (this.batchPartitionSelectionMode === 'explicit' && toApply.length === 0) {
this.$message.warning(this.$t('countryManagement.batchPartitionNoSelection'));
return;
}
if (!toApply.length) {
this.$message.info(this.$t('countryManagement.batchPartitionSkipSame'));
return;
}
try {
await this.$confirm(
this.$t('countryManagement.batchPartitionApplyConfirm', { n: String(toApply.length) }),
this.$t('countryManagement.batchPartitionTitle'),
{
type: 'warning',
confirmButtonText: this.$t('countryManagement.confirm'),
cancelButtonText: this.$t('countryManagement.cancel')
}
);
} catch (e) {
return;
}
this.batchPartitionApplyLoading = true;
let ok = 0;
let fail = 0;
for (let i = 0; i < toApply.length; i++) {
const pr = toApply[i];
const row = pr._row;
const params = {
country_id: String(this.countryRowId(row)),
zh_name: (row && row.zh_name) || '',
en_name: (row && row.en_name) || '',
code: (row && row.code) || '',
partition: String(pr.partition)
};
try {
const res = await this.$api.post('api/Country/edit', params);
if (res && res.code === 0) ok += 1;
else fail += 1;
} catch (e) {
fail += 1;
}
}
const miss = this.batchPartitionPreviewRows.filter((r) => !r._row).length;
const inputN = String(this.batchPartitionStats.input || this.batchPartitionPreviewRows.length);
this.$message.success(
this.$t('countryManagement.batchPartitionDone', {
ok: String(ok),
fail: String(fail),
miss: String(miss),
input: inputN
})
);
this.batchPartitionApplyLoading = false;
this.batchPartitionVisible = false;
this.fetchList();
}
}
};
</script>
<style scoped>
.country-manage {
padding: 0 10px;
}
.crumbs {
margin-bottom: 10px;
}
.toolbar {
/* margin-bottom: 15px; */
margin-top: 20px;
}
.table-card {
margin-top: 0;
}
.pagination {
margin-top: 15px;
text-align: right;
}
::v-deep .dark-table-header th {
background-color: #f5f7fa;
font-weight: 600;
}
.table-row-actions {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px;
}
.table-row-actions .action-btn {
margin: 0;
padding: 7px 12px;
border-radius: 4px;
font-weight: 500;
}
.table-row-actions .action-btn--edit {
background-color: #ecf5ff;
border-color: #b3d8ff;
color: #409eff;
}
.table-row-actions .action-btn--edit:hover {
background-color: #d9ecff;
border-color: #409eff;
color: #409eff;
}
.table-row-actions .action-btn--delete {
background-color: #fef0f0;
border-color: #fbc4c4;
color: #f56c6c;
}
.table-row-actions .action-btn--delete:hover {
background-color: #fde2e2;
border-color: #f56c6c;
color: #f56c6c;
}
.batch-partition-help {
font-size: 12px;
color: #606266;
line-height: 1.6;
white-space: pre-line;
margin: 0 0 10px;
}
.batch-partition-target-form {
margin-bottom: 4px;
}
.batch-partition-actions {
margin: 12px 0;
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.batch-partition-summary {
font-size: 13px;
color: #303133;
line-height: 1.55;
margin: 0 0 10px;
padding: 10px 12px;
background: #f4f6f9;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.batch-partition-selection-hint {
font-size: 12px;
color: #606266;
line-height: 1.55;
margin: 0 0 8px;
}
.batch-partition-table-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-bottom: 8px;
}
.batch-partition-filter-input {
width: min(320px, 100%);
flex: 1;
min-width: 200px;
}
.batch-partition-filter-count {
font-size: 12px;
color: #909399;
margin-left: 4px;
}
.batch-partition-checkbox-placeholder {
color: #dcdfe6;
font-size: 12px;
}
.batch-partition-native-checkbox {
width: 14px;
height: 14px;
cursor: pointer;
vertical-align: middle;
accent-color: #409eff;
}
.batch-partition-table {
margin-top: 8px;
}
.partition-chip,
.match-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 62px;
padding: 2px 8px;
border-radius: 12px;
line-height: 18px;
font-size: 12px;
}
.partition-chip--warn,
.match-chip--warn {
color: #8a5700;
background: #fff7e6;
border: 1px solid #ffd591;
}
.match-chip--danger {
color: #a8071a;
background: #fff1f0;
border: 1px solid #ffa39e;
}
.match-chip--ok {
color: #237804;
background: #f6ffed;
border: 1px solid #b7eb8f;
}
::v-deep .batch-row-not-found td {
background: #fff5f5 !important;
}
::v-deep .batch-row-mismatch td {
background: #fffdf0 !important;
}
</style>

View File

@@ -0,0 +1,344 @@
<template>
<div class="monitor-container">
<div class="control-panel">
<div class="panel-left">
<el-radio-group v-model="filterStatus" size="small" class="status-group" @change="handleFilter">
<el-radio-button label="">{{ $t('crawlTask.allKeywords') }}</el-radio-button>
<el-radio-button label="0">{{ $t('crawlTask.enabled') }}</el-radio-button>
<el-radio-button label="1">{{ $t('crawlTask.disabled') }}</el-radio-button>
</el-radio-group>
<el-input
v-model="searchText"
:placeholder="$t('crawlTask.searchPlaceholder')"
prefix-icon="el-icon-search"
size="small"
class="search-box"
clearable
@keyup.enter.native="handleFilter"
/>
<el-button type="primary" size="small" icon="el-icon-search" @click="handleSearchClick">
{{ $t('crawlTask.searchBtn') }}
</el-button>
</div>
<div class="panel-right">
<el-button type="primary" size="small" icon="el-icon-plus" @click="openAddDialog">
{{ $t('crawlTask.addKeyword') }}
</el-button>
</div>
</div>
<div v-loading="loading" class="task-list">
<el-empty v-if="list.length === 0" :description="$t('crawlTask.emptyResult')" />
<div
v-for="item in list"
:key="item.id"
class="task-row"
:class="item.state === 'running' ? 'is-active' : 'is-paused'"
>
<div class="col-base">
<div class="status-indicator">
<div class="status-dot"></div>
</div>
<div class="info-content">
<div class="task-name-row">
<span class="task-id">#{{ item.id }}</span>
<span class="task-name">{{ item.task_name }}</span>
</div>
<div class="task-meta">
<el-tag size="mini" effect="plain" type="info">{{ item.source }}</el-tag>
<span class="time-label"><i class="el-icon-time"></i> {{ item.create_time }}</span>
</div>
</div>
</div>
<div class="col-metrics">
<div class="metric-block main">
<span class="m-label">{{ $t('crawlTask.metricExperts') }}</span>
<span class="m-value expert-count">{{ item.expert_count }}</span>
</div>
<div class="divider"></div>
<div class="metric-block">
<span class="m-label">{{ $t('crawlTask.metricPages') }}</span>
<span class="m-value">{{ item.last_page }} <small>/ {{ item.total_pages }}</small></span>
</div>
</div>
<div class="col-progress">
<div class="prog-text">
<span>{{ $t('crawlTask.progress') }}</span>
<span class="percent">{{ item.progress }}%</span>
</div>
<el-progress
:percentage="item.progress"
:show-text="false"
:stroke-width="6"
:color="progressStrokeColor(item)"
/>
</div>
<div class="col-action">
<div class="switch-wrapper">
<span class="state-text" :class="item.state">{{
item.state === 'running' ? $t('crawlTask.stateRunning') : $t('crawlTask.stateStopped')
}}</span>
<el-switch
v-model="item.state"
active-value="running"
inactive-value="paused"
active-color="#13ce66"
inactive-color="#c0c4cc"
@change="handleToggleTask(item)"
/>
</div>
<el-button
type="primary"
size="mini"
plain
icon="el-icon-refresh-right"
:loading="runOnceLoadingId === item.id"
@click="handleRunOnce(item)"
>
{{ $t('crawlTask.runOnceBtn') }}
</el-button>
</div>
</div>
</div>
<div class="pagination-container">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:current-page.sync="currentPage"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<el-dialog :title="$t('crawlTask.addKeyword')" :visible.sync="addDialogVisible" width="460px" @closed="resetAddForm">
<el-form label-width="100px" size="small">
<el-form-item :label="$t('crawlTask.keyword')">
<el-input v-model="addForm.field" :placeholder="$t('crawlTask.keywordPlaceholder')" clearable />
</el-form-item>
<el-form-item :label="$t('crawlTask.runOnce')">
<el-switch v-model="addForm.runNow" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button size="small" @click="addDialogVisible = false">{{ $t('crawlTask.cancel') }}</el-button>
<el-button type="primary" size="small" :loading="addLoading" @click="submitAddKeyword">{{ $t('crawlTask.confirm') }}</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
export default {
data() {
return {
loading: false,
searchText: '',
filterStatus: '',
currentPage: 1,
pageSize: 10,
total: 0,
list: [],
addDialogVisible: false,
addLoading: false,
runOnceLoadingId: null,
addForm: { field: '', runNow: false }
};
},
created() {
this.fetchList();
},
methods: {
/** 停止态用灰条,避免 exception 大红;运行中未满用蓝,满格用绿 */
progressStrokeColor(item) {
if (item.state !== 'running') return '#c0c4cc';
if (item.progress >= 100) return '#67c23a';
return '#409eff';
},
// 数据标准化逻辑
normalizeItem(item) {
const total = Number(item.total_pages || 0);
const current = Number(item.last_page || 0);
let progress = total > 0 ? (current / total) * 100 : 0;
return {
id: item.expert_fetch_id || item.id,
task_name: item.field || '-',
source: (item.source || 'PubMed'),
expert_count: item.expert_count || 0,
total_pages: total,
last_page: current,
progress: Number(progress.toFixed(1)),
state: Number(item.state) === 0 ? 'running' : 'paused',
create_time: item.ctime_text || '-'
};
},
async fetchList() {
this.loading = true;
try {
const params = {
keyword: this.searchText,
pageIndex: this.currentPage,
pageSize: this.pageSize,
state: this.filterStatus !== '' ? this.filterStatus : undefined
};
const res = await this.$api.post('api/expert_manage/getFetchList', params);
if (res.code === 0) {
this.list = res.data.list.map(this.normalizeItem);
this.total = res.data.total;
}
} finally {
this.loading = false;
}
},
async handleToggleTask(item) {
const isNowRunning = item.state === 'running';
const newState = isNowRunning ? '0' : '1';
try {
const res = await this.$api.post('api/expert_manage/editFetchField', {
expert_fetch_id: item.id,
state: newState
});
if (res.code === 0) {
this.$message.success(isNowRunning ? this.$t('crawlTask.taskRunningMsg') : this.$t('crawlTask.taskStoppedMsg'));
} else {
item.state = isNowRunning ? 'paused' : 'running';
this.$message.error(res.msg || this.$t('crawlTask.operationFail'));
}
} catch (e) {
item.state = isNowRunning ? 'paused' : 'running';
}
},
async handleRunOnce(item) {
this.runOnceLoadingId = item.id;
try {
const res = await this.$api.post('/api/expert_finder/fetchOneField', { field: item.task_name });
if (res.code === 0) {
this.$message.success(this.$t('crawlTask.runOnceQueued'));
this.fetchList();
}
} finally {
this.runOnceLoadingId = null;
}
},
// 其余分页/弹窗逻辑
handleFilter() { this.currentPage = 1; this.fetchList(); },
handleSearchClick() { this.handleFilter(); },
handleSizeChange(val) { this.pageSize = val; this.fetchList(); },
handlePageChange(val) { this.currentPage = val; this.fetchList(); },
openAddDialog() { this.addDialogVisible = true; },
resetAddForm() { this.addForm = { field: '', runNow: false }; this.addLoading = false; },
async submitAddKeyword() {
if (!this.addForm.field.trim()) return this.$message.warning(this.$t('crawlTask.enterKeyword'));
this.addLoading = true;
try {
const res = await this.$api.post('api/expert_manage/addFetchField', { field: this.addForm.field });
if (res.code === 0) {
this.$message.success(this.$t('crawlTask.addKeywordSuccess'));
if (this.addForm.runNow) {
await this.$api.post('api/expert_finder/fetchOneField', { field: this.addForm.field });
}
this.addDialogVisible = false;
this.fetchList();
}
} finally { this.addLoading = false; }
}
}
};
</script>
<style scoped>
.monitor-container {
padding: 20px;
background: #f5f7fa;
min-height: calc(100vh - 100px);
}
/* 控制面板 */
.control-panel {
background: #fff;
padding: 12px 20px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
}
.panel-left { display: flex; gap: 15px; }
.search-box { width: 220px; }
/* 任务行卡片设计 */
.task-row {
background: #fff;
margin-bottom: 12px;
padding: 4px 15px;
border-radius: 10px;
display: flex;
align-items: center;
transition: all 0.3s;
border-left: 5px solid #dcdfe6;
}
.task-row:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.task-row.is-active { border-left-color: #409eff; }
.task-row.is-paused { background: #fbfbfc; border-left-color: #909399; opacity: 0.9; }
/* 1. 基础信息列 */
.col-base { flex: 1.5; display: flex; align-items: flex-start; gap: 15px; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #909399; margin-top: 6px; }
.is-active .status-dot { background: #13ce66; box-shadow: 0 0 8px #13ce66; animation: pulse 2s infinite; }
.task-id { font-family: monospace; color: #909399; font-size: 12px; margin-right: 8px; }
.task-name { font-size: 14px; font-weight: bold; color: #303133; }
.task-meta { margin-top: 6px; display: flex; align-items: center; gap: 12px; font-size: 12px; color: #909399; }
/* 2. 指标展示列 */
.col-metrics { flex: 1.2; display: flex; align-items: center; justify-content: space-around; }
.metric-block { text-align: center; }
.m-label { font-size: 12px; color: #909399; display: block; margin-bottom: 4px; }
.m-value { font-size: 18px; font-weight: 600; color: #606266; }
.m-value small { font-weight: normal; font-size: 12px; color: #888; }
.expert-count { color: #006699; font-size: 16px; } /* 专家数高亮 */
.divider { width: 1px; height: 35px; background: #ebeef5; }
/* 3. 进度条列 */
.col-progress { flex: 1.2; padding: 0 30px; }
.prog-text { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 6px; color: #606266; }
.percent { font-weight: bold; color: #409eff; }
/* 4. 操作列 */
.col-action { flex: 1.1; display: flex; align-items: center; justify-content: flex-end; gap: 14px; }
.switch-wrapper {
text-align: center;
display: flex;
flex-direction: column;
gap: 4px;
margin-right: 6px;
transform: translateX(-6px);
}
.state-text { font-size: 11px; font-weight: bold; }
.state-text.running { color: #13ce66; }
.state-text.paused { color: #909399; }
/* 分页 */
.pagination-container { margin-top: 25px; text-align: right; }
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
</style>

View File

@@ -240,7 +240,7 @@ If you're still having trouble with Chrome, you could use other browsers to comp
<el-dialog :visible.sync="questionVisible" width="1000px" :close-on-click-modal="false">
<!-- 审稿人表单修改 -->
<!-- 不显示给编辑的话 -->
<common-review-article
<common-review-article v-if="questionVisible"
:form="questionform1"
type="questionform"
:txt_mess="txt_mess"

View File

@@ -771,6 +771,7 @@
</template>
<script>
import bus from '../common/bus'
export default {
data() {
return {
@@ -1188,6 +1189,43 @@ export default {
this.getjour();
},
methods: {
readPromotionFactoryJumpJournalId() {
try {
const raw = sessionStorage.getItem('promotionFactoryJump');
if (!raw) return '';
const obj = JSON.parse(raw || '{}');
if (!obj || obj.from !== 'promotionFactory') return '';
if (obj.targetPath && String(obj.targetPath) !== String(this.$route.path)) return '';
const id = obj.journal_id != null ? String(obj.journal_id) : '';
sessionStorage.removeItem('promotionFactoryJump');
return id;
} catch (e) {
try {
sessionStorage.removeItem('promotionFactoryJump');
} catch (e2) {}
return '';
}
},
forceClearJumpQuery() {
const q = (this.$route && this.$route.query) || {};
const has = q && (q._ap_from || q.journal_id || q.journalId || q.journalIdd);
if (!has) return;
const oldFullPath = this.$route && this.$route.fullPath ? this.$route.fullPath : '';
const nextQuery = Object.assign({}, q);
delete nextQuery._ap_from;
delete nextQuery.journal_id;
delete nextQuery.journalId;
delete nextQuery.journalIdd;
// 关闭“中间态 fullPath”对应的标签页避免残留在顶部 Tags
if (oldFullPath) {
bus.$emit('close_tag_by_path', { path: oldFullPath, silent: true });
}
this.$router.replace({ path: this.$route.path, query: nextQuery }).catch(() => {});
try {
const cleanUrl = window.location.origin + this.$route.path;
window.history.replaceState({}, '', cleanUrl);
} catch (e) {}
},
unplIcon() {
this.$refs['upIconIMg'].$refs['upload-inner'].handleClick();
},
@@ -1314,9 +1352,28 @@ export default {
if (res.code == 0) {
this.df_jour = res.data.journals;
this.add_jour = res.data.journals;
this.query.journal_id = this.df_jour[0].journal_id;
this.query.journal_title = this.df_jour[0].title;
const fromQueryId = this.$route && this.$route.query ? this.$route.query.journal_id : '';
const fromSessionId = this.readPromotionFactoryJumpJournalId();
const pickedId = fromQueryId || fromSessionId;
const matched = pickedId ? this.df_jour.find((j) => String(j.journal_id) === String(pickedId)) : null;
const first = this.df_jour[0];
const picked = matched || first;
this.query.journal_id = picked ? picked.journal_id : 0;
this.query.journal_title = picked ? picked.title : '';
this.getgroup();
// 仅首次接收参数:应用后立刻清掉 URL避免刷新仍然固定某一本期刊
if (fromQueryId) {
const nextQuery = Object.assign({}, (this.$route && this.$route.query) || {});
delete nextQuery.journal_id;
delete nextQuery.journalId;
delete nextQuery._ap_from;
this.$router.replace({ path: this.$route.path, query: nextQuery }).catch(() => {});
// 双保险:路由稳定后再强制清参一次,避免地址栏残留“中间态”
setTimeout(() => {
this.forceClearJumpQuery();
}, 0);
}
} else {
this.$message.error(res.msg);
}

View File

@@ -29,6 +29,15 @@
style="width: 260px"
/>
</el-form-item>
<el-form-item label="">
<el-input
v-model="query.field"
:placeholder="$t('expertDatabase.fieldPlaceholder')"
clearable
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" :loading="loading" @click="handleSearch">
@@ -59,16 +68,42 @@
</p>
<p class="info-row" style="margin-top: 10px; font-size: 12px">
<span class="label">{{ $t('expertDatabase.fields.acquisitionTimeLabel') }}</span>
<span class="value time">{{ scope.row.ctime_text ? scope.row.ctime_text : '-' }}</span>
<span class="value time">{{ scope.row.ctime_text ? scope.row.ctime_text : $t('expertDatabase.emptyMark') }}</span>
</p>
<span class="custom-tag">{{ scope.row.state_text }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="affiliation" :label="$t('expertDatabase.columns.affiliation')" min-width="260" />
<el-table-column prop="fieldDisplay" :label="$t('expertDatabase.columns.researchAreas')" min-width="200">
<el-table-column prop="country" :label="$t('expertDatabase.columns.country')" min-width="100" />
<el-table-column prop="affiliation" :label="$t('expertDatabase.columns.affiliation')" min-width="460" />
<el-table-column :label="$t('expertDatabase.columns.unsubscribeStatus')" width="180" align="center">
<template slot-scope="scope">
<div class="unsubscribe-cell">
<el-tag
size="mini"
:type="getUnsubscribeValue(scope.row) === 1 ? 'warning' : 'success'"
effect="plain"
class="unsubscribe-tag"
>
{{
getUnsubscribeValue(scope.row) === 1
? $t('expertDatabase.unsubscribeUnsubscribed')
: $t('expertDatabase.unsubscribeNormal')
}}
</el-tag>
<el-switch
class="unsubscribe-switch"
:value="getUnsubscribeValue(scope.row) === 0"
active-color="#13ce66"
inactive-color="#dcdfe6"
:disabled="switchingExpertId === getExpertId(scope.row)"
@change="(val) => handleUnsubscribeSwitch(scope.row, val)"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="fieldDisplay" :label="$t('expertDatabase.columns.researchAreas')" min-width="260">
<template slot-scope="scope">
<div v-for="(field, index) in scope.row.fields" :key="index">
<span>
@@ -76,10 +111,43 @@
{{ field.field }}
</span>
</div>
<el-button
v-if="scope.row.fields && scope.row.fields.length"
type="text"
size="small"
class="view-all-btn"
@click.stop="openFieldDetail(scope.row)"
>
{{ $t('expertDatabase.viewAllInfo') }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
:title="$t('expertDatabase.detailDialogTitle')"
:visible.sync="fieldDetailVisible"
width="1200px"
append-to-body
destroy-on-close
class="field-detail-dialog"
>
<p v-if="fieldDetailExpert" class="field-detail-name">
<span class="label">{{ $t('expertDatabase.fields.nameLabel') }}</span>
<span class="value bold">{{ fieldDetailExpert.name }}</span>
</p>
<el-table v-if="fieldDetailRows.length" :data="fieldDetailRows" border stripe size="small" max-height="420">
<el-table-column type="index" :label="$t('expertDatabase.table.no')" width="56" align="center" />
<el-table-column prop="field" :label="$t('expertDatabase.detailColField')" min-width="140" />
<el-table-column prop="paper_title" :label="$t('expertDatabase.detailColPaper')" min-width="220" />
<el-table-column prop="paper_journal" :label="$t('expertDatabase.detailColJournal')" min-width="140" />
</el-table>
<p v-else class="field-detail-empty">{{ $t('expertDatabase.noFieldDetail') }}</p>
<span slot="footer" class="dialog-footer">
<el-button type="primary" @click="fieldDetailVisible = false">{{ $t('expertDatabase.detailClose') }}</el-button>
</span>
</el-dialog>
<div class="pagination">
<el-pagination
background
@@ -107,6 +175,7 @@ export default {
query: {
major_id: null,
keyword: '',
field: '',
pageIndex: 1,
pageSize: 10
},
@@ -114,7 +183,11 @@ export default {
list: [],
total: 0,
loading: false,
exportLoading: false
exportLoading: false,
switchingExpertId: '',
fieldDetailVisible: false,
fieldDetailExpert: null,
fieldDetailRows: []
};
},
created() {
@@ -167,6 +240,7 @@ export default {
const params = {
major_id: this.query.major_id,
keyword: this.query.keyword,
field: this.query.field,
pageIndex: this.query.pageIndex,
pageSize: this.query.pageSize
};
@@ -176,8 +250,12 @@ export default {
this.list = rawList.map((item) => {
const fieldArray = item.fields || [];
const fieldNames = fieldArray.map((f) => f.field).join(', ');
const unsubscribed = this.normalizeUnsubscribeValue(item);
return {
...item,
// 以 unsubscribed 作为页面主判断字段
unsubscribed,
unsubscribe: unsubscribed,
fieldDisplay: fieldNames
};
});
@@ -193,6 +271,57 @@ export default {
this.loading = false;
}
},
getExpertId(row) {
if (!row) return '';
return String(row.expert_id || row.id || row.user_id || row.uid || '').trim();
},
normalizeUnsubscribeValue(row) {
const raw =
row && row.unsubscribed != null
? row.unsubscribed
: row && row.unsubscribe != null
? row.unsubscribe
: row && row.is_unsubscribe != null
? row.is_unsubscribe
: 0;
return Number(raw) === 1 ? 1 : 0;
},
getUnsubscribeValue(row) {
return this.normalizeUnsubscribeValue(row);
},
async handleUnsubscribeSwitch(row, checked) {
const expertId = this.getExpertId(row);
if (!expertId) {
this.$message.warning(this.$t('expertDatabase.unsubscribeMissingId'));
return;
}
const oldValue = this.getUnsubscribeValue(row);
// switch: ON(checked)=正常(0), OFF=退订(1)
const nextValue = checked ? 0 : 1;
if (oldValue === nextValue) return;
row.unsubscribed = nextValue;
row.unsubscribe = nextValue;
this.switchingExpertId = expertId;
try {
const apiUrl = nextValue === 1 ? 'api/expert_manage/unsubscribe' : 'api/expert_manage/resubscribe';
const res = await this.$api.post(apiUrl, {
expert_ids: String(expertId)
});
if (res && Number(res.code) === 0) {
this.$message.success(this.$t('expertDatabase.unsubscribeUpdateSuccess'));
} else {
row.unsubscribed = oldValue;
row.unsubscribe = oldValue;
this.$message.error((res && res.msg) || this.$t('expertDatabase.unsubscribeUpdateFailed'));
}
} catch (e) {
row.unsubscribed = oldValue;
row.unsubscribe = oldValue;
this.$message.error(this.$t('expertDatabase.unsubscribeUpdateFailed'));
} finally {
this.switchingExpertId = '';
}
},
handleSearch() {
this.query.pageIndex = 1;
this.fetchList();
@@ -202,6 +331,7 @@ export default {
this.query = {
major_id: null,
keyword: '',
field: '',
pageIndex: 1,
pageSize: 10
};
@@ -216,8 +346,19 @@ export default {
this.query.pageIndex = page;
this.fetchList();
},
openFieldDetail(row) {
const empty = this.$t('expertDatabase.detailCellEmpty');
this.fieldDetailExpert = row || null;
const fields = (row && row.fields) || [];
this.fieldDetailRows = fields.map((f) => ({
field: (f && f.field) || empty,
paper_title: (f && (f.paper_title || f.title)) || empty,
paper_journal: (f && (f.paper_journal || f.journal)) || empty
}));
this.fieldDetailVisible = true;
},
async handleExport() {
if (!this.query.major_id && !this.query.keyword) {
if (!this.query.major_id && !this.query.keyword && !this.query.field) {
this.$message.warning(this.$t('expertDatabase.exportWarn'));
return;
}
@@ -225,7 +366,8 @@ export default {
try {
const params = {
major_id: this.query.major_id,
keyword: this.query.keyword
keyword: this.query.keyword,
field: this.query.field
};
const res = await this.$api.post('api/expert_manage/exportExcel', params);
if (res && res.code === 0 && res.data && res.data.file_url) {
@@ -259,6 +401,11 @@ export default {
margin: 0 20px;
margin-left: 0;
}
.form-line-break {
flex-basis: 100%;
width: 100%;
height: 0;
}
.actions {
white-space: nowrap;
}
@@ -269,11 +416,11 @@ export default {
margin-top: 15px;
text-align: right;
}
/deep/ .dark-table-header th {
::v-deep .dark-table-header th {
background-color: #f5f7fa;
font-weight: 600;
}
/deep/ .el-form-item--mini.el-form-item,
::v-deep .el-form-item--mini.el-form-item,
.el-form-item--small.el-form-item {
margin-bottom: 0;
}
@@ -312,5 +459,37 @@ export default {
.value.time {
color: #888;
}
.view-all-btn {
margin-top: 8px;
padding: 0;
}
.field-detail-name {
margin: 0 0 12px;
font-size: 14px;
}
.field-detail-empty {
margin: 16px 0;
color: #909399;
font-size: 13px;
}
.unsubscribe-cell {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 4px;
}
.unsubscribe-tag {
min-width: 56px;
height: 22px;
line-height: 20px;
text-align: center;
border-radius: 12px;
font-size: 12px;
}
.unsubscribe-switch {
flex-shrink: 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -67,8 +67,8 @@
<el-form-item :label="$t('mailboxConfig.password')" prop="password">
<el-input
v-model="form.password"
type="password"
show-password
type="text"
clearable
autocomplete="new-password"
></el-input>
@@ -256,6 +256,7 @@
today_sent: item.today_sent,
state: item.state,
remaining_today: item.remaining_today,
smtp_password: item.smtp_password,
};
}.bind(this));
this.tableData = list;
@@ -289,12 +290,14 @@
this.$nextTick(function() { if (this.$refs.formRef) this.$refs.formRef.clearValidate(); }.bind(this));
},
handleEdit(row) {
console.log("🚀 ~ handleEdit ~ row:", row);
this.dialogTitle = this.$t('mailboxConfig.dialogEdit');
this.form = {
id: row.id,
journal_id: row.journal_id,
account: row.account || row.smtp_user || '',
password: '',
password: row.smtp_password || '',
smtp_from_name: row.smtp_from_name || '',
smtp_host: row.smtp_host || '',
smtp_port: row.smtp_port != null && row.smtp_port !== '' ? String(row.smtp_port) : '',

View File

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

View File

@@ -98,9 +98,12 @@
<div class="subject-label" style="margin-bottom: 10px;">{{ $t('mailboxMouldDetail.emailBody') }}:</div>
<TmrEmailEditor
v-model="form.body"
:journalList="journalList"
:journalId="form.journalId"
:language="form.lang"
placeholder=""
/>
<!-- <CkeditorMail v-model="form.body" /> -->
<CkeditorMail v-model="form.body" />
</div>
</el-card>
</section>
@@ -172,8 +175,9 @@ export default {
.then(res => {
const list = res || [];
const mapped = (Array.isArray(list) ? list : []).map(j => ({
...j,
journal_id: j.journal_id || j.id,
title: j.title || j.name || ''
title: j.title || j.name || '',
}));
this.journalList = mapped;
if (fromRouteJournalId) {

View File

@@ -107,7 +107,7 @@
:source-content.sync="sourceContent"
:source-rows="16"
:source-placeholder="$t('mailboxSend.sourcePlaceholder')"
:show-select-template-button="true"
:show-select-template-button="false"
@onSelectTemplate="showTemplateDialog = true"
/>
</div>
@@ -672,24 +672,23 @@ import TemplateSelectorDialog from '@/components/page/components/email/TemplateS
this.$message.warning(this.$t('mailboxSend.needAccount'));
return;
}
if (!this.selectedTemplateId || !this.selectedStyleId) {
this.$message.warning(this.$t('mailboxSend.selectTemplateStyleFirst'));
return;
}
// if (!this.selectedTemplateId || !this.selectedStyleId) {
// this.$message.warning(this.$t('mailboxSend.selectTemplateStyleFirst'));
// return;
// }
this.sendLoading = true;
const params = {
journal_id: String(journalId),
template_id: String(this.selectedTemplateId),
to_email: toList.join(','),
style_id: String(this.selectedStyleId),
expert_id: expert_id_list.join(',') || '1',
subject: this.queryMail.sendtitle || '',
content: this.queryMail.content || '',
j_email_id: String(jEmailId),
journal_id: String(journalId),
};
const self = this;
this.$api
.post('api/email_client/sendTemplateStyleTest', params)
.post('api/email_client/sendOne', params)
.then((res) => {
if (res && res.code === 0) {
self.$message.success(self.$t('mailboxSend.sendSuccess'));

View File

@@ -33,6 +33,7 @@
<el-table-column type="index" label="No." width="55" align="center"></el-table-column>
<!-- <el-table-column prop="article_id" label="ID" width="55" align="center"></el-table-column> -->
<el-table-column prop="title" label="Title" align="left"></el-table-column>
<el-table-column prop="accept_sn" label="SN" align="left" width="180"></el-table-column>
<el-table-column prop="abbr" label="Journal" width="80" align="center"></el-table-column>
<el-table-column label="Type" width="100" align="center">
<template slot-scope="scope">
@@ -42,11 +43,18 @@
</template>
</el-table-column>
<el-table-column :formatter="repeFormat" prop="repetition" label="Repetition" width="80" align="center"></el-table-column>
<el-table-column :formatter="repeFormat" prop="repetition" label="Repetition" width="90" align="center"></el-table-column>
<el-table-column prop="realname" label="Editor" width="100" align="center"></el-table-column>
<el-table-column :formatter="dateFormat" prop="ctime" width="100" label="Add date" align="center"></el-table-column>
<el-table-column prop="state" label="Status" :formatter="stateFormat" align="center" width="100"></el-table-column>
<el-table-column prop="country" label="Country" width="120" align="center"></el-table-column>
<el-table-column prop="fee" label="Price" width="120" align="center"></el-table-column>
<el-table-column prop="is_buy" label="Payment status" width="120" align="center">
<template slot-scope="scope">
<span v-if="scope.row.is_buy==1" style="color:green;">Paid</span>
<span v-if="scope.row.is_buy==0" style="color: red;">Unpaid</span>
</template>
</el-table-column>
<el-table-column label="" width="100" align="center">
<template slot-scope="scope">
<el-button size="mini" type="primary" plain icon="el-icon-tickets" @click="showdetail(scope.row)">detail</el-button>

View File

@@ -424,6 +424,7 @@
</template>
<script>
import bus from '../common/bus'
export default {
data() {
return {
@@ -435,8 +436,7 @@ export default {
baseUrl: this.Common.baseUrl,
mediaUrl: this.Common.mediaUrl,
query: {
// journal_id: '',
// year: 0,
journal_id: 0,
keywords: '',
page: 1,
limit: 15
@@ -472,6 +472,23 @@ export default {
this.initMajor();
},
methods: {
readPromotionFactoryJumpJournalId() {
try {
const raw = sessionStorage.getItem('promotionFactoryJump');
if (!raw) return '';
const obj = JSON.parse(raw || '{}');
if (!obj || obj.from !== 'promotionFactory') return '';
if (obj.targetPath && String(obj.targetPath) !== String(this.$route.path)) return '';
const id = obj.journal_id != null ? String(obj.journal_id) : '';
sessionStorage.removeItem('promotionFactoryJump');
return id;
} catch (e) {
try {
sessionStorage.removeItem('promotionFactoryJump');
} catch (e2) {}
return '';
}
},
goDetail(row) {
console.log('row at line 460:', row);
this.$router.push({
@@ -518,8 +535,31 @@ export default {
.then((res) => {
if (res.code == 0) {
this.jourList = res.data.journals;
this.query.journal_id = this.jourList[0].journal_id;
const fromQueryId = this.$route && this.$route.query ? this.$route.query.journal_id : '';
const fromSessionId = this.readPromotionFactoryJumpJournalId();
const pickedId = fromQueryId || fromSessionId;
const matched = pickedId ? this.jourList.find((j) => String(j.journal_id) === String(pickedId)) : null;
this.query.journal_id = matched ? matched.journal_id : this.jourList[0].journal_id;
this.getData();
// 仅首次接收参数:应用后立刻清掉 URL避免刷新仍然固定某一本期刊
if (fromQueryId) {
const oldFullPath = this.$route && this.$route.fullPath ? this.$route.fullPath : '';
const nextQuery = Object.assign({}, (this.$route && this.$route.query) || {});
delete nextQuery.journal_id;
delete nextQuery.journalId;
delete nextQuery._ap_from;
if (oldFullPath) {
bus.$emit('close_tag_by_path', { path: oldFullPath, silent: true });
}
this.$router.replace({ query: nextQuery }).catch(() => {});
// 兜底:某些场景 router.replace 不更新地址栏,这里强制清掉 query
try {
const u = new URL(window.location.href);
['_ap_from', 'journal_id', 'journalId', 'journalIdd'].forEach((k) => u.searchParams.delete(k));
window.history.replaceState({}, '', u.toString());
} catch (e) {}
}
} else {
this.$message.error(res.msg);
}

View File

@@ -478,6 +478,7 @@ const currentYear = new Date().getFullYear();
var ReviewTime = `(${currentYear - 2}${currentYear})`;
import commonReviewer from '../page/components/reviewerList/add.vue';
import commonMajorTableList from '../page/components/major/tableList.vue';
import bus from '../common/bus'
export default {
components: {
commonReviewer,
@@ -657,11 +658,27 @@ export default {
},
created() {
this.getDate();
this.getContent();
this.loadFields();
// this.initMajor()
},
methods: {
readPromotionFactoryJumpJournalId() {
try {
const raw = sessionStorage.getItem('promotionFactoryJump');
if (!raw) return '';
const obj = JSON.parse(raw || '{}');
if (!obj || obj.from !== 'promotionFactory') return '';
if (obj.targetPath && String(obj.targetPath) !== String(this.$route.path)) return '';
const id = obj.journal_id != null ? String(obj.journal_id) : '';
sessionStorage.removeItem('promotionFactoryJump');
return id;
} catch (e) {
try {
sessionStorage.removeItem('promotionFactoryJump');
} catch (e2) {}
return '';
}
},
getProps() {
return {
value: 'value',
@@ -716,6 +733,32 @@ export default {
.then((res) => {
if (res.code == 0) {
this.df_jour = res.data.journals;
const fromQueryId = (this.$route && this.$route.query && (this.$route.query.journalId || this.$route.query.journal_id)) || '';
const fromSessionId = this.readPromotionFactoryJumpJournalId();
const pickedId = fromQueryId || fromSessionId;
if (pickedId) {
this.query.journalId = Number(pickedId) || 0;
}
this.getContent();
// 仅首次接收参数:应用后立刻清掉 URL避免刷新仍然固定某一本期刊
if (fromQueryId) {
const oldFullPath = this.$route && this.$route.fullPath ? this.$route.fullPath : '';
const nextQuery = Object.assign({}, (this.$route && this.$route.query) || {});
delete nextQuery.journal_id;
delete nextQuery.journalId;
delete nextQuery._ap_from;
if (oldFullPath) {
bus.$emit('close_tag_by_path', { path: oldFullPath, silent: true });
}
this.$router.replace({ query: nextQuery }).catch(() => {});
// 兜底:某些场景 router.replace 不更新地址栏,这里强制清掉 query
try {
const u = new URL(window.location.href);
['_ap_from', 'journal_id', 'journalId', 'journalIdd'].forEach((k) => u.searchParams.delete(k));
window.history.replaceState({}, '', u.toString());
} catch (e) {}
}
} else {
this.$message.error(res.msg);
}

View File

@@ -0,0 +1,545 @@
<template>
<div class="keywords-container">
<transition name="el-zoom-in-top" appear>
<div class="page-header">
<div class="header-left">
<h2 class="page-title">{{ $t('crawlerKeywords.pageTitle') }}</h2>
<p class="page-desc">
<i class="el-icon-info"></i> {{ $t('crawlerKeywords.pageDesc') }}
</p>
</div>
<div class="header-actions">
<!-- <el-button
type="primary"
icon="el-icon-video-play"
@click="handleStartCrawl"
:loading="crawlLoading"
class="glow-button"
round
>
{{ $t('crawlerKeywords.startCrawl') }}
</el-button> -->
</div>
</div>
</transition>
<div class="add-section shadow-hover">
<el-input
v-model="newKeyword"
:placeholder="$t('crawlerKeywords.inputPlaceholder')"
class="add-input-custom"
prefix-icon="el-icon-collection-tag"
clearable
@keyup.enter.native="handleAdd"
/>
<el-button
type="primary"
icon="el-icon-plus"
@click="handleAdd"
:loading="addLoading"
class="add-btn"
>
{{ $t('crawlerKeywords.addKeyword') }}
</el-button>
</div>
<el-card shadow="never" class="table-card animated-card">
<div class="table-toolbar">
<el-input
v-model="searchText"
:placeholder="$t('crawlerKeywords.searchPlaceholder')"
prefix-icon="el-icon-search"
clearable
class="search-input"
@input="handleSearch"
/>
<el-button style="margin-left: 20px;"
type="primary"
icon="el-icon-search"
@click="handleSearchNow"
>
{{ $t('crawlerKeywords.searchBtn') }}
</el-button>
</div>
<el-table
:data="list"
v-loading="loading"
border
stripe
highlight-current-row
header-row-class-name="custom-header"
style="width: 100%"
class="modern-table"
>
<el-table-column prop="keyword" :label="$t('crawlerKeywords.colKeyword')" min-width="180">
<template slot-scope="scope">
<transition name="fade-slide" mode="out-in">
<span :key="'t'+scope.row.id" v-if="editingId !== scope.row.id" class="keyword-text">
{{ scope.row.keyword }}
</span>
<el-input
:key="'e'+scope.row.id"
v-else
v-model="editForm.keyword"
size="small"
v-focus
class="edit-input"
/>
</transition>
</template>
</el-table-column>
<el-table-column prop="category" :label="$t('crawlerKeywords.colCategory')" width="150">
<template slot-scope="scope">
<transition name="fade-slide" mode="out-in">
<div :key="editingId === scope.row.id">
<el-tag
v-if="editingId !== scope.row.id"
:type="scope.row.category ? 'success' : 'info'"
size="small"
effect="light"
>
{{ scope.row.category || 'Default' }}
</el-tag>
<el-input v-else v-model="editForm.category" size="small" />
</div>
</transition>
</template>
</el-table-column>
<el-table-column prop="state" :label="$t('crawlerKeywords.colStatus')" width="140" align="center">
<template slot-scope="scope">
<div class="status-container">
<span :class="['status-dot', 'pulse', 'status-' + scope.row.state]"></span>
<span :class="['status-label', 'text-' + scope.row.state]">
{{ getStateText(scope.row.state) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="expert_count" :label="$t('crawlerKeywords.colExpertCount')" width="120" align="center">
<template slot-scope="scope">
<span class="count-badge">{{ scope.row.expert_count }}</span>
</template>
</el-table-column>
<el-table-column prop="last_crawl_time" :label="$t('crawlerKeywords.colLastCrawl')" width="180" />
<el-table-column :label="$t('crawlerKeywords.colAction')" width="160" align="center" fixed="right">
<template slot-scope="scope">
<div class="action-wrapper">
<template v-if="editingId === scope.row.id">
<el-button type="text" icon="el-icon-circle-check" class="save-btn" @click="handleSaveEdit(scope.row)">
{{ $t('crawlerKeywords.save') }}
</el-button>
<el-button type="text" icon="el-icon-circle-close" class="cancel-btn" @click="editingId = null">
{{ $t('crawlerKeywords.cancel') }}
</el-button>
</template>
<template v-else>
<el-tooltip :content="$t('crawlerKeywords.refresh')" placement="top">
<el-button
type="text"
icon="el-icon-refresh"
:class="['refresh-icon', { 'is-spinning': scope.row._refreshing }]"
@click="handleRefresh(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="$t('crawlerKeywords.edit')" placement="top">
<el-button type="text" icon="el-icon-edit-outline" @click="handleEdit(scope.row)" />
</el-tooltip>
<el-tooltip :content="$t('crawlerKeywords.delete')" placement="top">
<el-button type="text" icon="el-icon-delete" class="del-btn" @click="handleDelete(scope.row)" />
</el-tooltip>
</template>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:current-page="query.pageIndex"
:page-size="query.pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
</div>
</template>
<script>
import Common from '@/components/common/common';
const API = {
list: 'api/expert_manage/getKeywordList',
add: 'api/expert_manage/addKeyword',
update: 'api/expert_manage/updateKeyword',
delete: 'api/expert_manage/deleteKeyword',
refresh: 'api/expert_manage/refreshKeyword',
startCrawl: 'api/expert_manage/startCrawl',
exportData: 'api/expert_manage/exportKeywords'
};
export default {
name: 'scholarCrawlersKeywords',
directives: {
focus: {
inserted: (el) => el.querySelector('input').focus()
}
},
data() {
return {
mediaUrl: Common.mediaUrl,
newKeyword: '',
searchText: '',
loading: false,
addLoading: false,
exportLoading: false,
crawlLoading: false,
editingId: null,
editForm: { keyword: '', category: '' },
query: { pageIndex: 1, pageSize: 10 },
list: [],
total: 0,
searchTimer: null
};
},
created() {
this.fetchList();
},
methods: {
getStateText(state) {
const map = {
running: this.$t('crawlerKeywords.stateRunning'),
paused: this.$t('crawlerKeywords.statePaused'),
error: this.$t('crawlerKeywords.stateError'),
done: this.$t('crawlerKeywords.stateDone')
};
return map[state] || state || '-';
},
async fetchList() {
this.loading = true;
try {
const params = {
keyword: this.searchText || '',
pageIndex: this.query.pageIndex,
pageSize: this.query.pageSize
};
const res = await this.$api.post(API.list, params);
if (res && res.code === 0 && res.data) {
this.list = (res.data.list || []).map(item => ({
...item,
_refreshing: false
}));
this.total = res.data.total || 0;
}
} catch (e) {
console.error(e);
} finally {
setTimeout(() => { this.loading = false; }, 300); // 增加微小延迟让加载动画更平滑
}
},
handleSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.query.pageIndex = 1;
this.fetchList();
}, 400);
},
handleSearchNow() {
if (this.searchTimer) {
clearTimeout(this.searchTimer);
this.searchTimer = null;
}
this.query.pageIndex = 1;
this.fetchList();
},
async handleAdd() {
const kw = (this.newKeyword || '').trim();
if (!kw) {
this.$message.warning(this.$t('crawlerKeywords.emptyKeywordWarn'));
return;
}
this.addLoading = true;
try {
const res = await this.$api.post(API.add, { keyword: kw });
if (res && res.code === 0) {
this.$notify({ title: 'Success', message: this.$t('crawlerKeywords.addSuccess'), type: 'success' });
this.newKeyword = '';
this.query.pageIndex = 1;
this.fetchList();
}
} finally {
this.addLoading = false;
}
},
handleEdit(row) {
this.editingId = row.id;
this.editForm = { keyword: row.keyword, category: row.category || '' };
},
async handleSaveEdit(row) {
if (!(this.editForm.keyword || '').trim()) {
this.$message.warning(this.$t('crawlerKeywords.emptyKeywordWarn'));
return;
}
try {
const res = await this.$api.post(API.update, {
id: String(row.id),
keyword: this.editForm.keyword.trim(),
category: this.editForm.category || ''
});
if (res && res.code === 0) {
this.$message.success(this.$t('crawlerKeywords.updateSuccess'));
this.editingId = null;
this.fetchList();
}
} catch (e) {
this.$message.error(this.$t('crawlerKeywords.updateFail'));
}
},
handleDelete(row) {
this.$confirm(`确定要删除关键词 "${row.keyword}" 吗?`, '安全警告', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
center: true,
roundButton: true
}).then(async () => {
const res = await this.$api.post(API.delete, { id: String(row.id) });
if (res && res.code === 0) {
this.$notify({ title: 'Deleted', type: 'success', duration: 2000 });
this.fetchList();
}
}).catch(() => {});
},
async handleRefresh(row) {
row._refreshing = true;
try {
const res = await this.$api.post(API.refresh, { id: String(row.id) });
if (res && res.code === 0) {
this.$message.success(this.$t('crawlerKeywords.refreshSuccess'));
this.fetchList();
}
} finally {
row._refreshing = false;
}
},
async handleStartCrawl() {
this.crawlLoading = true;
try {
const res = await this.$api.post(API.startCrawl, {});
if (res && res.code === 0) {
this.$message({ message: this.$t('crawlerKeywords.crawlStarted'), type: 'success', showClose: true });
this.fetchList();
}
} finally {
this.crawlLoading = false;
}
},
async handleExport() {
this.exportLoading = true;
try {
const res = await this.$api.post(API.exportData, {});
if (res && res.code === 0 && res.data.file_url) {
window.open(this.mediaUrl + res.data.file_url, '_blank');
}
} finally {
this.exportLoading = false;
}
},
handleSizeChange(size) {
this.query.pageSize = size;
this.fetchList();
},
handlePageChange(page) {
this.query.pageIndex = page;
this.fetchList();
}
}
};
</script>
<style scoped>
/* 容器及整体布局 */
.keywords-container {
padding: 10px;
margin: 0 auto;
background-color: #f8fafc;
/* min-height: 100vh; */
}
/* 头部样式 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
margin: 0 0 6px;
font-size: 24px;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.5px;
}
.page-desc {
margin: 0;
font-size: 14px;
color: #64748b;
}
/* 新增区域:卡片化 */
.add-section {
display: flex;
gap: 16px;
margin-bottom: 24px;
background: #ffffff;
border-radius: 12px;
padding: 20px;
border: 1px solid #e2e8f0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.shadow-hover:hover {
box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.05);
transform: translateY(-2px);
}
.add-input-custom ::v-deep .el-input__inner {
border-radius: 8px;
background-color: #f1f5f9;
border-color: transparent;
}
.add-input-custom ::v-deep .el-input__inner:focus {
background-color: #fff;
border-color: #409eff;
}
/* 按钮发光 */
.glow-button {
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
.glow-button:hover {
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.4);
}
/* 表格卡片 */
.table-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 10px;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.search-input ::v-deep .el-input__inner {
border-radius: 20px;
}
/* 状态标签与脉冲 */
.status-container {
display: flex;
align-items: center;
justify-content: center;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
.pulse {
animation: shadow-pulse 2s infinite;
}
@keyframes shadow-pulse {
0% { box-shadow: 0 0 0 0px rgba(103, 194, 58, 0.4); }
70% { box-shadow: 0 0 0 8px rgba(103, 194, 58, 0); }
100% { box-shadow: 0 0 0 0px rgba(103, 194, 58, 0); }
}
.status-running { background: #67C23A; }
.status-paused { background: #E6A23C; }
.status-error { background: #F56C6C; animation-name: shadow-pulse-error; }
.status-done { background: #94a3b8; }
@keyframes shadow-pulse-error {
0% { box-shadow: 0 0 0 0px rgba(245, 108, 108, 0.4); }
70% { box-shadow: 0 0 0 8px rgba(245, 108, 108, 0); }
100% { box-shadow: 0 0 0 0px rgba(245, 108, 108, 0); }
}
.text-running { color: #67C23A; font-weight: 600; }
.text-error { color: #F56C6C; font-weight: 600; }
/* 表格定制 */
::v-deep .custom-header th {
/* background-color: #f8fafc !important; */
color: #475569;
font-weight: 600;
/* height: 50px; */
}
.count-badge {
background: #eff6ff;
color: #2563eb;
padding: 2px 8px;
border-radius: 12px;
font-weight: 700;
font-size: 13px;
}
/* 操作按钮 */
.action-wrapper .el-button {
font-size: 18px;
padding: 0 6px;
transition: all 0.2s;
}
.action-wrapper .el-button:hover {
transform: scale(1.2);
}
.del-btn { color: #ef4444 !important; }
.save-btn { color: #10b981 !important; font-size: 14px !important; }
.cancel-btn { color: #64748b !important; font-size: 14px !important; }
/* 刷新旋转 */
.is-spinning {
animation: rotate 1s linear infinite;
color: #409eff;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 过渡动画 */
.fade-slide-enter-active, .fade-slide-leave-active {
transition: all 0.25s ease;
}
.fade-slide-enter {
opacity: 0;
transform: translateY(5px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-5px);
}
.pagination-wrapper {
margin-top: 24px;
display: flex;
justify-content: flex-end;
}
::v-deep .el-card__body{
padding: 10px !important;
}
</style>

View File

@@ -187,6 +187,7 @@
<script>
import commonCv from '../common/cv.vue';
import bus from '../common/bus';
export default {
components: {
commonCv
@@ -362,6 +363,7 @@ export default {
for (var i = 0; i < this.tableData.length; i++) {
this.getScoreData(i, this.tableData[i].score);
}
bus.$emit('apply-badge-refresh');
} else {
this.$message.error(res.msg);
}

View File

@@ -248,6 +248,7 @@
</template>
<script>
import bus from '../common/bus'
export default {
name: 'youthList',
data() {
@@ -326,6 +327,21 @@
this.yearData()
},
methods: {
readPromotionFactoryJumpJournalId() {
try {
const raw = sessionStorage.getItem('promotionFactoryJump')
if (!raw) return ''
const obj = JSON.parse(raw || '{}')
if (!obj || obj.from !== 'promotionFactory') return ''
if (obj.targetPath && String(obj.targetPath) !== String(this.$route.path)) return ''
const id = obj.journal_id != null ? String(obj.journal_id) : ''
sessionStorage.removeItem('promotionFactoryJump')
return id
} catch (e) {
try { sessionStorage.removeItem('promotionFactoryJump') } catch (e2) {}
return ''
}
},
// 获取当年时间
yearData() {
this.YearThis = new Date().getFullYear()
@@ -347,8 +363,33 @@
.then(res => {
if (res.code == 0) {
this.df_jour = res.data.journals;
this.query.journal_id = this.df_jour[0].journal_id
const fromQueryId =
(this.$route && this.$route.query && (this.$route.query.journal_id || this.$route.query.journalId)) || ''
const fromSessionId = this.readPromotionFactoryJumpJournalId()
const pickedId = fromQueryId || fromSessionId
const matched = pickedId ? this.df_jour.find(j => String(j.journal_id) === String(pickedId)) : null
this.query.journal_id = matched ? matched.journal_id : this.df_jour[0].journal_id
this.getDate();
// 仅首次接收参数:应用后立刻清掉 URL避免刷新仍然固定某一本期刊
if (fromQueryId) {
const oldFullPath = this.$route && this.$route.fullPath ? this.$route.fullPath : ''
const nextQuery = Object.assign({}, (this.$route && this.$route.query) || {})
delete nextQuery.journal_id
delete nextQuery.journalId
delete nextQuery.journalIdd
delete nextQuery._ap_from
if (oldFullPath) {
bus.$emit('close_tag_by_path', { path: oldFullPath, silent: true })
}
this.$router.replace({ path: this.$route.path, query: nextQuery }).catch(() => {})
// 兜底:某些场景 router.replace 不更新地址栏,这里强制清掉 query
try {
const u = new URL(window.location.href)
;['_ap_from', 'journal_id', 'journalId', 'journalIdd'].forEach(k => u.searchParams.delete(k))
window.history.replaceState({}, '', u.toString())
} catch (e) {}
}
} else {
this.$message.error(res.msg);
}

View File

@@ -222,7 +222,7 @@ const i18n = new VueI18n({
router.beforeEach(async (to, from, next) => {
const currentRoute = to; // 获取当前路由路径,例如 "/home"
if (currentRoute.meta.hideJournal) {
if (currentRoute.meta.hideJournal || currentRoute.meta.public) {
} else {
try {
@@ -239,11 +239,11 @@ router.beforeEach(async (to, from, next) => {
// 无论接口成功/失败,都执行原有跳转逻辑
document.title = `${to.meta.title} | Traditional Medicine Research`;
document.title = `${to.meta.title || 'TMR'} | Traditional Medicine Research`;
const role = localStorage.getItem('U_name');
const userrole = localStorage.getItem('U_status');
if (!role && to.path != '/register' && to.path !== '/submission' && to.path !== '/verification' && to.path !== '/orcidLink' && to.path !== '/img' && to.path !== '/reviewer' && to.path !== '/thanks' && to.path !== '/login' && to.path !== '/refuse' && to.path !== '/managing' && to.path.search(/retrieve/i) < 0) {
if (!role && to.meta.public !== true && to.path != '/register' && to.path !== '/submission' && to.path !== '/verification' && to.path !== '/orcidLink' && to.path !== '/img' && to.path !== '/reviewer' && to.path !== '/thanks' && to.path !== '/login' && to.path !== '/refuse' && to.path !== '/managing' && to.path.search(/retrieve/i) < 0) {
next('/login');
} else {
if (navigator.userAgent.indexOf('MSIE') > -1 && to.path === '/editor') {

View File

@@ -1097,11 +1097,34 @@ export default new Router({
title: 'Scholar Database'
}
},
{
path: '/crawlTaskMonitor',
component: () => import('../components/page/crawlTaskMonitor'),
meta: {
title: 'Crawl Task Monitor'
}
},
{
path: '/scholarCrawlersKeywords',
component: () => import('../components/page/scholarCrawlersKeywords'),
meta: {
title: 'Crawl Keywords Config'
}
},
{
path: '/expertDatabase', //专家库
component: () => import('../components/page/expertDatabase'),
meta: {
title: 'Expert Database'
title: 'Expert Database',
titleKey: 'sidebar.expertList'
}
},
{
path: '/countryManagement', // 专家库-国家信息维护
component: () => import('../components/page/countryManagement'),
meta: {
title: 'Country Management',
titleKey: 'sidebar.countryManagement'
}
},
{
@@ -1454,6 +1477,24 @@ export default new Router({
title: 'img'
}
},
{
path: '/youthBoardRegister',
component: () => import( /* webpackChunkName: "youthBoardRegister" */ '../components/page/YouthEditorialBoardRegistration.vue'),
meta: {
title: 'Youth Editorial Board Registration',
public: true,
hideJournal: true
}
},
{
path: '/youthBoardSubmitSuccess',
component: () => import( /* webpackChunkName: "youthBoardSubmitSuccess" */ '../components/page/YouthBoardSubmitSuccess.vue'),
meta: {
title: 'Submission Success',
public: true,
hideJournal: true
}
},
{
path: '*',
redirect: '/404'

View File

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

View File

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

View File

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

View File

@@ -60,7 +60,7 @@ module.exports = {
assetsDir: 'static',
productionSourceMap: false,
devServer: {
// public: 'http://192.168.110.159:8080/', // 你自己本地的ip地址:端口号
port: '8080',
open: true,
@@ -77,7 +77,16 @@ module.exports = {
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
},
compress: false, // 💡 关键:禁止压缩,否则 SSE 流会被攒着不发
// 增加超时时间,防止长连接被代理提前掐断
proxyTimeout: 10 * 60 * 1000,
timeout: 10 * 60 * 1000
},
'/public': {
target: 'https://submission.tmrjournals.com/',
changeOrigin: true
}
}
},