This commit is contained in:
2026-06-09 14:31:32 +08:00
parent e57e06be95
commit b56d5aa105
9 changed files with 2683 additions and 72 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',
@@ -956,6 +957,54 @@ const en = {
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',
@@ -1348,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',
@@ -1356,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',
@@ -1443,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.',

View File

@@ -655,8 +655,9 @@ const zh = {
inboxTab: '收件箱',
outboxTab: '发件箱',
draftsTab: '草稿箱',
deletedTab: '已删除',
spamTab: '垃圾邮件',
deletedTab: '已删除',
trashTab: '已过滤的邮件',
spamTab: '垃圾邮件',
searchPlaceholder: '请输入姓名或邮箱',
searchBtn: '搜索',
syncBtn: '同步远程邮箱',
@@ -941,6 +942,54 @@ const zh = {
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: '用户指南',
@@ -1328,7 +1377,7 @@ const zh = {
clearAll: '取消全选',
selectPromotionFieldsTip: '可多选;未选择则不限制推广领域。',
selectPromotionCountryTip: '可多选;未选择则不限制国家。与领域接口一致,后续可对接独立国家数据。',
fieldSearchPlaceholder: '搜索推广领域',
fieldSearchPlaceholder: '单个词模糊搜索;逗号/换行多个名称需完全匹配',
countrySearchPlaceholder: '搜索国家',
countryQuickZone1: '1区',
countryQuickZone2: '2区',
@@ -1336,6 +1385,9 @@ const zh = {
countryQuickChina: 'China',
countryQuickIndia: 'India',
noFieldMatch: '没有匹配的领域',
copySelectedFields: '复制已选领域(分号分隔)',
copySelectedFieldsSuccess: '已复制 {count} 个领域',
copySelectedFieldsEmpty: '暂无已选领域',
noCountryMatch: '没有匹配的国家',
confirm: '确定',
fieldsSaved: '推广领域已保存',
@@ -1423,10 +1475,10 @@ const zh = {
factoryStepNav2Desc: '选好邮件模板和样式。',
factoryStepNav3Title: '发送与场景',
factoryStepNav3Desc: '选账号,填发送数量和目标人类型。',
factoryStepNav4Title: '推广领域',
factoryStepNav4Desc: '至少选择一个推广领域。',
factoryStepNav5Title: '国家',
factoryStepNav5Desc: '至少选择一个国家或分区。',
factoryStepNav4Title: '国家',
factoryStepNav4Desc: '至少选择一个国家或分区。',
factoryStepNav5Title: '推广领域',
factoryStepNav5Desc: '至少选择一个推广领域。',
factoryStepNav6Title: '确认并开启',
factoryStepNav6Desc: '选择仅保存或次日自动开启。',
factoryPromotionFieldsBlockTip: '请打开「选择领域」,在列表中至少勾选一项;不得留空提交。',

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

@@ -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

@@ -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

@@ -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 : '';
@@ -449,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
@@ -477,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;
@@ -512,7 +603,7 @@ fetchLatestSingleMail(jEmailId, journalId) {
},
switchFolder(f) {
this.currentFolder = f;
this.activeMailId = null;
this.closeDetail();
},
/** 接口 code 可能是数字 0 或字符串 "0" */
isEmailApiSuccess(res) {
@@ -771,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'),