提交
This commit is contained in:
@@ -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.',
|
||||
|
||||
@@ -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: '请打开「选择领域」,在列表中至少勾选一项;不得留空提交。',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') }} :</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 || '';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
851
src/components/page/nursingExpertInsight.vue
Normal file
851
src/components/page/nursingExpertInsight.vue
Normal 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>
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user