Compare commits

2 Commits

Author SHA1 Message Date
b56d5aa105 提交 2026-06-09 14:31:32 +08:00
e57e06be95 作者背调 2026-06-05 14:33:24 +08:00
15 changed files with 3543 additions and 185 deletions

View File

@@ -643,6 +643,7 @@ const en = {
outboxTab: 'Mail sent',
draftsTab: 'Drafts',
deletedTab: 'Deleted',
trashTab: 'Filtered mail',
spamTab: 'Spam',
searchPlaceholder: 'Please enter name or email',
searchBtn: 'Search',
@@ -947,7 +948,63 @@ const en = {
selectTemplateStyleFirst: 'Please select template and style first',
recipientLimit: 'You can add up to {count} recipient(s)',
recipientLimitPlaceholder: 'Limit reached (max {count})',
sendBatchTitle: 'Send result',
sendBatchSuccess: 'Sent successfully:',
sendBatchFail: 'Failed to send:',
sendBatchConfirm: 'OK',
sendProgressTitle: 'Sending mail',
sendProgressRunning: 'Sending… {done}/{total} completed',
sendProgressAllSuccess: 'All sent successfully ({count})',
sendProgressDone: 'Done: {ok} sent, {fail} failed',
backToInbox: 'Back to email list',
articleContextId: 'Article ID:',
articleContextIdPlaceholder: 'Enter article ID',
articleContextLoad: 'Load manuscript',
articleContextSearch: 'Search',
articleContextSearchHint: 'Please enter article ID',
articleContextPickTitle: 'Select recipients',
articleContextNavAuthors: 'Authors',
articleContextNavUnderReview: 'Under review',
articleContextNavFinal: 'Final Decision',
articleContextNeedId: 'Please enter article ID',
articleContextLoadFail: 'Failed to load manuscript info',
articleContextTitle: 'Title',
articleContextAuthors: 'Authors',
articleContextUnderReview: 'First / re-review',
articleContextFirstReview: 'First-review reviewers',
articleContextReReview: 'Re-review reviewers',
articleContextFinalReview: 'Final decision',
tableName: 'Name',
tableNameScore: 'Name ( score )',
tableRole: 'Role',
tableAuthorRole: '1st auth. / Corr.',
tableEmail: 'Email',
articleContextEmptyGroup: 'None',
articleContextNoEmail: 'No email',
articleContextAddTo: 'Add to To',
articleContextSelectAll: 'Select all',
articleContextClearAll: 'Clear',
articleContextAddSelected: 'Add to To',
articleContextAddedTo: 'Added {count} recipient(s)',
articleContextOpenDialog: 'Select from manuscript',
articleContextDialogTitle: 'Manuscript contacts · {sn}',
articleContextSelectedHint: '{count} selected',
articleContextSelectedEmpty: 'Select contacts from the left',
articleContextRemoveSelected: 'Remove',
articleContextAddAllSelected: 'Add all selected',
articleContextReplaceVars: 'Replace template vars',
articleContextReplaceVarsSuccess: 'Replaced {{article_title}} / {{article_sn}}',
articleContextReplaceVarsEmpty: 'Subject or body has no {{article_title}} / {{article_sn}} placeholders',
roleFirstAuthor: 'First author',
roleCorrespondingAuthor: 'Corresponding author',
roleFirstAuthorShort: '1st auth.',
roleCorrespondingAuthorShort: 'Corr.',
roleFirstReview: 'First review',
roleReReview: 'Re-review',
roleFinalReview: 'Final decision',
copyText: 'Copy',
copySuccess: 'Copied',
copyFail: 'Copy failed',
},
home: {
authortop: 'Author guide',
@@ -1287,6 +1344,8 @@ const en = {
addTemplateBtn: 'Go add templates',
templateTab: 'Template',
styleTab: 'Style',
noStyle: 'No style',
noStyleDesc: 'Apply template body only, without header/footer',
loading: 'Loading...',
refresh: 'Refresh'
},
@@ -1338,7 +1397,7 @@ const en = {
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',
fieldSearchPlaceholder: 'Single term: fuzzy; comma/newline list: exact match',
countrySearchPlaceholder: 'Search countries',
countryQuickZone1: 'Partition 1',
countryQuickZone2: 'Partition 2',
@@ -1346,6 +1405,9 @@ const en = {
countryQuickChina: 'China',
countryQuickIndia: 'India',
noFieldMatch: 'No matching fields',
copySelectedFields: 'Copy selected fields (semicolon-separated)',
copySelectedFieldsSuccess: 'Copied {count} field(s)',
copySelectedFieldsEmpty: 'No fields selected',
noCountryMatch: 'No matching countries',
confirm: 'Confirm',
fieldsSaved: 'Promotion fields saved',
@@ -1433,10 +1495,10 @@ const en = {
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.',
factoryStepNav4Title: 'Country',
factoryStepNav4Desc: 'Select at least one country or partition.',
factoryStepNav5Title: 'Promotion fields',
factoryStepNav5Desc: 'Select at least one promotion field.',
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.',
@@ -1627,6 +1689,10 @@ const en = {
dialogIntroduction: 'Introduction :',
cancel: 'Cancel'
},
articleDetailEditor: {
reReviewNotifyBtn: 'Notify re-review',
reReviewNotifyTitle: 'Notify re-review'
},
refRelevance: {
startDetect: 'Batch Audit Reference Context',
startDetectTip:

View File

@@ -655,8 +655,9 @@ const zh = {
inboxTab: '收件箱',
outboxTab: '发件箱',
draftsTab: '草稿箱',
deletedTab: '已删除',
spamTab: '垃圾邮件',
deletedTab: '已删除',
trashTab: '已过滤的邮件',
spamTab: '垃圾邮件',
searchPlaceholder: '请输入姓名或邮箱',
searchBtn: '搜索',
syncBtn: '同步远程邮箱',
@@ -932,7 +933,63 @@ const zh = {
selectTemplateStyleFirst: '请先选择模板和风格',
recipientLimit: '最多只能添加 {count} 个收件人',
recipientLimitPlaceholder: '已达上限(最多 {count} 个)',
sendBatchTitle: '发送结果',
sendBatchSuccess: '发送成功:',
sendBatchFail: '发送失败:',
sendBatchConfirm: '确定',
sendProgressTitle: '正在发送邮件',
sendProgressRunning: '正在发送,已完成 {done}/{total}',
sendProgressAllSuccess: '全部发送成功,共 {count} 封',
sendProgressDone: '发送完成:成功 {ok} 封,失败 {fail} 封',
backToInbox: '返回收件箱',
articleContextId: '文章 ID',
articleContextIdPlaceholder: '请输入文章 ID',
articleContextLoad: '加载稿件信息',
articleContextSearch: '查询',
articleContextSearchHint: '请输入文章 ID',
articleContextPickTitle: '选择收件人',
articleContextNavAuthors: '稿件作者',
articleContextNavUnderReview: 'Under review',
articleContextNavFinal: 'Final Decision',
articleContextNeedId: '请输入文章 ID',
articleContextLoadFail: '加载稿件信息失败',
articleContextTitle: '标题',
articleContextAuthors: '作者',
articleContextUnderReview: '初审 / 复审',
articleContextFirstReview: '初审审稿人',
articleContextReReview: '复审审稿人',
articleContextFinalReview: '终审',
tableName: '姓名',
tableNameScore: 'Name ( score )',
tableRole: '身份',
tableAuthorRole: '一作/通讯',
tableEmail: '邮箱',
articleContextEmptyGroup: '暂无',
articleContextNoEmail: '无邮箱',
articleContextAddTo: '加入收件人',
articleContextSelectAll: '全选',
articleContextClearAll: '取消',
articleContextAddSelected: '加入收件人',
articleContextAddedTo: '已加入 {count} 个收件人',
articleContextOpenDialog: '从稿件选择联系人',
articleContextDialogTitle: '稿件联系人 · {sn}',
articleContextSelectedHint: '已勾选 {count} 人',
articleContextSelectedEmpty: '请从左侧勾选联系人',
articleContextRemoveSelected: '移除',
articleContextAddAllSelected: '加入全部已选',
articleContextReplaceVars: '替换模板变量',
articleContextReplaceVarsSuccess: '已替换 {{article_title}} / {{article_sn}}',
articleContextReplaceVarsEmpty: '主题或正文不含 {{article_title}}、{{article_sn}} 等占位符',
roleFirstAuthor: '第一作者',
roleCorrespondingAuthor: '通讯作者',
roleFirstAuthorShort: '一作',
roleCorrespondingAuthorShort: '通讯',
roleFirstReview: '初审',
roleReReview: '复审',
roleFinalReview: '终审',
copyText: '复制',
copySuccess: '已复制',
copyFail: '复制失败',
},
home: {
authortop: '用户指南',
@@ -1267,6 +1324,8 @@ const zh = {
addTemplateBtn: '去新增模板',
templateTab: '模版',
styleTab: '样式',
noStyle: '不使用样式',
noStyleDesc: '仅应用模板正文,不套页眉页脚',
loading: '加载中...',
refresh: '刷新'
},
@@ -1318,7 +1377,7 @@ const zh = {
clearAll: '取消全选',
selectPromotionFieldsTip: '可多选;未选择则不限制推广领域。',
selectPromotionCountryTip: '可多选;未选择则不限制国家。与领域接口一致,后续可对接独立国家数据。',
fieldSearchPlaceholder: '搜索推广领域',
fieldSearchPlaceholder: '单个词模糊搜索;逗号/换行多个名称需完全匹配',
countrySearchPlaceholder: '搜索国家',
countryQuickZone1: '1区',
countryQuickZone2: '2区',
@@ -1326,6 +1385,9 @@ const zh = {
countryQuickChina: 'China',
countryQuickIndia: 'India',
noFieldMatch: '没有匹配的领域',
copySelectedFields: '复制已选领域(分号分隔)',
copySelectedFieldsSuccess: '已复制 {count} 个领域',
copySelectedFieldsEmpty: '暂无已选领域',
noCountryMatch: '没有匹配的国家',
confirm: '确定',
fieldsSaved: '推广领域已保存',
@@ -1413,10 +1475,10 @@ const zh = {
factoryStepNav2Desc: '选好邮件模板和样式。',
factoryStepNav3Title: '发送与场景',
factoryStepNav3Desc: '选账号,填发送数量和目标人类型。',
factoryStepNav4Title: '推广领域',
factoryStepNav4Desc: '至少选择一个推广领域。',
factoryStepNav5Title: '国家',
factoryStepNav5Desc: '至少选择一个国家或分区。',
factoryStepNav4Title: '国家',
factoryStepNav4Desc: '至少选择一个国家或分区。',
factoryStepNav5Title: '推广领域',
factoryStepNav5Desc: '至少选择一个推广领域。',
factoryStepNav6Title: '确认并开启',
factoryStepNav6Desc: '选择仅保存或次日自动开启。',
factoryPromotionFieldsBlockTip: '请打开「选择领域」,在列表中至少勾选一项;不得留空提交。',
@@ -1607,6 +1669,10 @@ const zh = {
dialogIntroduction: '简介:',
cancel: '取消'
},
articleDetailEditor: {
reReviewNotifyBtn: '通知复审',
reReviewNotifyTitle: '通知复审'
},
refRelevance: {
startDetect: '全量引文相关性核查',
startDetectTip: '一键启动对本篇稿件全部参考文献与正文语境的 AI 一致性核查',

View File

@@ -3,8 +3,8 @@
<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 class="success-desc success-desc--left" v-if="isFromChina">
You may voluntarily scan the QR code to connect with the editorial team and join our editorial board group for future academic exchanges.
</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.
@@ -15,8 +15,8 @@
<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> -->
<p class="remark-tip">When joining the group, please use the following remark format:</p>
<div class="remark-box">{{ groupRemarkFormat }}</div>
</div>
<router-link to="/login" replace class="back-btn"> Login to the Submission System Now </router-link>
@@ -30,10 +30,17 @@ export default {
data() {
return {
qrCodeUrl: '',
qrLoading: false
qrLoading: false,
journalAbbr: ''
};
},
computed: {
groupRemarkFormat() {
if (this.journalAbbr) {
return `${this.journalAbbr} + Name`;
}
return 'Journal Abbreviation + Name';
},
isFromChina() {
const s = String(this.$route.query.country || '').trim();
if (!s) return false;
@@ -85,6 +92,7 @@ export default {
const raw =
(journal && (journal.wechat_yboard_qrcode || journal.yboard_qrcode || journal.qrcode_url)) || '';
this.qrCodeUrl = this.buildMediaUrl(raw);
this.journalAbbr = journal && journal.abbr ? String(journal.abbr).trim() : '';
})
.catch(() => {
this.qrCodeUrl = '';
@@ -147,6 +155,10 @@ export default {
line-height: 1.6;
}
.success-desc--left {
text-align: left;
}
.qr-section {
background-color: #f0f7ff;
border: 1px solid #c3dafe;
@@ -182,19 +194,22 @@ export default {
.remark-tip {
font-size: 14px;
color: #4a5568;
margin-top: 10px;
margin-top: 16px;
text-align: left;
line-height: 1.6;
}
.remark-box {
background: #fff;
border: 1px solid #feb2b2;
color: var(--danger);
font-weight: bold;
border: 1px solid #c3dafe;
color: #2b6cb0;
font-weight: 600;
padding: 10px 15px;
border-radius: 8px;
display: inline-block;
margin-top: 5px;
margin-top: 8px;
font-size: 14px;
text-align: left;
word-break: break-word;
}
.back-btn {

View File

@@ -812,9 +812,14 @@
>
</span>
<span v-else>
<!-- 无数据:补全空内容(可自定义为“-”“无”等) -->
<span>-</span>
<el-button
v-if="index1 === 0 && showReReviewNotifyBtn(iken)"
type="text"
size="mini"
style="margin-left: 8px"
@click.stop="openReReviewNotifyMail(iken)"
>{{ $t('articleDetailEditor.reReviewNotifyBtn') }}</el-button>
</span>
</td>
</template>
@@ -1126,6 +1131,16 @@
</el-dialog>
<!-- 审稿详情 start-->
<reviewerDetail ref="reviewerDetail" v-if="reviewerVisible" :reviewerDetail="reviewerDetail" destroy-on-close></reviewerDetail>
<re-review-notify-mail-dialog
:visible.sync="reReviewNotifyVisible"
:reviewer="reReviewNotifyReviewer"
:article-id="editform.articleId"
:article-title="form.title"
:accept-sn="form.accept_sn"
:journal-id="form.journal"
:journal-name="form.journal_name"
:journal-url="form.journal_url"
/>
<!-- 审稿详情 end-->
<el-dialog title="Edit H Index" :visible.sync="HEditVisible" width="400px" :close-on-click-modal="false">
<div style="margin: 0 0 20px 10px; font-weight: bold">{{ HIxForm.realname }}</div>
@@ -1547,11 +1562,13 @@
import timetalk from './time_talk';
import reviewerDetail from '../../components/page/components/articleDetail/reviewerdetail.vue';
import FigureCopyright from '../../components/page/components/articleDetail/FigureCopyright.vue';
import ReReviewNotifyMailDialog from '../../components/page/components/articleDetail/ReReviewNotifyMailDialog.vue';
export default {
components: {
timetalk,
reviewerDetail,
FigureCopyright
FigureCopyright,
ReReviewNotifyMailDialog
},
data() {
return {
@@ -1561,6 +1578,8 @@ export default {
finalDecisionData: {},
FinalDecisionVisible: false,
reviewerVisible: false,
reReviewNotifyVisible: false,
reReviewNotifyReviewer: null,
expanded: false,
corresVisible: false,
isShowAuthorInfo: false,
@@ -1658,6 +1677,8 @@ export default {
articleId: this.$route.query.id,
journal: '',
journalname: '',
journal_name: '',
journal_url: '',
username: '',
title: '',
accept_sn: '',
@@ -2272,6 +2293,23 @@ export default {
this.$refs.reviewerDetail.init(item.art_rev_id, type, repeatItem);
});
},
showReReviewNotifyBtn(reviewer) {
if (!this.isReReviewNotifyEnabled()) return false;
if (!reviewer) return false;
const hasFirstReview =
Number(reviewer.state) !== 0 && [1, 2, 3, 4].includes(Number(reviewer.recommend));
if (!hasFirstReview) return false;
const second = Array.isArray(reviewer.repeat) ? reviewer.repeat[0] : null;
if (!second) return true;
return ![1, 2, 3].includes(Number(second.recommend));
},
isReReviewNotifyEnabled() {
return Object.prototype.hasOwnProperty.call(this.$route.query, 'zy');
},
openReReviewNotifyMail(reviewer) {
this.reReviewNotifyReviewer = reviewer ? { ...reviewer } : null;
this.reReviewNotifyVisible = true;
},
goReviewerDetail(id) {
console.log('id at line 1112:', id);
@@ -2694,7 +2732,9 @@ export default {
: '';
this.form.title = res.article.title;
this.form.journal = res.article.journal_id;
this.form.journalname = res.article.journalname;
this.form.journal_name = res.article.journal_name || res.article.journalname || '';
this.form.journalname = res.article.journalname || res.article.journal_name || '';
this.form.journal_url = res.article.journal_url || res.article.website || res.article.journal_website || '';
this.form.abstrart = res.article.abstrart;
this.form.accept_sn = res.article.accept_sn;
this.form.scoring = res.article.scoring;

View File

@@ -275,41 +275,40 @@
>
<div style="display: flex; align-items: center; justify-content: space-between">
<div style="display: flex; align-items: center; width: calc(100% - 120px)">
<span class="reportIcon" style="cursor: pointer" @click="linkEmail(item, v.art_aut_id)"
<span class="reportIcon"
><i class="el-icon-s-custom"></i
></span>
<span
@click="linkEmail(item, v.art_aut_id)"
style="
max-width: calc(100% - 60px);
white-space: nowrap;
display: inline-block;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
"
>{{ v.realname || v.email || '-' }}
<span class="author-name">{{ v.realname || v.email || '-' }}</span>
<span class="author-action-icons">
<span
class="author-action-btn author-action-btn--mail"
title="Send email"
@click.stop="linkEmail(item, v.art_aut_id)"
>
<svg class="author-action-svg" viewBox="0 0 16 16" aria-hidden="true">
<path
fill="currentColor"
d="M2 3.5A1.5 1.5 0 0 1 3.5 2h9A1.5 1.5 0 0 1 14 3.5v9A1.5 1.5 0 0 1 12.5 14h-9A1.5 1.5 0 0 1 2 12.5v-9zm1.2-.5 4.8 3.6L12.8 3H3.2zM3 4.3v7.7h10V4.3l-4.5 3.4a.6.6 0 0 1-.7 0L3 4.3z"
/>
</svg>
</span>
<span
class="author-action-btn author-action-btn--search"
title="Search author"
@click.stop="openAuthorSearch(v)"
>
<svg class="author-action-svg" viewBox="0 0 16 16" aria-hidden="true">
<path
fill="currentColor"
d="M7 1.25a5.75 5.75 0 1 1 0 11.5 5.75 5.75 0 0 1 0-11.5zm0 1.3a4.45 4.45 0 1 0 0 8.9 4.45 4.45 0 0 0 0-8.9z"
/>
<path
fill="currentColor"
d="M11.05 11.05a.65.65 0 0 1 .92 0l2.98 2.98a.65.65 0 1 1-.92.92l-2.98-2.98a.65.65 0 0 1 0-.92z"
/>
</svg>
</span>
</span>
<svg
@click="linkEmail(item, v.art_aut_id)"
t="1763011629734"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="11794"
width="24"
height="16"
style="margin-top: 1.5px; margin-left: 4px; cursor: pointer"
>
<path
d="M780.8 0c134.3168 0 243.2 108.8832 243.2 243.2v537.6c0 134.3168-108.8832 243.2-243.2 243.2H243.2c-134.3168 0-243.2-108.8832-243.2-243.2V243.2C0 108.8832 108.8832 0 243.2 0h537.6z m5.3568 604.4864c-7.9936-6.496-13.4912-8.4928-16.4864-5.9904-3.0016 2.496-4.4992 8.2432-4.4992 17.2352v32.9792c0 8.9984-2.1248 15.2448-6.368 18.7392-4.2496 3.5008-11.3728 5.248-21.3632 5.248h-34.432a455.7504 455.7504 0 0 1-17.664-0.3776 357.76 357.76 0 0 0-15.36-0.3712h-14.9952c-11.4944 0-19.488 1.4976-23.9872 4.4992-4.4928 2.9952-6.7456 11.488-6.7456 25.4848v38.2208c0 8 3.3728 14.496 10.1184 19.488 6.7456 4.9984 13.6192 7.5008 20.6144 7.5008h86.944c7.0016 0 12.6208 2.7456 16.8704 8.2432 4.2432 5.4976 6.368 12.4928 6.368 20.992v33.728c0 13.4848 2.1248 19.9808 6.3744 19.4816 4.2432-0.4992 11.616-4.992 22.1056-13.4912a919.7312 919.7312 0 0 0 28.1088-22.4832c10.24-8.4992 20.736-16.992 31.488-25.4848a1926.9952 1926.9952 0 0 0 31.104-25.1136c9.9904-8.2432 18.7328-15.36 26.2272-21.3568 5.504-4 8.128-10.496 7.872-19.488-0.2496-8.9984-3.6224-15.9936-10.112-20.992a1228.544 1228.544 0 0 1-28.864-22.8608 1902.9184 1902.9184 0 0 0-32.9792-26.2336 2137.6256 2137.6256 0 0 1-32.6016-25.856 968.064 968.064 0 0 0-27.7376-21.7408zM180.5248 389.376c-7.488-5.4976-13.7344-6.8672-18.7328-4.1216-4.992 2.752-7.4944 9.3696-7.4944 19.8656v277.3248c0 10.496 1.248 20.6144 3.744 30.3616a62.72 62.72 0 0 0 13.4976 25.856c6.496 7.4944 15.488 13.6192 26.9824 18.368 11.488 4.7424 26.2336 7.1168 44.224 7.1168h56.16c22.464 0.0192 48.2112 0.1408 77.2544 0.3776 30.976 0.2496 64.2112 0.3712 99.6864 0.3712h103.4368a42.112 42.112 0 0 1-0.7488-8.2432v-61.4656c0-7.9936 0.6272-16.3648 1.8752-25.1072 1.248-8.7424 3.7504-16.6144 7.4944-23.6096a48.5568 48.5568 0 0 1 15.3664-17.2416c6.496-4.4928 14.9888-6.7456 25.4848-6.7456h87.6992c0-1.4976 0.2496-3.4944 0.7488-5.9968 0-2.496 0.128-5.4976 0.3712-8.992 0.256-3.5008 0.3776-8 0.3776-13.4912 0-8 1.376-16.992 4.1216-26.9824 2.752-9.9968 6.7456-18.7392 11.9936-26.24 5.248-7.488 11.9936-12.3648 20.2368-14.6112 8.2432-2.2464 17.8624 0.3712 28.8576 7.872 10.9952 6.9952 23.104 15.488 36.352 25.4848a1266.0096 1266.0096 0 0 1 40.8512 32.2304V396.864c0-7.0016-2.624-11.1232-7.872-12.3712-5.248-1.248-12.6144 1.1264-22.112 7.1232-2.9952 1.9968-8 5.248-14.9888 9.7408a5917.1648 5917.1648 0 0 1-22.8608 14.6176c-8.2432 5.248-16.992 10.8672-26.24 16.864a12420.544 12420.544 0 0 1-25.4784 16.4928c-7.744 4.992-14.24 9.2416-19.488 12.736-5.248 3.5008-8.3712 5.504-9.3696 5.9968l-94.4448 58.464c-3.4944 2.0032-8.8704 5.248-16.1152 9.7472a2571.0336 2571.0336 0 0 1-23.232 14.24c-8.2496 4.992-15.872 9.6192-22.8608 13.8688a3873.9264 3873.9264 0 0 1-14.24 8.6208c-2.496 1.4976-6.2464 3.1232-11.2448 4.864-4.9984 1.7536-10.624 2.88-16.864 3.3792-6.2464 0.4992-12.7424 0.2496-19.488-0.7488a46.4064 46.4064 0 0 1-19.1168-7.4944l-20.2368-13.4912A3314.24 3314.24 0 0 0 432 544.896a2994.4 2994.4 0 0 1-19.8592-13.12 260.5568 260.5568 0 0 0-14.2464-8.992c-5.4912-3.0016-11.8656-3.6224-19.1104-1.8752-7.2448 1.7472-13.12 4.6208-17.6128 8.6208-2.496 1.4976-7.1232 5.8688-13.8688 13.12a2662.8928 2662.8928 0 0 0-21.3632 23.232 654.7392 654.7392 0 0 1-21.3568 22.4832c-6.7456 6.752-10.8736 10.624-12.3712 11.6224-7.4944 4.4928-12.992 5.4976-16.4864 2.9952-3.5008-2.496-4-7.2448-1.504-14.24 1.504-4 4.3776-9.7408 8.6208-17.2416 4.2496-7.488 8.6208-15.36 13.12-23.6096a770.4192 770.4192 0 0 0 12.736-24.3584c4-8 7.0016-14.4896 8.9984-19.488 2.496-4.992 3.2512-10.8672 2.2464-17.6128-0.9984-6.7456-4.992-12.3712-11.9872-16.864-2.0032-0.9984-6.1248-3.5008-12.3712-7.4944a3333.7728 3333.7728 0 0 0-22.4832-14.2464 3710.0032 3710.0032 0 0 1-27.7376-17.6128 1545.984 1545.984 0 0 0-28.1024-17.6128 915.1872 915.1872 0 0 1-23.2384-14.6176 494.88 494.88 0 0 0-13.4912-8.6208z m17.9968-139.4112c-15.9936 0-27.232 3.744-33.728 11.2448-6.5024 7.488-9.7472 17.7344-9.7472 30.7264v6.7456c0 4.4992 0.7488 8 2.2464 10.496 1.504 2.496 4.9984 5.7472 10.496 9.7408l306.56 183.6416c7.4944 4.4928 14.24 8.1216 20.2368 10.8672 5.9968 2.752 10.496 4.1216 13.4912 4.1216 2.496-0.4992 7.3728-2.496 14.6176-5.9968a299.5712 299.5712 0 0 1 18.368-8.2432l306.56-184.384c6.9952-5.504 10.9888-9.12 11.9936-10.8736 0.9984-1.7472 1.4976-4.8704 1.4976-9.3696l-0.7488-8.2432c0-12.4928-3.2512-22.2336-9.7472-29.2288-6.496-7.0016-17.984-10.496-34.4768-10.496z"
fill="#409eff"
p-id="11795"
data-spm-anchor-id="a313x.search_index.0.i17.7fe43a81QbZr1X"
class="selected"
></path>
</svg>
</div>
<div style="max-width: 260px; width: auto" v-if="v.user_id">
@@ -2758,6 +2757,16 @@ export default {
query: data
});
},
openAuthorSearch(author) {
const artAutId = author && author.art_aut_id != null && author.art_aut_id !== '' ? author.art_aut_id : '';
if (!artAutId) {
return;
}
window.open(
'https://submission.tmrjournals.com/api/author/index?artAutId=' + encodeURIComponent(artAutId),
'_blank'
);
},
//超出传送文件个数限制
alertlimit() {
this.$message.error('The maximum number of uploaded files has been exceeded');
@@ -3382,6 +3391,58 @@ td {
align-items: center;
justify-content: center;
}
.author-name {
max-width: calc(100% - 100px);
white-space: nowrap;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
color: #303133;
}
.author-action-icons {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
flex-shrink: 0;
}
.author-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 5px;
cursor: pointer;
line-height: 1;
color: #fff;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.author-action-svg {
width: 13px;
height: 13px;
display: block;
}
.author-action-btn--mail {
background: linear-gradient(180deg, #66b1ff 0%, #409eff 100%);
box-shadow: 0 1px 4px rgba(64, 158, 255, 0.32);
}
.author-action-btn--mail:hover {
background: linear-gradient(180deg, #79bbff 0%, #53a8ff 100%);
box-shadow: 0 3px 8px rgba(64, 158, 255, 0.38);
transform: translateY(-1px);
}
.author-action-btn--search {
background: linear-gradient(180deg, #0088bf 0%, #006699 100%);
box-shadow: 0 1px 4px rgba(0, 102, 153, 0.32);
}
.author-action-btn--search:hover {
background: linear-gradient(180deg, #0099cc 0%, #007bb8 100%);
box-shadow: 0 3px 8px rgba(0, 102, 153, 0.38);
transform: translateY(-1px);
}
.author-information {
cursor: pointer;

View File

@@ -105,6 +105,12 @@
>
<span class="tpl-name">{{ taskCard.countryScopeLabel || '-' }}</span>
</div>
<div class="meta-row">
<i class="el-icon-s-data"
><span style="font-size: 11px; margin-left: 3px; margin-right: 3px">{{ $t('autoPromotion.factorySendCount') }}&nbsp;:</span></i
>
<span class="tpl-name">{{ taskCard.sendCountText }}</span>
</div>
</div>
@@ -1104,6 +1110,7 @@ export default {
fieldCount: fieldCount,
fieldCountText: String(fieldCount),
countryScopeLabel: task.country_scope_label || '-',
sendCountText: this.formatFactorySendCount(task && task.send_count),
createdAtText: this.formatTaskCreateTime(task),
totalCount: total,
showCount: idx === 0 && total > 0,
@@ -1189,6 +1196,11 @@ export default {
.filter(Boolean);
return fetchIds.length;
},
formatFactorySendCount(value) {
if (value == null || String(value).trim() === '') return '-';
const n = Number(value);
return Number.isFinite(n) ? String(n) : String(value).trim();
},
formatTaskCreateTime(task) {
if (!task || typeof task !== 'object') return '';
const raw = task.ctime || task.create_time || task.created_at || task.time || '';

View File

@@ -0,0 +1,307 @@
<template>
<el-dialog
:title="$t('articleDetailEditor.reReviewNotifyTitle')"
:visible.sync="dialogVisible"
width="920px"
top="5vh"
append-to-body
:close-on-click-modal="false"
@open="handleOpen"
@closed="handleClosed"
>
<div v-loading="loading" class="re-review-mail-dialog">
<div class="mail_shuru">
<span class="mail_tit">{{ $t('mailboxSend.to') }}</span>
<el-tag v-if="toEmail" type="info" size="small">{{ toEmail }}</el-tag>
<span v-else class="mail-empty-tip">-</span>
</div>
<div class="mail_shuru">
<span class="mail_tit">{{ $t('mailboxSend.subject') }}</span>
<el-input v-model="mailForm.subject" class="mail_inp" />
</div>
<div class="mail-editor-wrap">
<ckeditor-mail
v-if="editorMounted"
ref="mailEditor"
:key="editorId"
:id="editorId"
v-model="mailForm.content"
:use-safe-init-content="true"
/>
</div>
<div class="mail-footer-bar">
<div class="sender-info">
<span class="sender-label">{{ $t('mailboxSend.sender') }}</span>
<span class="sender-content">
<template v-if="senderName && senderEmail">{{ senderName }} &lt;{{ senderEmail }}&gt;</template>
<template v-else>-</template>
</span>
</div>
<div class="action-buttons">
<el-button @click="dialogVisible = false">{{ $t('mailboxSend.cancel') || 'Cancel' }}</el-button>
<el-button type="primary" icon="el-icon-s-promotion" :loading="sendLoading" @click="handleSend">
{{ $t('mailboxSend.send') }}
</el-button>
</div>
</div>
</div>
</el-dialog>
</template>
<script>
import CkeditorMail from '@/components/page/components/email/CkeditorMail.vue';
import { applyReviewNotifyTemplateVariables } from '@/utils/mailTemplatePreview';
const RE_REVIEW_TEMPLATE_ID = '82';
/** 复审通知固定发件邮箱 j_email_id */
const RE_REVIEW_SENDER_J_EMAIL_ID = '23';
export default {
name: 'ReReviewNotifyMailDialog',
components: {
CkeditorMail
},
props: {
visible: {
type: Boolean,
default: false
},
reviewer: {
type: Object,
default: null
},
articleId: {
type: [String, Number],
default: ''
},
articleTitle: {
type: String,
default: ''
},
/** 稿件 SN对应接口字段 accept_sn */
acceptSn: {
type: String,
default: ''
},
journalId: {
type: [String, Number],
default: ''
},
/** 期刊名称,对应 getArticleDetail 返回字段 journal_name */
journalName: {
type: String,
default: ''
},
/** 期刊官网,对应 getArticleDetail 返回字段 */
journalUrl: {
type: String,
default: ''
}
},
data() {
return {
loading: false,
sendLoading: false,
editorMounted: false,
editorId: 're-review-mail-' + Date.now(),
mailForm: {
subject: '',
content: ''
},
toEmail: '',
senderName: '',
senderEmail: '',
jEmailId: ''
};
},
computed: {
dialogVisible: {
get() {
return this.visible;
},
set(val) {
this.$emit('update:visible', val);
}
}
},
methods: {
handleOpen() {
this.editorMounted = false;
this.editorId = 're-review-mail-' + Date.now();
this.resetForm();
this.loadMailData();
},
handleClosed() {
this.editorMounted = false;
this.resetForm();
},
resetForm() {
this.mailForm = { subject: '', content: '' };
this.toEmail = '';
this.senderName = '';
this.senderEmail = '';
this.jEmailId = '';
},
async loadMailData() {
this.loading = true;
this.editorMounted = false;
try {
await Promise.all([this.loadReviewerEmail(), this.loadSenderAccount()]);
await this.loadTemplate();
await this.$nextTick();
this.editorMounted = true;
} catch (e) {
console.error('loadMailData failed', e);
} finally {
this.loading = false;
}
},
async loadReviewerEmail() {
const reviewer = this.reviewer || {};
if (reviewer.email) {
this.toEmail = reviewer.email;
return;
}
if (!reviewer.art_rev_id || !this.articleId) return;
const res = await this.$api.post('api/Workbench/getArticleReviewDetail', {
article_id: this.articleId,
art_rev_id: reviewer.art_rev_id,
account: localStorage.getItem('U_name')
});
if (res && res.status === 1 && res.data && res.data.article_reviewer) {
this.toEmail = res.data.article_reviewer.email || '';
}
},
async loadSenderAccount() {
this.jEmailId = RE_REVIEW_SENDER_J_EMAIL_ID;
const emailRes = await this.$api.post('api/email_client/getOneEmail', {
j_email_id: this.jEmailId
});
if (emailRes && emailRes.code === 0 && emailRes.data && emailRes.data.email) {
const email = emailRes.data.email;
this.senderName = email.smtp_from_name || email.smtp_user || '';
this.senderEmail = email.smtp_user || '';
}
},
async loadTemplate() {
const res = await this.$api.post('api/mail_template/getTemplate', {
template_id: RE_REVIEW_TEMPLATE_ID
});
if (!res || res.code !== 0) {
this.$message.error((res && res.msg) || 'Failed to load mail template');
return;
}
const data = (res && res.data) || {};
const t = data.template || data.detail || data || {};
const vars = {
title: this.articleTitle,
accept_sn: this.acceptSn,
journal_name: this.journalName,
journal_url: this.journalUrl
};
const subjectRaw = t.subject != null ? String(t.subject) : '';
const bodyRaw = t.body_html != null ? t.body_html : t.body != null ? t.body : '';
this.mailForm.subject = applyReviewNotifyTemplateVariables(subjectRaw, vars);
this.mailForm.content = applyReviewNotifyTemplateVariables(String(bodyRaw || ''), vars);
},
async handleSend() {
if (!this.toEmail) {
this.$message.warning(this.$t('mailboxSend.validateTo'));
return;
}
if (!this.mailForm.subject) {
this.$message.warning(this.$t('mailboxSend.validateSubject'));
return;
}
if (!this.jEmailId) {
this.$message.warning(this.$t('mailboxSend.needAccount'));
return;
}
if (!this.journalId) {
this.$message.warning(this.$t('mailboxSend.needAccount'));
return;
}
this.sendLoading = true;
try {
let content = this.mailForm.content || '';
const editorRef = this.$refs.mailEditor;
if (editorRef && typeof editorRef.getInlinedContent === 'function') {
content = editorRef.getInlinedContent() || content;
}
const res = await this.$api.post('api/email_client/sendOne', {
to_email: this.toEmail,
subject: this.mailForm.subject,
content,
j_email_id: this.jEmailId,
journal_id: String(this.journalId)
});
if (res && res.code === 0) {
this.$message.success(this.$t('mailboxSend.sendSuccess'));
this.dialogVisible = false;
this.$emit('sent');
} else {
this.$message.error((res && res.msg) || this.$t('mailboxSend.sendFail'));
}
} catch (e) {
this.$message.error(this.$t('mailboxSend.sendFail'));
} finally {
this.sendLoading = false;
}
}
}
};
</script>
<style scoped>
.re-review-mail-dialog {
min-height: 420px;
}
.mail_shuru {
display: flex;
align-items: center;
min-height: 40px;
line-height: 40px;
margin-bottom: 8px;
}
.mail_tit {
width: 72px;
flex-shrink: 0;
color: #606266;
font-size: 14px;
}
.mail_inp {
flex: 1;
}
.mail_inp ::v-deep .el-input__inner {
border: none;
border-bottom: 1px solid #ebeef5;
border-radius: 0;
}
.mail-editor-wrap {
margin-top: 12px;
}
.mail-footer-bar {
margin-top: 16px;
padding: 10px 15px;
background-color: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.sender-info {
font-size: 14px;
color: #666;
}
.sender-label {
font-weight: bold;
margin-right: 10px;
}
.sender-content {
color: #006699;
}
.mail-empty-tip {
color: #909399;
}
</style>

View File

@@ -44,15 +44,25 @@
<el-option :label="$t('autoPromotionLogs.taskLogState3')" value="3"></el-option>
<el-option :label="$t('autoPromotionLogs.taskLogState4')" value="4"></el-option>
</el-select>
<el-input
v-model="logKeyword"
size="mini"
clearable
:placeholder="$t('autoPromotionLogs.searchPlaceholder')"
prefix-icon="el-icon-search"
class="log-keyword-input"
@keyup.enter.native="handleLogKeywordSearch"
@clear="handleLogKeywordSearch"
/>
<el-button
type="primary"
size="mini"
icon="el-icon-refresh"
icon="el-icon-search"
:loading="loading"
class="log-refresh-btn"
@click="handleRefreshLogs"
class="log-search-btn"
@click="handleLogKeywordSearch"
>
{{ $t('autoPromotionLogs.logRefresh') }}
{{ $t('autoPromotionLogs.searchBtn') }}
</el-button>
</div>
</header>
@@ -69,7 +79,7 @@
<div class="list-wrapper">
<div
v-for="(item, rowIndex) in fullData"
v-for="(item, rowIndex) in displayLogData"
:key="item.id"
class="log-row"
:class="{ 'row-error': item.isErrorRow }"
@@ -137,7 +147,7 @@
</div>
</div>
<div v-if="!loading && fullData.length === 0" class="empty-ui">
<div v-if="!loading && displayLogData.length === 0" class="empty-ui">
<i class="el-icon-document"></i>
<p>{{ $t('autoPromotionLogs.emptyLogs') }}</p>
</div>
@@ -147,7 +157,7 @@
<el-pagination
:current-page.sync="currentPage"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:page-sizes="[20, 50, 100, 500]"
:total="totalCount"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageSizeChange"
@@ -306,10 +316,11 @@ export default {
return {
loading: false,
logState: '',
logKeyword: '',
currentPage: 1,
pageSize: 20,
totalCount: 0,
fullData: [],
displayLogData: [],
logDetailVisible: false,
logDetailLoading: false,
logDetailMode: 'preview',
@@ -376,6 +387,7 @@ export default {
if (oldVal === '' || oldVal == null) return;
this.currentPage = 1;
this.logState = '';
this.logKeyword = '';
this.loadData();
}
},
@@ -383,13 +395,15 @@ export default {
handleOpen() {
this.currentPage = 1;
this.logState = '';
this.logKeyword = '';
this.loadData();
},
handleLogStateChange() {
this.currentPage = 1;
this.loadData();
},
handleRefreshLogs() {
handleLogKeywordSearch() {
this.currentPage = 1;
this.loadData();
},
handlePageSizeChange(size) {
@@ -399,7 +413,7 @@ export default {
},
async loadData() {
if (!this.taskId) {
this.fullData = [];
this.displayLogData = [];
this.totalCount = 0;
return;
}
@@ -413,10 +427,14 @@ export default {
if (this.logState !== '' && this.logState != null) {
params.state = String(this.logState);
}
const keyword = String(this.logKeyword || '').trim();
if (keyword) {
params.keyword = keyword;
}
const res = await this.$api.post(API_GET_TASK_LOGS, params);
if (res && res.code != null && Number(res.code) !== 0) {
this.$message.error(res.msg || this.$t('autoPromotionLogs.logLoadFailed'));
this.fullData = [];
this.displayLogData = [];
this.totalCount = 0;
return;
}
@@ -431,9 +449,9 @@ export default {
this.totalCount = Number(
(payload && (payload.total != null ? payload.total : payload.count)) || rawList.length || 0
);
this.fullData = rawList.map((row, idx) => this.mapLogItem(row, idx));
this.displayLogData = rawList.map((row, idx) => this.mapLogItem(row, idx));
} catch (e) {
this.fullData = [];
this.displayLogData = [];
this.totalCount = 0;
this.$message.error((e && e.message) || this.$t('autoPromotionLogs.logLoadFailed'));
} finally {
@@ -771,7 +789,11 @@ export default {
align-items: center;
}
.log-refresh-btn {
.log-keyword-input {
width: 220px;
}
.log-search-btn {
padding: 7px 10px;
}

View File

@@ -220,19 +220,30 @@
</h4>
<div
class="status-confirm-box is-clickable"
:class="{ 'has-selected-fields': selectedFieldLabels.length }"
role="button"
tabindex="0"
@click="openFieldDialog"
@keyup.enter="openFieldDialog"
>
<el-tooltip
v-if="selectedFieldLabels.length"
:content="$t('autoPromotion.copySelectedFields')"
placement="top"
>
<i
class="el-icon-document-copy field-copy-icon"
role="button"
tabindex="0"
:aria-label="$t('autoPromotion.copySelectedFields')"
@click.stop="copySelectedFieldText"
@keyup.enter.stop="copySelectedFieldText"
></i>
</el-tooltip>
<div v-if="selectedFieldLabels.length" class="selected-tags">
<el-tag v-for="label in selectedFieldLabels" :key="'pf-' + label" size="mini" type="info" effect="plain">{{ label }}</el-tag>
</div>
<!-- <div v-else class="empty-inline">
<i class="el-icon-collection-tag"></i>
<span>{{ $t('autoPromotion.factoryClickConfigureFields') }}</span>
</div> -->
<div class="field-tip">
<div v-else class="field-tip">
<span class="factory-required-star" aria-hidden="true">*</span
><span>{{ $t('autoPromotion.factoryPromotionFieldsBlockTip') }}</span>
</div>
@@ -361,6 +372,7 @@
},
expertTypeCountsLoading: false,
expertTypeCountsReqSeq: 0,
expertDbCountReqSeq: 0,
factoryStartPromotion: true,
factoryConfig: { defaultTemplateId: '', defaultStyleId: '' },
factoryTemplateName: '',
@@ -550,8 +562,9 @@
const list = (this.availableFields || []).filter(function (item) {
if (!tokens.length) return true;
const label = normalize(item.label || '');
const fuzzy = tokens.length === 1;
return tokens.some(function (t) {
return t === label;
return fuzzy ? label === t || label.includes(t) : label === t;
});
});
return list.slice().sort(function (a, b) {
@@ -1006,12 +1019,7 @@
limit: 1
})
.catch(() => null),
this.$api
.post('api/expert_manage/getList', {
pageIndex: 1,
pageSize: 1
})
.catch(() => null)
this.$api.post('api/expert_manage/getList', this.buildExpertDbListParams()).catch(() => null)
]);
const next = {
@@ -1058,6 +1066,40 @@
}
}
},
buildExpertDbListParams() {
const params = {
pageIndex: 1,
pageSize: 1
};
const zoneIds = (this.factoryZoneCountryIds || []).map(String);
if (zoneIds.length === 1 && zoneIds[0] === 'country_china') {
params.country = 'China';
}
return params;
},
async refreshExpertDbCount() {
const journalId = this.selectedJournalId;
if (!journalId) return;
const reqId = ++this.expertDbCountReqSeq;
this.expertTypeCountsLoading = true;
try {
const res = await this.$api.post('api/expert_manage/getList', this.buildExpertDbListParams()).catch(() => null);
if (reqId !== this.expertDbCountReqSeq) return;
const safeCount = function (v) {
return v == null || isNaN(Number(v)) ? 0 : Number(v);
};
let expertDb = null;
if (res && res.code === 0) {
const total = (res.data && (res.data.total || res.data.count)) || res.total;
expertDb = safeCount(total);
}
this.expertTypeCounts = Object.assign({}, this.expertTypeCounts, { expertDb: expertDb });
} finally {
if (reqId === this.expertDbCountReqSeq) {
this.expertTypeCountsLoading = false;
}
}
},
async loadAccounts(id) {
this.accountsLoading = true;
try {
@@ -1239,6 +1281,20 @@
this.syncActiveStep();
},
selectAllFields() {
const kwRaw = String(this.fieldSearchText || '').trim();
if (kwRaw) {
const filtered = this.sortedFilteredFields || [];
if (!filtered.length) {
this.$message.warning(this.$t('autoPromotion.noFieldMatch'));
return;
}
const idSet = new Set((this.factoryFieldIds || []).map(String));
filtered.forEach(function (f) {
idSet.add(String(f.id));
});
this.factoryFieldIds = Array.from(idSet);
return;
}
this.factoryFieldIds = (this.availableFields || []).map(function (f) {
return String(f.id);
});
@@ -1246,6 +1302,42 @@
clearAllFields() {
this.factoryFieldIds = [];
},
copySelectedFieldText() {
const labels = this.selectedFieldLabels || [];
if (!labels.length) {
this.$message.warning(this.$t('autoPromotion.copySelectedFieldsEmpty'));
return;
}
const text = labels.join('; ');
const done = function () {
this.$message.success(this.$t('autoPromotion.copySelectedFieldsSuccess', { count: labels.length }));
}.bind(this);
const fail = function () {
this.$message.warning(this.$t('autoPromotion.factoryBatchImportCopyFail'));
}.bind(this);
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(text).then(done).catch(function () {
this.fallbackCopyFieldText(text, done, fail);
}.bind(this));
return;
}
this.fallbackCopyFieldText(text, done, fail);
},
fallbackCopyFieldText(text, onSuccess, onFail) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
} catch (e) {
onFail();
}
},
selectAllAccounts() {
this.selectedEmailIds = this.accountList.map(function (a) {
return String(a.j_email_id);
@@ -1375,6 +1467,9 @@
factoryZoneCountryIds: {
handler: function () {
this.syncActiveStep();
if (this.selectedJournalId) {
this.refreshExpertDbCount();
}
},
deep: true
},
@@ -1547,6 +1642,11 @@
cursor: pointer;
}
.status-confirm-box.has-selected-fields {
position: relative;
padding-top: 28px;
}
.status-confirm-box-radio {
cursor: default;
}
@@ -1561,7 +1661,23 @@
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.field-copy-icon {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
font-size: 16px;
color: #909399;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.2s;
}
.field-copy-icon:hover {
color: #409eff;
}
.field-tip {

View File

@@ -40,6 +40,14 @@
<el-tab-pane :label="$t('mailTemplate.styleTab')" name="style">
<div class="card-grid">
<div
v-if="styleOptional"
:class="['card-item', 'card-item-none', { active: !selectedHeaderId }]"
@click="selectedHeaderId = null"
>
<p class="card-title">{{ $t('mailTemplate.noStyle') }}</p>
<p class="card-desc">{{ $t('mailTemplate.noStyleDesc') }}</p>
</div>
<div
v-for="(item, index) in headerStyles"
:key="item.id"
@@ -111,6 +119,8 @@ export default {
// 可选:打开时回显的风格/模板
initialStyleId: { type: [String, Number], default: '' },
initialTemplateId: { type: [String, Number], default: '' },
// 为 true 时仅选模板即可应用Style 可选(可不选)
styleOptional: { type: Boolean, default: false },
// 可选:来源页面标识,用于回跳时决定 mailboxMouldDetail 返回到哪里
// 例如:'autoPromotion':返回自动化推广;默认返回邮件模版列表
returnSource: { type: String, default: '' }
@@ -163,24 +173,36 @@ export default {
},
computed: {
applyDisabled() {
return this.initLoading || this.contentLoading || !this.selectedHeaderId || !this.selectedContentId;
if (this.initLoading || this.contentLoading) return true;
if (!this.selectedContentId) return true;
if (!this.styleOptional && !this.selectedHeaderId) return true;
return false;
},
hasValidPreview() {
const header = this.headerStyles.find((h) => h.id === this.selectedHeaderId);
const content = this.contentTemplates.find((c) => c.id === this.selectedContentId);
if (!content) return false;
if (this.styleOptional && !this.selectedHeaderId) return true;
const header = this.headerStyles.find((h) => h.id === this.selectedHeaderId);
return !!(header && content);
},
// 【关键】拼接 HTML
combinedHtml() {
if (this.initLoading) return '';
if (!this.hasValidPreview) return '';
const header = this.headerStyles.find((h) => h.id === this.selectedHeaderId);
const content = this.contentTemplates.find((c) => c.id === this.selectedContentId);
if (!content) return '';
if (this.styleOptional && !this.selectedHeaderId) {
return content.bodyHtml || '';
}
const header = this.headerStyles.find((h) => h.id === this.selectedHeaderId);
return `${header.htmlHeader}${content.bodyHtml}${header.htmlFooter}`;
},
currentSelectionText() {
const h = this.headerStyles.find((h) => h.id === this.selectedHeaderId);
const c = this.contentTemplates.find((c) => c.id === this.selectedContentId);
if (this.styleOptional && !this.selectedHeaderId) {
return c ? c.name : '';
}
return `${c ? c.name : ''} + ${h ? h.name : ''}`;
},
currentJournalLabel() {
@@ -306,8 +328,8 @@ export default {
htmlFooter: item.footer_html || ''
}));
// 默认选中第一条
if (this.headerStyles.length && !this.selectedHeaderId) {
// 默认选中第一条Style 可选时不自动选中)
if (this.headerStyles.length && !this.selectedHeaderId && !this.styleOptional) {
this.selectedHeaderId = this.headerStyles[0].id;
}
} catch (e) {
@@ -382,13 +404,14 @@ export default {
},
submit() {
if (this.applyDisabled) return;
// 将拼接好的 HTML 及选择信息抛给父组件(兼容:父组件也可只用 html
const header = this.headerStyles.find((h) => h.id === this.selectedHeaderId);
const header = this.selectedHeaderId
? this.headerStyles.find((h) => h.id === this.selectedHeaderId)
: null;
const content = this.contentTemplates.find((c) => c.id === this.selectedContentId);
this.$emit('confirm', {
html: this.combinedHtml,
journal_id: this.selectedJournalId,
style_id: this.selectedHeaderId,
style_id: this.selectedHeaderId || '',
template_id: this.selectedContentId,
style: header || null,
template: content || null
@@ -494,6 +517,12 @@ export default {
background: #f5f7ff;
outline: 1px solid #6366f1;
}
.card-item-none {
min-height: 56px;
display: flex;
flex-direction: column;
justify-content: center;
}
.card-img {
width: 100%;
height: auto;

View File

@@ -14,7 +14,11 @@
<ul class="folder-list">
<li :class="{ active: currentFolder === 'inbox' }" @click="switchFolder('inbox')">
<i class="el-icon-message"></i> {{ $t('mailboxCollect.inboxTab') }}
<span class="badge" v-if="queryIn.num > 0">{{ queryIn.num }}</span>
<span class="badge" v-if="inboxCount > 0">{{ inboxCount }}</span>
</li>
<li :class="{ active: currentFolder === 'trash' }" @click="switchFolder('trash')">
<i class="el-icon-delete"></i> {{ $t('mailboxCollect.trashTab') }}
<span class="badge" v-if="trashCount > 0">{{ trashCount }}</span>
</li>
<!-- <li :class="{ active: currentFolder === 'sent' }" @click="switchFolder('sent')">
<i class="el-icon-position"></i><span style="font-size: 14px">{{ $t('mailboxCollect.outboxTab') }}</span>
@@ -194,6 +198,7 @@ import {
inboxTotalPages: 1,
inboxTotal: 0,
inboxLoadingMore: false,
inboxLoadingAll: false,
inboxLoading: false,
listWidth: 350,
minWidth: 260,
@@ -219,8 +224,22 @@ import {
},
components: { MailDetail },
computed: {
inboxList() {
return this.tableData_in.filter((item) => !this.isFilteredMail(item));
},
trashList() {
return this.tableData_in.filter((item) => this.isFilteredMail(item));
},
inboxCount() {
return this.inboxList.length;
},
trashCount() {
return this.trashList.length;
},
displayList() {
return this.currentFolder === 'inbox' ? this.tableData_in : this.tableData_out;
if (this.currentFolder === 'trash') return this.trashList;
if (this.currentFolder === 'sent') return this.tableData_out;
return this.inboxList;
},
selectedAccountEmail() {
return this.selectedAccount ? this.selectedAccount.smtp_user : '';
@@ -229,6 +248,16 @@ import {
created() {
this.loadJournals();
this.initAccountSelection();
this._skipNextActivatedSync = true;
},
activated() {
if (this._skipNextActivatedSync) {
this._skipNextActivatedSync = false;
return;
}
if (this.selectedAccount) {
this.autoSyncInbox();
}
},
beforeDestroy() {
this.stopInboxSse(true);
@@ -311,7 +340,7 @@ import {
: null;
if (res && Number(res.code) === 0 && email) {
this.selectedAccount = email;
this.fetchData();
this.autoSyncInbox();
this.startInboxSse();
return;
}
@@ -381,8 +410,8 @@ import {
localStorage.setItem('mailboxCollect_journal_id', String(row.journal_id));
}
// 不再写入路由 query直接拉取数据
this.fetchData();
// 切换账号后自动收信并刷新列表
this.autoSyncInbox();
this.startInboxSse();
},
handleAccountDialogBeforeClose(done) {
@@ -439,27 +468,114 @@ fetchLatestSingleMail(jEmailId, journalId) {
this.insertMailsFromBuffer();
});
},
isFilteredMail(item) {
if (!item) return false;
const email = String(item.email || item.from_email || '')
.toLowerCase()
.trim();
const fromName = String(item.from_name || '').trim();
const subject = String(item.subject || '');
const subjectLower = subject.toLowerCase();
const excerpt = this.stripHtml(item.content || '').toLowerCase();
const text = subjectLower + ' ' + excerpt;
if (email.includes('mailer-daemon')) return true;
if (/failure\s+notice/i.test(subjectLower)) return true;
if (email.includes('mailsupport.aliyun.com') || email.includes('no-reply@mailsupport.aliyun')) {
return true;
}
if (fromName.includes('阿里邮箱')) return true;
if (text.includes('的退信')) return true;
if (text.includes('退信') && (text.includes('aliyun') || text.includes('mailsupport'))) {
return true;
}
if (subject.includes('自动回复') || fromName.includes('自动回复')) return true;
if (/auto[\s-]?reply/i.test(subject)) return true;
if (/automatic\s+reply/i.test(subject)) return true;
if (/out\s+of\s+(the\s+)?office/i.test(subjectLower)) return true;
return false;
},
mapInboxRows(list) {
return (Array.isArray(list) ? list : []).map((item) => ({
id: item.inbox_id || item.id,
inbox_id: item.inbox_id || item.id,
email: item.from_email,
has_attachment: item.has_attachment,
from_name: item.from_name,
subject: item.subject,
email_date: item.email_date,
content: item.content_html || item.content_text || '',
is_read: item.is_read
}));
},
appendInboxRows(rows) {
const filteredRows = rows.filter(
(row) => !this.tableData_in.some((existing) => existing.id === row.id)
);
if (filteredRows.length) {
this.tableData_in = this.tableData_in.concat(filteredRows);
}
},
applyInboxPageMeta(data) {
this.inboxPage = data.page != null ? Number(data.page) : this.inboxPage;
this.inboxTotalPages = data.total_pages != null ? Number(data.total_pages) : 1;
this.inboxTotal = data.total != null ? Number(data.total) : this.tableData_in.length;
},
loadRemainingInboxPages() {
if (!this.selectedAccount || this.inboxLoadingAll) return;
if (this.inboxPage >= this.inboxTotalPages) return;
this.inboxLoadingAll = true;
const loadNext = (page) => {
if (page > this.inboxTotalPages) {
this.inboxLoadingAll = false;
this.inboxLoadingMore = false;
return Promise.resolve();
}
const params = {
j_email_id: this.selectedAccount.j_email_id,
journal_id: this.selectedAccount.journal_id,
page,
per_page: this.inboxPerPage
};
return this.$api
.post(API.getInboxList, params)
.then((res) => {
const data = res && res.data ? res.data : {};
const list = Array.isArray(data.list) ? data.list : Array.isArray(data) ? data : [];
this.appendInboxRows(this.mapInboxRows(list));
this.applyInboxPageMeta({ ...data, page });
return loadNext(page + 1);
})
.catch(() => {
this.inboxLoadingAll = false;
this.inboxLoadingMore = false;
});
};
this.inboxLoadingMore = true;
loadNext(this.inboxPage + 1);
},
// 拉取收件列表支持分页page=1 时替换列表page>1 时追加;接口返回 total、page、per_page、total_pages
fetchData(page) {
if (!this.selectedAccount) return;
const isFirstPage = page === 1 || page == null;
if (isFirstPage) {
this.inboxPage = 1;
// 首次拉取:显示加载中
this.inboxLoadingAll = false;
this.inboxLoading = true;
// 避免复用旧列表造成“先空后满”的闪烁
this.tableData_in = [];
}
const params = {
j_email_id: this.selectedAccount.j_email_id,
journal_id: this.selectedAccount.journal_id,
// keyword: this.searchKeyword || '',
page: isFirstPage ? 1 : page,
per_page: this.inboxPerPage
};
if (isFirstPage) {
// 第一页不显示 loadingMore仅翻页时显示
} else {
if (!isFirstPage) {
this.inboxLoadingMore = true;
}
this.$api
@@ -467,33 +583,18 @@ fetchLatestSingleMail(jEmailId, journalId) {
.then((res) => {
const data = res && res.data ? res.data : {};
const list = Array.isArray(data.list) ? data.list : Array.isArray(data) ? data : [];
const rows = list.map((item) => ({
id: item.inbox_id || item.id,
inbox_id: item.inbox_id || item.id,
email: item.from_email,
has_attachment: item.has_attachment,
from_name: item.from_name,
subject: item.subject,
email_date: item.email_date,
content: item.content_html || item.content_text || '',
is_read: item.is_read
}));
const rows = this.mapInboxRows(list);
if (isFirstPage) {
this.tableData_in = rows;
} else {
// 过滤掉已经在列表中存在的 id (可能来自 SSE 之前手动插入的)
const filteredRows = rows.filter(row =>
!this.tableData_in.some(existing => existing.id === row.id)
);
this.tableData_in = this.tableData_in.concat(filteredRows);
this.appendInboxRows(rows);
}
this.inboxPage = data.page != null ? Number(data.page) : isFirstPage ? 1 : this.inboxPage;
this.inboxTotalPages = data.total_pages != null ? Number(data.total_pages) : 1;
this.inboxTotal = data.total != null ? Number(data.total) : this.tableData_in.length;
this.queryIn.num = this.inboxTotal;
this.applyInboxPageMeta(data);
this.inboxLoadingMore = false;
if (isFirstPage) this.inboxLoading = false;
if (isFirstPage) {
this.inboxLoading = false;
this.loadRemainingInboxPages();
}
})
.catch(() => {
this.inboxLoadingMore = false;
@@ -502,7 +603,7 @@ fetchLatestSingleMail(jEmailId, journalId) {
},
switchFolder(f) {
this.currentFolder = f;
this.activeMailId = null;
this.closeDetail();
},
/** 接口 code 可能是数字 0 或字符串 "0" */
isEmailApiSuccess(res) {
@@ -651,29 +752,52 @@ fetchLatestSingleMail(jEmailId, journalId) {
.catch(() => {});
});
},
// 同步收件箱api/email_client/syncInbox 参数 j_email_id、journal_id均为 String完成后重新拉取 displayList
autoSyncInbox() {
return this.syncInboxAndRefresh({ showMessage: false, fetchOnFail: true });
},
handleSyncInbox() {
if (!this.selectedAccount) return;
this.syncInboxAndRefresh({ showMessage: true, fetchOnFail: false });
},
syncInboxAndRefresh(options = {}) {
const showMessage = options.showMessage !== false;
const fetchOnFail = options.fetchOnFail === true;
if (!this.selectedAccount) return Promise.resolve(false);
if (this.syncLoading) return Promise.resolve(false);
this.syncLoading = true;
const params = {
j_email_id: String(this.selectedAccount.j_email_id),
journal_id: String(this.selectedAccount.journal_id)
};
this.$api
return this.$api
.post(API.syncInbox, params)
.then((res) => {
this.syncLoading = false;
if (res && res.code === 0) {
this.newMailsBuffer=[]
this.$message.success(this.$t('mailboxCollect.syncSuccess'));
this.newMailsBuffer = [];
if (showMessage) {
this.$message.success(this.$t('mailboxCollect.syncSuccess'));
}
this.fetchData();
} else {
return true;
}
if (showMessage) {
this.$message.error((res && res.msg) || this.$t('mailboxCollect.syncFail'));
}
if (fetchOnFail) {
this.fetchData();
}
return false;
})
.catch(() => {
if (showMessage) {
this.$message.error(this.$t('mailboxCollect.syncFail'));
}
if (fetchOnFail) {
this.fetchData();
}
return false;
})
.finally(() => {
this.syncLoading = false;
this.$message.error(this.$t('mailboxCollect.syncFail'));
});
},
/**
@@ -738,7 +862,7 @@ fetchLatestSingleMail(jEmailId, journalId) {
},
onListScroll(e) {
const el = e.target;
if (!el || this.currentFolder !== 'inbox' || this.inboxLoadingMore) return;
if (!el || this.inboxLoadingMore || this.inboxLoadingAll) return;
if (this.inboxPage >= this.inboxTotalPages) return;
const threshold = 80;
if (el.scrollHeight - el.scrollTop - el.clientHeight <= threshold) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,851 @@
<template>
<div class="nursing-insight-page">
<div class="page-header">
<div>
<h2>{{ currentJournal.title }}</h2>
<p class="subtitle">关注中国专家待联系存量与库内规模</p>
</div>
<div class="header-actions">
<span class="journal-label">期刊</span>
<el-select
v-model="selectedJournal"
size="small"
style="width: 220px"
:disabled="loading"
@change="handleJournalChange"
>
<el-option
v-for="item in journalOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" size="small" icon="el-icon-refresh" :loading="loading" @click="loadAll">
刷新数据
</el-button>
</div>
</div>
<div v-if="loading" class="fetch-progress-panel">
<div class="fetch-progress-head">
<span class="fetch-progress-title">关键词加载进度</span>
<span class="fetch-progress-count">{{ fetchProgress.completed }} / {{ fetchProgress.total }}</span>
</div>
<el-progress
:percentage="fetchProgress.percent"
:stroke-width="18"
:text-inside="true"
/>
<div v-if="loadingProgress" class="fetch-progress-detail">{{ loadingProgress }}</div>
</div>
<el-card shadow="never" class="focus-panel">
<div class="focus-grid">
<div class="focus-item focus-item--primary">
<div class="focus-label">中国 · 待联系可发池</div>
<div class="focus-value focus-value--pending">{{ summary.chinaPendingCount }}</div>
<div class="focus-ratio">占中国专家 {{ pendingRatioText }}</div>
<div class="focus-hint">全量分页拉取后跨关键词去重</div>
</div>
<div class="focus-item">
<div class="focus-label">中国专家(去重)</div>
<div class="focus-value">{{ summary.chinaCount }}</div>
<div class="focus-hint">{{ activeKeywords.length }} 个关键词汇总</div>
</div>
</div>
<el-alert
class="focus-tip"
type="warning"
:closable="false"
show-icon
title="说明:中国专家通过 country=China 筛选;每关键词自动分页拉全量后再统计,汇总时对专家去重。数据量较大时刷新会较慢。"
/>
</el-card>
<el-row :gutter="16" class="chart-row">
<el-col :xs="24">
<el-card shadow="never" class="chart-card">
<div slot="header">中国专家 · 待联系 / 已联系</div>
<div class="pending-ratio-bar">
<div class="pending-ratio-head">
<span>待联系占比</span>
<span class="pending-ratio-value">
{{ summary.chinaPendingCount }} / {{ summary.chinaCount }}{{ pendingRatioText }}
</span>
</div>
<el-progress
v-if="!pendingRatioIsTiny"
:percentage="pendingRatio"
:stroke-width="18"
color="#f56c6c"
:format="() => pendingRatioText"
/>
<div v-else class="pending-ratio-tiny">
<span class="pending-ratio-tiny-value">{{ pendingRatioText }}</span>
<span class="pending-ratio-tiny-hint">占比不足 1%进度条难以直观展示请以上方数字为准</span>
</div>
</div>
<div ref="contactPieChart" class="chart-box"></div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
const NURSING_KEYWORDS = [
'Infection Control Nursing',
'Infectious Disease Nursing',
'Pandemic Preparedness',
'Community Health Nursing',
'Home Health Nursing',
'Gerontological Nursing',
'Oncology Nursing',
'Cardiovascular Nursing',
'Diabetes Nursing',
'Hematology Nursing',
'Hemodialysis Nursing',
'Nephrology Nursing',
'Critical Care Nursing',
'Perinatal Nursing',
'Midwifery Care',
'Neonatal Intensive Care',
'Pediatric Nursing',
'Infertility Nursing',
'Reproductive Nursing',
'Chronic Disease Management',
'Transitional Care',
'Patient Centered Care',
'Nursing Leadership',
'Nursing Administration',
'Nursing Informatics',
'Artificial Intelligence in Nursing',
'Psychosocial Nursing',
'Hospice and Palliative Care',
'Telenursing',
'mHealth in Nursing',
'Robotic Nursing Care',
'Precision Nursing',
'Qualitative Nursing Research',
'Mixed Methods Nursing',
'Psychometric Testing',
'Health Literacy in Nursing',
'Adherence to Treatment',
'Virtual Reality in Nursing Education'
];
const CANCER_KEYWORDS = [
'Bladder cancer',
'Thyroid cancer',
'Renal cell carcinoma',
'Kidney cancer',
'Endometrial cancer',
'Oral cancer',
'Nasopharyngeal carcinoma',
'Multiple myeloma',
'Brain tumor',
'Bone tumor',
'Tumor',
'Tumour',
'Neoplasm',
'Cancer',
'Malignancy',
'Carcinoma',
'Sarcoma',
'Lymphoma',
'Leukemia',
'Melanoma',
'Glioma',
'Lung cancer',
'Breast cancer',
'Gastric cancer',
'Hepatocellular carcinoma',
'Colorectal cancer',
'Pancreatic cancer',
'Ovarian cancer',
'Prostate cancer',
'Esophageal cancer',
'Cervical cancer'
];
const BMEC_KEYWORDS = [
'Biomechanics',
'Nanomedicine',
'Nanodrug delivery',
'Wound healing engineering',
'Metal-organic frameworks',
'Cancer therapy',
'3D bioprinting',
'Biomaterials',
'Tissue regeneration',
'Stem cells',
'Regenerative medicine',
'Cancer nanotherapy',
'Targeted drug delivery',
'Bioactive materials',
'Smart polymers',
'Biocompatible materials',
'Nanostructures',
'Drug-loaded nanoparticles',
'Nanocarriers',
'Immunotherapy',
'Molecular imaging',
'Precision medicine',
'Cancer immunotherapy',
'Biomedical engineering',
'Advanced drug delivery systems',
'Bioprinting technologies',
'Tissue engineering',
'Bioinks',
'Nanoparticles',
'Gene therapy',
'Biodegradable polymers',
'Surface modification',
'Biofabrication',
'Biosensors'
];
const MDM_KEYWORDS = [
'Artificial Intelligence',
'Machine Learning',
'Deep Learning',
'Medical Data Mining',
'Biomedical Informatics',
'Clinical Decision Support',
'Electronic Health Records',
'Digital Health',
'Medical Imaging',
'Radiomics',
'Predictive Modeling',
'Explainable AI',
'Natural Language Processing',
'Precision Medicine',
'Bioinformatics',
'Multi-omics',
'Drug Discovery',
'Healthcare Analytics',
'Federated Learning',
'Clinical Validation'
];
const LR_KEYWORDS = [
'Molecular Biology',
'Gene Expression',
'Cell Signaling',
'Apoptosis',
'Neurodegeneration',
'Immune Response',
'Inflammation',
'Aging Mechanisms',
'Oxidative Stress',
'Animal Behavior',
'Plant Development',
'Microbial Communities',
'Antibiotic Resistance',
'Synthetic Biology',
'Evolutionary Biology'
];
const JOURNAL_CONFIGS = {
cancer: {
title: 'Cancer Communications · Expert Insight',
label: 'Cancer Communications',
keywords: CANCER_KEYWORDS
},
bmec: {
title: 'BMEC · Expert Insight',
label: 'BMEC',
keywords: BMEC_KEYWORDS
},
mdm: {
title: 'MDM · Expert Insight',
label: 'MDM',
keywords: MDM_KEYWORDS
},
lr: {
title: 'LR · Expert Insight',
label: 'LR',
keywords: LR_KEYWORDS
},
nursing: {
title: 'Nursing Communications · Expert Insight',
label: 'Nursing Communications',
keywords: NURSING_KEYWORDS
}
};
const DEFAULT_JOURNAL = 'cancer';
const JOURNAL_STORAGE_KEY = 'nc_expert_insight_journal';
const CHINA_COUNTRY = 'China';
const LIST_PAGE_SIZE = 500;
export default {
name: 'NursingExpertInsight',
data() {
const savedJournal = localStorage.getItem(JOURNAL_STORAGE_KEY);
const selectedJournal = JOURNAL_CONFIGS[savedJournal] ? savedJournal : DEFAULT_JOURNAL;
return {
loading: false,
loadingProgress: '',
selectedJournal,
fetchProgress: {
completed: 0,
total: JOURNAL_CONFIGS[selectedJournal].keywords.length,
percent: 0
},
summary: {
chinaCount: 0,
chinaPendingCount: 0,
chinaContactedCount: 0
},
chartInstances: []
};
},
computed: {
journalOptions() {
return Object.keys(JOURNAL_CONFIGS).map((key) => ({
value: key,
label: JOURNAL_CONFIGS[key].label
}));
},
currentJournal() {
return JOURNAL_CONFIGS[this.selectedJournal] || JOURNAL_CONFIGS[DEFAULT_JOURNAL];
},
activeKeywords() {
return this.currentJournal.keywords || [];
},
pendingRatio() {
const total = Number(this.summary.chinaCount || 0);
if (!total) return 0;
return Math.min(100, (this.summary.chinaPendingCount / total) * 100);
},
pendingRatioText() {
return this.formatRatioPercent(this.summary.chinaPendingCount, this.summary.chinaCount);
},
pendingRatioIsTiny() {
return this.pendingRatio > 0 && this.pendingRatio < 1;
}
},
mounted() {
this.loadAll();
window.addEventListener('resize', this.handleResize);
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
this.disposeCharts();
},
methods: {
formatRatioPercent(part, total) {
const numerator = Number(part || 0);
const denominator = Number(total || 0);
if (!denominator) return '—';
const ratio = (numerator / denominator) * 100;
if (ratio > 0 && ratio < 1) return `${ratio.toFixed(2)}%`;
if (ratio < 10) return `${ratio.toFixed(1)}%`;
return `${Math.round(ratio)}%`;
},
isPendingContact(row) {
const text = String((row && row.state_text) || '').trim();
if (!text) return true;
if (text.includes('待联系')) return true;
if (/pending|to contact|not contacted/i.test(text)) return true;
if (row.state != null && String(row.state) === '0') return true;
return false;
},
async runPool(items, limit, iterator) {
const results = new Array(items.length);
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const current = cursor;
cursor += 1;
results[current] = await iterator(items[current], current);
}
});
await Promise.all(workers);
return results;
},
async fetchChinaExpertList(field, onProgress) {
const baseParams = {
major_id: null,
keyword: '',
field,
country: CHINA_COUNTRY,
pageSize: LIST_PAGE_SIZE
};
const firstRes = await this.$api.post('api/expert_manage/getList', {
...baseParams,
pageIndex: 1
});
const firstList = (firstRes && firstRes.data && firstRes.data.list) || [];
const total = Number((firstRes && firstRes.data && firstRes.data.total) || firstList.length || 0);
let totalPages = Number(
(firstRes && firstRes.data && (firstRes.data.totalPages || firstRes.data.total_pages)) || 0
);
if (!totalPages && total > 0) {
totalPages = Math.ceil(total / LIST_PAGE_SIZE);
}
totalPages = Math.max(1, totalPages);
if (totalPages <= 1) {
return { list: firstList, total };
}
const restPages = [];
for (let pageIndex = 2; pageIndex <= totalPages; pageIndex += 1) {
restPages.push(pageIndex);
}
if (onProgress) {
onProgress(`${totalPages} 页,已加载 1/${totalPages}`);
}
let loadedPages = 1;
const restLists = await this.runPool(restPages, 4, async (pageIndex) => {
const res = await this.$api.post('api/expert_manage/getList', {
...baseParams,
pageIndex
});
loadedPages += 1;
if (onProgress) {
onProgress(`${totalPages} 页,已加载 ${loadedPages}/${totalPages}`);
}
return (res && res.data && res.data.list) || [];
});
const list = firstList.concat(...restLists);
return { list, total };
},
async fetchKeywordInsight(field, onProgress) {
const listPayload = await this.fetchChinaExpertList(field, onProgress);
const list = listPayload.list || [];
const total = Number(listPayload.total || list.length || 0);
return { field, list, total };
},
getExpertKey(row) {
return String((row && (row.expert_id || row.id || row.uid || row.email)) || '').trim();
},
resetFetchProgress() {
this.fetchProgress = {
completed: 0,
total: this.activeKeywords.length,
percent: 0
};
this.loadingProgress = '';
},
handleJournalChange(journal) {
localStorage.setItem(JOURNAL_STORAGE_KEY, journal);
if (!this.loading) this.loadAll();
},
markKeywordDone(field) {
const completed = Math.min(this.fetchProgress.completed + 1, this.fetchProgress.total);
const percent = this.fetchProgress.total
? Math.min(100, Math.round((completed / this.fetchProgress.total) * 100))
: 0;
this.fetchProgress = {
...this.fetchProgress,
completed,
percent
};
this.loadingProgress = completed >= this.fetchProgress.total ? '正在汇总统计...' : `已完成:${field}`;
},
async loadAll() {
this.loading = true;
this.resetFetchProgress();
this.loadingProgress = '正在按关键词并行查询...';
try {
const expertMap = new Map();
const keywords = this.activeKeywords;
const results = await this.runPool(keywords, 8, async (field) => {
this.loadingProgress = `正在查询:${field}`;
try {
const result = await this.fetchKeywordInsight(field, (pageText) => {
this.loadingProgress = `${field} · ${pageText}`;
});
this.markKeywordDone(field);
return result;
} catch (e) {
this.markKeywordDone(field);
throw e;
}
});
results.forEach(({ list }) => {
list.forEach((row) => {
const key = this.getExpertKey(row);
if (key && !expertMap.has(key)) expertMap.set(key, row);
});
});
this.buildSummary(expertMap);
this.$nextTick(() => this.renderCharts());
} catch (e) {
this.$message.error('加载失败,请稍后重试');
} finally {
this.loading = false;
this.loadingProgress = '';
this.resetFetchProgress();
}
},
buildSummary(expertMap) {
const summary = {
chinaCount: 0,
chinaPendingCount: 0,
chinaContactedCount: 0
};
expertMap.forEach((row) => {
summary.chinaCount += 1;
if (this.isPendingContact(row)) {
summary.chinaPendingCount += 1;
} else {
summary.chinaContactedCount += 1;
}
});
this.summary = summary;
},
disposeCharts() {
this.chartInstances.forEach((chart) => {
if (chart) chart.dispose();
});
this.chartInstances = [];
},
initChart(refName) {
const el = this.$refs[refName];
if (!el) return null;
const chart = this.$echarts.init(el);
this.chartInstances.push(chart);
return chart;
},
renderCharts() {
this.disposeCharts();
this.renderContactPieChart();
},
renderContactPieChart() {
const chart = this.initChart('contactPieChart');
if (!chart) return;
const pending = this.summary.chinaPendingCount;
const contacted = this.summary.chinaContactedCount;
const total = this.summary.chinaCount;
const pendingPercent = this.formatRatioPercent(pending, total);
chart.setOption({
tooltip: {
trigger: 'item',
formatter: (params) => {
const percent =
params.name === '待联系'
? pendingPercent
: this.formatRatioPercent(contacted, total);
return `${params.name}: ${params.value}${percent}`;
}
},
legend: {
bottom: 0,
formatter: (name) => {
if (name === '待联系') return `待联系 ${pending}${pendingPercent}`;
return `已联系 ${contacted}${this.formatRatioPercent(contacted, total)}`;
}
},
graphic: [
{
type: 'group',
left: 'center',
top: 'center',
children: [
{
type: 'text',
left: 'center',
top: -26,
style: {
text: String(pending),
fill: '#f56c6c',
fontSize: 40,
fontWeight: 'bold',
textAlign: 'center'
}
},
{
type: 'text',
left: 'center',
top: 18,
style: {
text: '待联系',
fill: '#606266',
fontSize: 14,
textAlign: 'center'
}
},
{
type: 'text',
left: 'center',
top: 40,
style: {
text: pendingPercent,
fill: '#909399',
fontSize: 13,
textAlign: 'center'
}
}
]
}
],
series: [
{
type: 'pie',
radius: ['46%', '72%'],
center: ['50%', '46%'],
selectedMode: 'single',
selectedOffset: 10,
minShowLabelAngle: 0,
label: {
show: true,
formatter: (params) => {
if (params.name === '待联系') {
return `{pending|${params.name}}\n{pending|${params.value}${pendingPercent}}`;
}
return `${params.name}\n${params.value}${this.formatRatioPercent(params.value, total)}`;
},
rich: {
pending: {
color: '#f56c6c',
fontWeight: 'bold',
fontSize: 14,
lineHeight: 20
}
}
},
labelLine: {
show: true,
length: 18,
length2: 12
},
emphasis: {
scale: true,
scaleSize: 12
},
data: [
{
name: '待联系',
value: pending,
selected: true,
itemStyle: {
color: '#f56c6c',
borderColor: '#fff',
borderWidth: 3,
shadowBlur: 12,
shadowColor: 'rgba(245, 108, 108, 0.5)'
}
},
{
name: '已联系',
value: contacted,
itemStyle: { color: '#dcdfe6' }
}
]
}
]
});
},
handleResize() {
this.chartInstances.forEach((chart) => chart && chart.resize());
}
}
};
</script>
<style scoped>
.nursing-insight-page {
padding: 16px 20px 32px;
background: #f5f7fa;
min-height: calc(100vh - 90px);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.page-header h2 {
margin: 0 0 6px;
font-size: 22px;
color: #303133;
}
.subtitle {
margin: 0;
font-size: 13px;
color: #909399;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.journal-label {
font-size: 13px;
color: #606266;
white-space: nowrap;
}
.fetch-progress-panel {
margin-bottom: 16px;
padding: 14px 16px 12px;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.fetch-progress-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.fetch-progress-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.fetch-progress-count {
font-size: 13px;
color: #606266;
}
.fetch-progress-detail {
margin-top: 8px;
font-size: 12px;
color: #909399;
line-height: 1.5;
word-break: break-word;
}
.focus-panel {
margin-bottom: 16px;
}
.focus-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.focus-item {
background: #f8fafc;
border: 1px solid #ebeef5;
border-radius: 10px;
padding: 14px 16px;
}
.focus-item--primary {
background: #fff5f5;
border-color: #f56c6c;
box-shadow: 0 0 0 1px rgba(245, 108, 108, 0.08);
}
.focus-label {
font-size: 13px;
color: #909399;
margin-bottom: 8px;
}
.focus-value {
font-size: 30px;
font-weight: 700;
color: #303133;
line-height: 1.1;
}
.focus-value--pending {
font-size: 42px;
color: #f56c6c;
}
.focus-ratio {
margin-top: 6px;
font-size: 15px;
font-weight: 600;
color: #f56c6c;
}
.focus-hint {
margin-top: 8px;
font-size: 12px;
color: #a8abb2;
line-height: 1.5;
}
.focus-tip {
margin-top: 4px;
}
.chart-row {
margin-bottom: 16px;
}
.chart-card {
margin-bottom: 12px;
}
.pending-ratio-bar {
padding: 0 4px 16px;
}
.pending-ratio-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
font-size: 13px;
color: #606266;
}
.pending-ratio-value {
font-size: 14px;
font-weight: 600;
color: #f56c6c;
}
.pending-ratio-tiny {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #fef0f0;
border: 1px solid #fde2e2;
border-radius: 6px;
}
.pending-ratio-tiny-value {
font-size: 20px;
font-weight: 700;
color: #f56c6c;
white-space: nowrap;
}
.pending-ratio-tiny-hint {
font-size: 12px;
color: #909399;
line-height: 1.5;
}
.chart-box {
height: 340px;
}
@media (max-width: 1200px) {
.focus-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -1104,6 +1104,14 @@ export default new Router({
title: 'Crawl Task Monitor'
}
},
{
path: '/ncNursingExpertInsightPrivate',
component: () => import('../components/page/nursingExpertInsight'),
meta: {
title: 'Nursing Expert Insight'
},
hidden: true
},
{
path: '/scholarCrawlersKeywords',
component: () => import('../components/page/scholarCrawlersKeywords'),

View File

@@ -74,3 +74,36 @@ export function applyMailTemplateVariableMocks(html, options) {
return full;
});
}
/** 复审通知邮件模板变量标题、SN、期刊名称、期刊官网 */
export function applyReviewNotifyTemplateVariables(html, options) {
const opts = options || {};
if (!html) return '';
const title = opts.title != null ? String(opts.title) : '';
const acceptSn =
opts.accept_sn != null && opts.accept_sn !== ''
? String(opts.accept_sn)
: opts.sn != null
? String(opts.sn)
: '';
const journalName = opts.journal_name != null ? String(opts.journal_name) : '';
const journalUrl = opts.journal_url != null ? String(opts.journal_url) : '';
const map = {
title,
article_title: title,
manuscript_title: title,
accept_sn: acceptSn,
article_sn: acceptSn,
sn: acceptSn,
journal_name: journalName,
journal_url: journalUrl
};
return String(html).replace(/\{\{\s*([^}]+?)\s*\}\}/g, (full, rawKey) => {
const key = String(rawKey).trim();
if (!key) return full;
if (Object.prototype.hasOwnProperty.call(map, key)) {
return map[key];
}
return full;
});
}