This commit is contained in:
2026-05-21 17:42:08 +08:00
parent 7868c25643
commit 589c28acab
10 changed files with 3272 additions and 114 deletions

View File

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

View File

@@ -874,6 +874,30 @@ const en = {
taskStoppedMsg: 'Crawl has been paused.',
runOnceQueued: 'A one-off crawl has been queued.',
},
articleDetailEmail: {
pageTitle: 'Manuscript email detail',
sender: 'Sender :',
content: 'Content :',
attachment: 'Attachment :',
uploadTip: 'Only word file can be uploaded(.docx)',
clickUpload: 'Click Upload',
sendMail: 'Send mail',
templateSelection: 'Template selection',
letterLang: 'Email language',
langZh: '中文',
langEn: 'English',
greeting: 'Dear Dr. {name},',
signOff: 'Yours Sincerely',
officeLine: '{journal} | Editorial Office | New Zealand',
telephone: 'Telephone: +64 02108293806',
emailLine: 'Email: {email}',
subscribeLine: 'Subscribe to receive Latest Research and News from {journal}',
contentRequired: 'Please enter the message content!',
sendSuccess: 'Sent successfully!',
uploadError: 'upload error',
serviceError: 'service error: {msg}',
uploadLimit: 'The maximum number of uploaded files has been exceeded!'
},
mailboxSend: {
title: 'Write mail',
to: 'To:',
@@ -1502,7 +1526,55 @@ const en = {
previewWithVariablesHint: 'The expert data is an example, used for variable spelling check only.',
close: 'Close',
placeholder: 'Please enter email content'
}
},
refRelevance: {
filterAll: 'All ({count})',
filterModify: 'Needs revision ({count})',
columnTitle: 'AI citation relevance review',
uncitedTag: 'Not cited in text',
uncitedDesc: 'This reference appears in the list but no matching in-text citation was found.',
uncitedTip: 'Remove it from the reference list or add an in-text citation in the manuscript.',
citationN: 'Cite {n}',
relevancePct: 'Relevance {score}%',
relevancePctShort: '{score}%',
viewAiAnalysis: 'View AI analysis →',
viewAiAnalysisShort: 'AI →',
dialogTitle: 'Citation relevance details',
prevRef: 'Previous reference',
nextRef: 'Next reference',
manuscript: 'Manuscript',
excerpt: '(excerpt)',
citeListTitle: 'This reference · all in-text citations',
citeListTotal: '{n} citation(s)',
current: 'Current',
close: 'Close',
emptyModify: 'No references need revision',
locatorMark: 'Highlighted in-text marker {mark}',
locatorMarkRef: 'Highlighted marker {mark} (reference [{ref}])',
locatorMarkOnly: 'Highlighted citation marker {mark}',
summaryModify: '{total} cite(s) · {modify} revise',
summaryOk: '{total} cite(s) · OK',
insightTitle: 'Context review',
insightTitleRef: 'Context review [{ref}]',
recommendAction: 'Editorial note',
recommendRevise: 'Suggested revision',
recommendKeep: 'Keep as is',
recommendTextRevise: '{brief} Review the passage at left before revising or removing this citation.',
recommendTextReviseDefault: 'Review the passage at left before revising or removing this citation.',
recommendTextKeep: 'This citation strongly supports the argument; no change recommended.',
revisionLabel: 'Revision',
citeTag: 'Citation {idx}',
citeTagTotal: 'Citation {idx}/{total}',
briefSuggestRevise: 'Revise',
briefOk: 'OK',
briefAppropriate: 'OK',
briefKeep: 'Keep',
briefTrim: 'Revise',
briefWeakEvidence: 'Peripheral',
briefNeedDiff: 'Clarify',
briefDelete: 'Drop',
briefYearMismatch: 'Year?'
}

View File

@@ -619,6 +619,30 @@ const zh = {
saveFail: '保存失败',
saveSuccessMock: '模板已保存(模拟)',
},
articleDetailEmail: {
pageTitle: '稿件邮件详情',
sender: '发件人:',
content: '正文:',
attachment: '附件:',
uploadTip: '仅支持上传 Word 文件(.docx',
clickUpload: '点击上传',
sendMail: '发送邮件',
templateSelection: '模板选择',
letterLang: '邮件语言',
langZh: '中文',
langEn: 'English',
greeting: '尊敬的 Dr. {name}',
signOff: '此致敬礼',
officeLine: '{journal} | 编辑部 | 新西兰',
telephone: '电话:+64 02108293806',
emailLine: '邮箱:{email}',
subscribeLine: '订阅 {journal} 最新研究与资讯',
contentRequired: '请输入邮件正文内容!',
sendSuccess: '发送成功!',
uploadError: '上传失败',
serviceError: '服务错误:{msg}',
uploadLimit: '已超过可上传文件数量上限!'
},
mailboxCollect: {
inboxTab: '收件箱',
outboxTab: '发件箱',
@@ -1483,7 +1507,55 @@ const zh = {
previewWithVariablesHint: '专家数据仅为示例,仅用于变量拼写检查。',
close: '关闭',
placeholder: '请输入邮件内容'
}
},
refRelevance: {
filterAll: '全部({count}',
filterModify: '需修改({count}',
columnTitle: 'AI 智能评定相关性建议',
uncitedTag: '正文未引用',
uncitedDesc: '该文献已列入参考文献列表,但正文中未找到对应的引用上标。',
uncitedTip: '建议:从参考文献列表中删除,或在正文中补充引用。',
citationN: '第{n}处',
relevancePct: '相关性 {score}%',
relevancePctShort: '{score}%',
viewAiAnalysis: '查看 AI 分析 →',
viewAiAnalysisShort: 'AI分析→',
dialogTitle: '相关性检测详情',
prevRef: '上一篇参考文献',
nextRef: '下一篇参考文献',
manuscript: '稿件',
excerpt: '(摘录)',
citeListTitle: '本参考文献 · 全部引用处',
citeListTotal: '共 {n} 处',
current: '当前',
close: '关闭',
emptyModify: '暂无需要修改的参考文献',
locatorMark: '高亮段落中的引用标记 {mark}',
locatorMarkRef: '高亮标记 {mark}(对应参考文献 [{ref}]',
locatorMarkOnly: '高亮引用标记 {mark}',
summaryModify: '{total}处引用·{modify}处待改',
summaryOk: '{total}处引用·均可保留',
insightTitle: '语境深度批核',
insightTitleRef: '语境深度批核 「[{ref}]」',
recommendAction: '操作建议',
recommendRevise: '建议修订引用',
recommendKeep: '建议完美保留',
recommendTextRevise: '{brief}。请根据左侧正文核对后决定是否删改或改写该处引用。',
recommendTextReviseDefault: '请根据左侧正文核对后决定是否删改或改写该处引用。',
recommendTextKeep: '与段落论述高度一致,有力支撑核心论点,建议不作修改保留该引用。',
revisionLabel: '修订建议',
citeTag: '引用 {idx}',
citeTagTotal: '引用 {idx}/{total}',
briefSuggestRevise: '待改',
briefOk: '恰当',
briefAppropriate: '恰当',
briefKeep: '保留',
briefTrim: '宜改写',
briefWeakEvidence: '非主依据',
briefNeedDiff: '需说明差异',
briefDelete: '删除',
briefYearMismatch: '年份不符'
}
}

View File

@@ -999,6 +999,10 @@
</span>
<span class="plagiarism-sim-date">{{ formatPlagiarismDate(row) }}</span>
<span class="plagiarism-sim-state" :class="getPlagiarismStateClass(row)">
<i
v-if="isPlagiarismStateLoading(row)"
class="el-icon-loading plagiarism-state-loading-icon"
></i>
{{ formatPlagiarismStateLabel(row) }}
</span>
<span class="plagiarism-sim-report">
@@ -1012,7 +1016,11 @@
{{ $t('articleListEditor.plagiarismPreviewPdf') }}
<i class="el-icon-link"></i>
</a>
<span v-else class="plagiarism-check-no-pdf">{{ $t('articleListEditor.plagiarismNoPdfLink') }}</span>
<span
v-else-if="!isPlagiarismStateLoading(row)"
class="plagiarism-check-no-pdf"
>{{ $t('articleListEditor.plagiarismNoPdfLink') }}</span
>
</span>
</div>
</div>
@@ -2973,6 +2981,15 @@ export default {
closeResubmit() {
(this.resubmitVisible = false), this.$refs['resubmitJournal'].resetFields();
},
/** 查重列表静默轮询间隔(毫秒) */
getPlagiarismPollIntervalMs() {
return 60 * 1000;
},
/** 无「进行中」记录时视为已结束停止轮询state 1=进行中2/3=完成4/5=失败) */
shouldStopPlagiarismPolling(list) {
if (!Array.isArray(list) || !list.length) return false;
return !list.some((row) => Number(row && row.state) === 1);
},
startPlagiarismPolling(resetList) {
this.stopPlagiarismPolling();
if (resetList !== false) {
@@ -2982,7 +2999,7 @@ export default {
this.fetchPlagiarismList(false);
this.plagiarismPollTimer = setInterval(() => {
this.fetchPlagiarismList(false);
}, 3 * 60 * 1000);
}, this.getPlagiarismPollIntervalMs());
},
stopPlagiarismPolling() {
if (this.plagiarismPollTimer) {
@@ -2998,10 +3015,11 @@ export default {
}
this.plagiarismSubmitLoading = true;
try {
const res = await this.$api.post('api/Plagiarism/submit', { article_id: articleId });
const res = await this.$api.post('api/Plagiarism/submit', { article_id: articleId ,type:'body_only'});
if (res && Number(res.code) === 0) {
this.$message.success((res && res.msg) || this.$t('articleListEditor.plagiarismChecking'));
await this.fetchPlagiarismList(true);
this.startPlagiarismPolling(false);
} else {
this.$message.error((res && res.msg) || this.$t('articleListEditor.plagiarismCheckFailed'));
}
@@ -3026,6 +3044,9 @@ export default {
const payload = res.data || {};
const list = Array.isArray(payload.list) ? payload.list : Array.isArray(payload) ? payload : [];
this.plagiarismList = list;
if (!manual && this.shouldStopPlagiarismPolling(list)) {
this.stopPlagiarismPolling();
}
} else if (manual) {
this.$message.error((res && res.msg) || this.$t('articleListEditor.plagiarismStatusFailed'));
}
@@ -3058,6 +3079,13 @@ export default {
if (s === 4 || s === 5) return 'state-fail';
return '';
},
/** 上传中 / 比对中等进行中状态显示加载图标 */
isPlagiarismStateLoading(row) {
if (!row || typeof row !== 'object') return false;
if (Number(row.state) === 1) return true;
const label = String(row.state_label || row.stateLabel || '').trim();
return /上传中|比对中/i.test(label);
},
getPlagiarismSimilarityScore(row) {
if (!row || typeof row !== 'object') return null;
const raw = row.similarity_score != null ? row.similarity_score : row.similarity;
@@ -3338,13 +3366,24 @@ export default {
min-width: 48px;
color: #909399;
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plagiarism-state-loading-icon {
flex-shrink: 0;
font-size: 14px;
}
.plagiarism-sim-state.state-uploading {
color: #e6a23c;
}
.plagiarism-sim-state.state-uploading .plagiarism-state-loading-icon {
color: #e6a23c;
}
.plagiarism-sim-state.state-done {
color: #67c23a;
}

View File

@@ -3,7 +3,7 @@
<div class="crumbs">
<el-breadcrumb separator="/">
<el-breadcrumb-item>
<i class="el-icon-lx-calendar"></i> Manuscript email detail
<i class="el-icon-lx-calendar"></i> {{ $t('articleDetailEmail.pageTitle') }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
@@ -11,39 +11,46 @@
<el-row :gutter="30">
<el-col :span="16">
<el-form :model="EmailData" label-width="110px" class="Email_Data">
<el-form-item label="Sender :">
<el-form-item :label="$t('articleDetailEmail.sender')">
<el-input v-model="AuthorMes.email"></el-input>
</el-form-item>
<el-form-item label="Content :">
<el-form-item :label="$t('articleDetailEmail.content')">
<div class="email-letter-lang">
<span class="email-letter-lang-label">{{ $t('articleDetailEmail.letterLang') }}</span>
<el-radio-group v-model="emailLetterLang" size="small" @change="applyEmailLetterBlocks">
<el-radio-button label="zh">{{ $t('articleDetailEmail.langZh') }}</el-radio-button>
<el-radio-button label="en">{{ $t('articleDetailEmail.langEn') }}</el-radio-button>
</el-radio-group>
</div>
<p v-html="EmailData.topmail"></p>
<p v-html="EmailData.articleInfor"></p>
<el-input type="textarea" rows="9" v-model="EmailData.substance" @input="btn_ft=false">
</el-input>
<p v-html="EmailData.bottomail"></p>
</el-form-item>
<el-form-item label="Attachment :">
<el-form-item :label="$t('articleDetailEmail.attachment')">
<el-upload style="display: inline-block;" class="upload-demo" :action="upload_enclosure"
accept=".docx," name="enclosure" :before-upload="beforeupload_enclosure"
:on-error="uperr_enclosure" :on-success="upSuccess_enclosure" :limit="1"
:on-exceed="alertlimit" :on-remove="removefilenclosure">
<div class="el-upload__text" style="padding:0 5px;">
<em>Click Upload</em>
<em>{{ $t('articleDetailEmail.clickUpload') }}</em>
</div>
<div class="el-upload__tip" slot="tip">Only word file can be uploaded(.docx)</div>
<div class="el-upload__tip" slot="tip">{{ $t('articleDetailEmail.uploadTip') }}</div>
</el-upload>
</el-form-item>
<el-button type="primary" @click="sendData" :disabled="btn_ft"
style="float: right;margin-top: -20px;">Send mail
style="float: right;margin-top: -20px;">{{ $t('articleDetailEmail.sendMail') }}
</el-button>
</el-form>
</el-col>
<el-col :span="8">
<div class="muban_list">
<p>Template selection</p>
<p>{{ $t('articleDetailEmail.templateSelection') }}</p>
<el-collapse v-model="activeNames" default-expand-all>
<el-collapse-item v-for="(itemC, indexC) in mubanList" :title="itemC.etitle"
:name="itemC.eid">
<div class="sel_muban" v-for="(item, index) in itemC.children">
:name="itemC.eid" :key="itemC.eid || indexC">
<div class="sel_muban" v-for="(item, index) in itemC.children" :key="index">
<i class="header-icon el-icon-circle-plus" @click="add_muban(item)"></i>
{{item.econtent}}
<span style="color: #999;margin-left: 10px;">({{item.num}})</span>
@@ -62,11 +69,20 @@
<script>
export default {
data() {
const savedLang = localStorage.getItem('langs');
return {
loading: false,
baseUrl: this.Common.baseUrl,
articleId: this.$route.query.id,
AuthorMes: {},
emailLetterLang: savedLang === 'zh' ? 'zh' : 'en',
authorDisplayName: '',
journalMeta: {
title: '',
email: '',
website: '',
issn: ''
},
EmailData: {
email: '',
attachment: '',
@@ -83,12 +99,52 @@
created: function() {
this.getData();
},
watch: {
'$i18n.locale'(val) {
if (val === 'zh' || val === 'en') {
this.emailLetterLang = val;
this.applyEmailLetterBlocks();
}
}
},
computed: {
upload_enclosure: function() {
return this.baseUrl + 'api/Email/up_enclosure_file';
},
},
methods: {
letterT(key, params) {
const loc = this.emailLetterLang === 'zh' ? 'zh' : 'en';
return params ? this.$t('articleDetailEmail.' + key, loc, params) : this.$t('articleDetailEmail.' + key, loc);
},
applyEmailLetterBlocks() {
const name = this.authorDisplayName || '';
this.EmailData.topmail = this.letterT('greeting', { name }) + '<br/>';
const j = this.journalMeta;
if (!j.title) return;
const subscribeUrl = 'https://www.tmrjournals.com/draw_up.html?issn=' + (j.issn || '');
this.EmailData.bottomail =
'<br/>' +
this.letterT('signOff') +
'<br/>' +
this.letterT('officeLine', { journal: j.title }) +
'<br/>' +
this.letterT('telephone') +
'<br/>' +
this.letterT('emailLine', { email: j.email || '' }) +
'<br/>' +
(j.website || '') +
'<br/>' +
this.letterT('subscribeLine', { journal: j.title }) +
'<br/>' +
subscribeUrl;
},
setAuthorFromResponse(user) {
if (!user) return;
this.AuthorMes = user;
this.authorDisplayName = user.realname || user.name || '';
this.applyEmailLetterBlocks();
},
//获取数据
getData() {
this.$api
@@ -96,9 +152,6 @@
.then(res => {
if (res.code == 0) {
this.mubanList = res.data.templates;
// for (let i in this.mubanList) {
// this.activeNames.push(this.mubanList[i].eid)
// }
} else {
this.$message.error(res.msg);
}
@@ -117,15 +170,13 @@ if(this.$route.query.user_id){
.then(res => {
if(this.$route.query.user_id){
if (res.status == 1) {
this.AuthorMes = res.data.user;
this.EmailData.topmail = 'Dear Dr. ' + this.AuthorMes.realname + ',<br/>';
this.setAuthorFromResponse(res.data.user);
} else {
this.$message.error(res.msg);
}
}else{
if (res.code == 0) {
this.AuthorMes = res.data.userDetail;
this.EmailData.topmail = 'Dear Dr. ' + this.AuthorMes.realname + ',<br/>';
this.setAuthorFromResponse(res.data.userDetail);
} else {
this.$message.error(res.msg);
}
@@ -156,13 +207,13 @@ if(this.$route.query.user_id){
.then(res => {
if (res.code == 0) {
let arry = res.data.journal;
this.EmailData.bottomail = '<br/>Yours Sincerely' +
'<br/>' + arry.title + ' | Editorial Office | New Zealand' +
'<br/>Telephone: +64 02108293806' +
'<br/>Email: ' + arry.email +
'<br/>' + arry.website +
'<br/>Subscribe to receive Latest Research and News from ' + arry.title +
'<br/>https://www.tmrjournals.com/draw_up.html?issn=' + arry.issn
this.journalMeta = {
title: arry.title || '',
email: arry.email || '',
website: arry.website || '',
issn: arry.issn || ''
};
this.applyEmailLetterBlocks();
} else {
this.$message.error(res.msg);
}
@@ -176,7 +227,7 @@ if(this.$route.query.user_id){
// 发送邮件
sendData() {
if (this.EmailData.substance == '') {
this.$message.error('Please enter the message content!');
this.$message.error(this.$t('articleDetailEmail.contentRequired'));
return
}
this.loading = true;
@@ -191,7 +242,7 @@ if(this.$route.query.user_id){
setTimeout(() => {
this.loading = false
this.btn_ft = true
this.$message.success('Sent successfully!');
this.$message.success(this.$t('articleDetailEmail.sendSuccess'));
this.$router.push({
path: 'articleDetailEmailist',
query: {
@@ -229,20 +280,20 @@ if(this.$route.query.user_id){
// 附件上传
beforeupload_enclosure() {},
uperr_enclosure(err) {
this.$message.error('upload error');
this.$message.error(this.$t('articleDetailEmail.uploadError'));
},
upSuccess_enclosure(res, file) {
if (res.code == 0) {
this.EmailData.attachment = 'enclosure/' + res.upurl;
} else {
this.$message.error('service error' + res.msg);
this.$message.error(this.$t('articleDetailEmail.serviceError', { msg: res.msg }));
}
},
removefilenclosure(file, fileList) {
this.EmailData.attachment = '';
},
alertlimit() {
this.$message.error('The maximum number of uploaded files has been exceeded!');
this.$message.error(this.$t('articleDetailEmail.uploadLimit'));
},
}
};
@@ -254,6 +305,20 @@ if(this.$route.query.user_id){
margin: 30px 0 0 0;
}
.email-letter-lang {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.email-letter-lang-label {
font-size: 13px;
color: #606266;
font-weight: 600;
}
.Email_Data .el-textarea__inner {
line-height: 26px;
margin: 10px 0 -20px 0;

View File

@@ -59,22 +59,38 @@
mailData.to_list.length
}}
</div>
<div v-if="mailData.attachments && mailData.attachments.length" class="attachment-brief-bar">
<span class="brief-info">
{{ $t('mailboxCollect.totalAttachments', { count: mailData.attachments.length }) }}
<i class="el-icon-document" style="color: #409EFF;"></i>
<span class="first-file-name">{{ mailData.attachments[0].name }}{{ mailData.attachments.length > 1 ? $t('mailboxCollect.etcSuffix') : '' }}</span>
</span>
<el-link type="primary" :underline="false" @click="scrollToAttachments" class="jump-link">
{{ $t('mailboxCollect.viewAttachments') }}
</el-link>
</div>
</div>
</div>
<div class="mail-body-content" v-html="mailBodyHtml"></div>
<div
v-if="mailData.attachments && mailData.attachments.length"
class="attachment-brief-bar"
>
<span class="brief-info">
{{ $t('mailboxCollect.totalAttachments', { count: mailData.attachments.length }) }}
<i class="el-icon-document" style="color: #409EFF;"></i>
<span class="first-file-name">{{ mailData.attachments[0].name }}{{ mailData.attachments.length > 1 ? $t('mailboxCollect.etcSuffix') : '' }}</span>
</span>
<el-link type="primary" :underline="false" @click="scrollToAttachments" class="jump-link">
{{ $t('mailboxCollect.viewAttachments') }}
</el-link>
</div>
<div class="mail-body-wrap">
<iframe
v-if="mailIframeSrcdoc"
ref="mailHtmlFrame"
class="mail-html-iframe"
:srcdoc="mailIframeSrcdoc"
sandbox="allow-scripts allow-same-origin"
frameborder="0"
scrolling="no"
:style="mailIframeStyle"
@load="onMailIframeLoad"
></iframe>
<div v-else class="mail-body-content" v-html="mailBodyHtml"></div>
</div>
<div v-if="mailData.attachments && mailData.attachments.length" class="attachment-section">
<div class="attachment-header">
@@ -123,7 +139,16 @@
<script>
import Common from '@/components/common/common';
import { normalizeEmailHtmlForInlineDisplay } from '@/utils/emailHtmlView';
import {
normalizeEmailHtmlForInlineDisplay,
buildEmailPreviewDocument,
isRichHtmlEmail,
unescapeHtmlEntities,
resizeIframeToContent,
measureHtmlContentHeight,
sanitizeMailPreviewHeight,
MAIL_PREVIEW_HEIGHT_MSG
} from '@/utils/emailHtmlView';
import JSZip from 'jszip';
import { saveAs } from 'file-saver';
import FilePreviewDialog from './FilePreviewDialog.vue';
@@ -152,19 +177,61 @@ export default {
mediaUrl: Common.mediaUrl,
isDetailExpanded: false,
downloadingIndex: -1,
packingAll: false
packingAll: false,
mailIframeHeight: 120,
_iframeResizeObserver: null,
_iframeResizeTimers: [],
_mailPreviewMessageHandler: null
};
},
mounted() {
this._mailPreviewMessageHandler = (e) => this.handleMailPreviewMessage(e);
window.addEventListener('message', this._mailPreviewMessageHandler);
},
beforeDestroy() {
if (this._mailPreviewMessageHandler) {
window.removeEventListener('message', this._mailPreviewMessageHandler);
this._mailPreviewMessageHandler = null;
}
this.teardownIframeResize();
},
computed: {
totalAttachmentSize() {
if (!this.mailData.attachments || !this.mailData.attachments.length) return '0B';
const total = this.mailData.attachments.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
return this.formatFileSize(total);
},
mailRawHtml() {
const m = this.mailData || {};
const html =
m.content_html ||
m.body_html ||
m.html ||
m.body ||
m.content ||
'';
if (html && String(html).trim()) return html;
const text = m.content_text || '';
if (isRichHtmlEmail(text)) return text;
return '';
},
mailIframeSrcdoc() {
if (!isRichHtmlEmail(this.mailRawHtml)) return '';
return buildEmailPreviewDocument(this.mailRawHtml) || '';
},
mailIframeStyle() {
const h = Math.max(Number(this.mailIframeHeight) || 0, 80);
return {
height: h + 'px',
minHeight: '80px',
maxHeight: 'none'
};
},
/** 正文:兼容 content_html / body / html纯文本时包一层 pre */
mailBodyHtml() {
if (this.mailIframeSrcdoc) return '';
const m = this.mailData || {};
let raw = m.content_html || m.body_html || m.html || m.body || m.content || '';
let raw = unescapeHtmlEntities(this.mailRawHtml);
raw = normalizeEmailHtmlForInlineDisplay(raw);
if (raw) return raw;
const text = m.content_text;
@@ -174,7 +241,109 @@ export default {
return '';
}
},
watch: {
mailIframeSrcdoc() {
this.teardownIframeResize();
this.mailIframeHeight = 120;
this.$nextTick(() => this.onMailIframeLoad());
},
mailRawHtml() {
this.mailIframeHeight = 120;
this.teardownIframeResize();
}
},
methods: {
handleMailPreviewMessage(e) {
if (!e || !e.data || e.data.type !== MAIL_PREVIEW_HEIGHT_MSG) return;
const frame = this.$refs.mailHtmlFrame;
if (
frame &&
frame.contentWindow &&
e.source &&
e.source !== frame.contentWindow
) {
return;
}
const h = sanitizeMailPreviewHeight(e.data.height);
if (h > 0) {
this.mailIframeHeight = h;
}
},
measureMailIframeOffscreen() {
const panel = this.$el && this.$el.querySelector('.detail-scroll-content');
const w = panel ? Math.max(panel.clientWidth - 48, 280) : 640;
const h = sanitizeMailPreviewHeight(measureHtmlContentHeight(this.mailRawHtml, w));
if (h > 0) {
this.mailIframeHeight = Math.max(this.mailIframeHeight, h);
}
},
teardownIframeResize() {
if (this._iframeResizeObserver) {
this._iframeResizeObserver.disconnect();
this._iframeResizeObserver = null;
}
if (this._iframeResizeTimers && this._iframeResizeTimers.length) {
this._iframeResizeTimers.forEach((id) => clearTimeout(id));
this._iframeResizeTimers = [];
}
},
resizeMailIframe() {
const frame = this.$refs.mailHtmlFrame;
if (!frame) return;
try {
const h = sanitizeMailPreviewHeight(resizeIframeToContent(frame));
if (h > 0) {
this.mailIframeHeight = h;
}
} catch (e) {
/* 无法访问 iframe 文档时保持当前高度 */
}
},
setupIframeResizeObserver() {
if (this._iframeResizeObserver) {
this._iframeResizeObserver.disconnect();
this._iframeResizeObserver = null;
}
const frame = this.$refs.mailHtmlFrame;
if (!frame || typeof ResizeObserver === 'undefined') return;
try {
const doc = frame.contentDocument;
if (!doc || !doc.body) return;
const ro = new ResizeObserver(() => this.resizeMailIframe());
ro.observe(doc.body);
if (doc.documentElement) ro.observe(doc.documentElement);
this._iframeResizeObserver = ro;
} catch (e) {
/* ignore */
}
},
bindIframeImageLoad() {
const frame = this.$refs.mailHtmlFrame;
if (!frame) return;
try {
const doc = frame.contentDocument;
if (!doc) return;
doc.querySelectorAll('img').forEach((img) => {
if (img.complete) return;
img.addEventListener('load', () => this.resizeMailIframe());
img.addEventListener('error', () => this.resizeMailIframe());
});
} catch (e) {
/* ignore */
}
},
onMailIframeLoad() {
this.$nextTick(() => {
this.resizeMailIframe();
this.measureMailIframeOffscreen();
this.setupIframeResizeObserver();
this.bindIframeImageLoad();
const delays = [100, 400, 1000, 2000];
this._iframeResizeTimers = delays.map((ms) =>
setTimeout(() => this.resizeMailIframe(), ms)
);
});
},
escapeHtml(text) {
if (text == null) return '';
return String(text)
@@ -453,6 +622,17 @@ const res = await this.$api.post('api/email_client/getAttachment', {
color: #909399;
}
/* 完整 HTML 邮件 iframe高度由 JS 设为固定 px禁止 height:auto */
.mail-html-iframe {
display: block;
width: 100%;
min-height: 120px;
margin-bottom: 50px;
border: 0;
background: #fff;
overflow: hidden;
}
/* 正文 */
.mail-body-content {
line-height: 1.6;
@@ -460,6 +640,30 @@ const res = await this.$api.post('api/email_client/getAttachment', {
color: #303133;
min-height: 200px;
margin-bottom: 50px;
overflow-x: auto;
word-break: break-word;
}
/* 营销邮件在窄容器内常被 @media 规则隐藏 desktop-only预览强制展示 */
.mail-body-content >>> .desktop-only,
.mail-body-content >>> .mobile-only {
display: block !important;
max-height: none !important;
overflow: visible !important;
}
.mail-body-content >>> .imgDesk {
display: block !important;
max-height: none !important;
overflow: visible !important;
}
.mail-body-content >>> .imgMobile {
display: none !important;
}
.mail-body-content >>> table {
max-width: 100% !important;
}
.mail-body-content >>> img {
max-width: 100% !important;
height: auto !important;
}
/* v-html 注入的正文无 scoped 属性,用 deep 命中内部的 pre */
@@ -664,26 +868,37 @@ const res = await this.$api.post('api/email_client/getAttachment', {
color: #0052d9;
}
.attachment-brief-bar {
display: flex;
align-items: center;
padding: 8px 0;
font-size: 13px;
color: #606266;
border-top: 1px solid #ebeef5;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
box-sizing: border-box;
padding: 10px 0;
margin-bottom: 12px;
font-size: 13px;
color: #606266;
border-top: 1px solid #ebeef5;
}
.attachment-brief-bar .brief-info {
}
.attachment-brief-bar .first-file-name {
color: #909399;
margin-left: 4px;
}
.jump-link {
margin-left: 15px;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-brief-bar .first-file-name {
color: #909399;
margin-left: 4px;
}
.attachment-brief-bar .jump-link {
flex-shrink: 0;
margin-left: 16px;
font-size: 13px;
}
}
.mail-body-wrap {
width: 100%;
box-sizing: border-box;
}
.attachment-section {
margin-top: 30px;
padding-top: 20px;

File diff suppressed because it is too large Load Diff

View File

@@ -172,7 +172,11 @@ const API = {
getAllJournal: 'api/Article/getJournal'
};
import MailDetail from '../../components/page/components/email/MailDetail.vue';
import { normalizeEmailHtmlForInlineDisplay } from '@/utils/emailHtmlView';
import {
normalizeEmailHtmlForInlineDisplay,
isRichHtmlEmail,
unescapeHtmlEntities
} from '@/utils/emailHtmlView';
export default {
data() {
return {
@@ -551,20 +555,31 @@ fetchLatestSingleMail(jEmailId, journalId) {
has_attachment: (item && item.has_attachment) || 0
};
}
const html = d.content_html || d.body_html || d.html || d.body || '';
let html = d.content_html || d.body_html || d.html || d.body || '';
const text = d.content_text || '';
if (!html && isRichHtmlEmail(text)) {
html = text;
}
let content_html =
html ||
(text ? `<pre class="mail-plain-pre">${this.escapeHtml(text)}</pre>` : '') ||
listSnippet;
if (content_html && typeof content_html === 'string' && /^<!DOCTYPE|^<\s*html[\s>]/i.test(content_html.trim())) {
const normalized = normalizeEmailHtmlForInlineDisplay(content_html);
if (normalized && normalized.trim()) content_html = normalized;
content_html = unescapeHtmlEntities(content_html);
if (isRichHtmlEmail(content_html) || isRichHtmlEmail(html)) {
const normalized = normalizeEmailHtmlForInlineDisplay(content_html || html);
if (normalized && normalized.trim()) {
content_html = normalized;
}
}
if (!content_html || !String(content_html).trim()) {
content_html =
(text ? `<pre class="mail-plain-pre">${this.escapeHtml(text)}</pre>` : '') || listSnippet;
}
return {
...item,
...d,
content_html,
content_text: text || d.content_text || '',
inbox_id: inboxKey,
attachments: []
};

View File

@@ -1,7 +1,7 @@
<template>
<div>
<!-- publish 引用编辑页面 -->
<editPublicRefRdit ref="editPublicRefRdit" :chanFerFormRepeatList="chanFerFormRepeatList" :chanFerForm = "chanFerForm" :gridData = "gridData" :p_article_id='p_article_id' @ChanFerMashUp="ChanFerMashUp" @refrashComp="refrashComp" @changeRefer="changeRefer"></editPublicRefRdit>
<editPublicRefRdit ref="editPublicRefRdit" :chanFerFormRepeatList="chanFerFormRepeatList" :chanFerForm = "chanFerForm" :gridData = "gridData" :p_article_id='p_article_id' :article_id="article_id" @ChanFerMashUp="ChanFerMashUp" @refrashComp="refrashComp" @changeRefer="changeRefer"></editPublicRefRdit>
</div>
</template>
@@ -13,7 +13,7 @@ import {
export default {
data(){
return{
article_id: this.$route.query.id,
article_id: null,
chanFerForm: [],
chanFerFormRepeatList: [],
gridData: '',
@@ -21,9 +21,27 @@ export default {
}
},
created(){
this.getData()
this.initPage()
},
methods:{
/** 正文接口 getArticleMainsNew 需用 production.article_id非路由 id */
fetchProductionArticleId() {
if (!this.p_article_id) return Promise.resolve();
return this.$api
.post('api/Production/getProductionDetail', { p_article_id: this.p_article_id })
.then((res) => {
if (res.code == 0 && res.data && res.data.production) {
this.article_id = res.data.production.article_id;
}
})
.catch((err) => {
console.warn('getProductionDetail failed', err);
});
},
async initPage() {
await this.fetchProductionArticleId();
this.getData();
},
// 获取p_article_id的值
getArtcleDetails(){
// 获得文章详情

View File

@@ -1,26 +1,655 @@
/**
* 将完整 HTML 邮件转为适合 div[v-html] 的片段:
* 取 body.innerHTML并前置 head 内 <style>,避免版式与改版前不一致。
* HTML 邮件预览ResearchGate 等营销邮件在 @media 窄屏规则下会隐藏 .desktop-only
* 导致预览区看似空白。通过剥离 @media、注入覆盖样式并用 iframe 渲染完整文档。
*/
export function normalizeEmailHtmlForInlineDisplay(html) {
if (!html || typeof html !== 'string') return '';
const t = html.trim();
if (!/^<!DOCTYPE|^<\s*html[\s>]/i.test(t)) return html;
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const body = doc && doc.body;
if (!body) return html;
const inner = body.innerHTML;
if (!inner || !inner.trim()) return html;
let headInject = '';
const head = doc.head;
if (head) {
head.querySelectorAll('style').forEach((node) => {
headInject += node.outerHTML;
});
}
return headInject + inner;
} catch (e) {
return html;
}
const MAIL_PREVIEW_FIX_CSS = `
html, body {
overflow: visible !important;
height: auto !important;
min-height: 0 !important;
max-height: none !important;
margin: 0;
padding: 0;
}
.desktop-only,
.mobile-only {
display: block !important;
max-height: none !important;
overflow: visible !important;
visibility: visible !important;
font-size: inherit !important;
mso-hide: initial !important;
}
.imgDesk {
display: block !important;
max-height: none !important;
overflow: visible !important;
font-size: inherit !important;
mso-hide: initial !important;
}
.imgMobile {
display: none !important;
max-height: 0 !important;
overflow: hidden !important;
}
.hidden-sm {
display: block !important;
}
td.hidden-sm {
display: table-cell !important;
}
table.container,
table.content,
table.content-outer {
width: 100% !important;
max-width: 100% !important;
}
img {
max-width: 100% !important;
height: auto !important;
}
body table,
body div,
body center,
body td {
overflow: visible !important;
max-height: none !important;
}
body > table,
body > div {
height: auto !important;
}
body,
body > table,
body > div,
body > center,
body table {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
body table[width] {
width: 100% !important;
}
body td[width] {
max-width: 100% !important;
}
@media only screen and (max-width: 550px) {
.desktop-only,
.mobile-only {
display: block !important;
max-height: none !important;
overflow: visible !important;
visibility: visible !important;
}
.imgDesk {
display: block !important;
max-height: none !important;
overflow: visible !important;
}
.imgMobile {
display: none !important;
}
}
@media only screen and (max-device-width: 736px) {
.hidden-sm {
display: block !important;
}
td.hidden-sm {
display: table-cell !important;
}
}
@media only screen and (max-device-width: 550px) {
.imgDesk {
display: block !important;
max-height: none !important;
overflow: visible !important;
}
.imgMobile {
display: none !important;
}
}
`;
const MAIL_PREVIEW_FIX_STYLE = `<style data-mail-preview-fix>${MAIL_PREVIEW_FIX_CSS}</style>`;
/** 去掉邮件内 @media避免窄视口把 desktop-only 整块藏掉 */
function stripMediaQueries(css) {
if (!css || typeof css !== 'string') return '';
let out = '';
let i = 0;
const s = css;
while (i < s.length) {
const idx = s.indexOf('@media', i);
if (idx === -1) {
out += s.slice(i);
break;
}
out += s.slice(i, idx);
let depth = 0;
let j = idx;
let started = false;
for (; j < s.length; j++) {
const ch = s[j];
if (ch === '{') {
depth++;
started = true;
} else if (ch === '}') {
depth--;
if (started && depth === 0) {
j++;
break;
}
}
}
i = j;
}
return out;
}
function extractBodyHtmlFallback(html) {
const m = String(html).match(/<body[^>]*>([\s\S]*)<\/body>/i);
return m && m[1] ? m[1].trim() : '';
}
/** API 有时返回 HTML 实体编码 */
export function unescapeHtmlEntities(html) {
if (!html || typeof html !== 'string') return html || '';
const t = html.trim();
if (!/&lt;(?:!DOCTYPE|html|body|table)/i.test(t) && !/^&#60;/i.test(t)) return html;
if (typeof document === 'undefined') return html;
const ta = document.createElement('textarea');
ta.innerHTML = t;
return ta.value;
}
export function isRichHtmlEmail(html) {
if (!html || typeof html !== 'string') return false;
const t = unescapeHtmlEntities(html).trim();
if (!t) return false;
if (/^<!DOCTYPE|^<\s*html[\s>]/i.test(t)) return true;
return t.length > 80 && /<(?:table|div|td|tbody|style|img|p|span)[\s>/]/i.test(t);
}
function collectHeadStyles(doc) {
let headInject = '';
const head = doc && doc.head;
if (!head) return headInject;
head.querySelectorAll('style').forEach((node) => {
if (node.getAttribute('data-mail-preview-fix') != null) return;
const el = node.cloneNode(true);
el.textContent = stripMediaQueries(node.textContent || '');
headInject += el.outerHTML;
});
return headInject;
}
/** iframe 内脚本:向父页面报告真实内容高度 */
function injectHeightReporterScript(doc) {
if (!doc || !doc.body) return;
if (doc.querySelector('script[data-mail-preview-height]')) return;
const script = doc.createElement('script');
script.setAttribute('data-mail-preview-height', '');
script.textContent = `(function(){
var SUSP=8000,EXP=12000;
function report(){
var b=document.body,de=document.documentElement;
if(!b)return;
b.style.overflow='visible';b.style.height='auto';b.style.maxHeight='none';
if(de){de.style.overflow='visible';de.style.height='auto';de.style.maxHeight='none';}
var h=0,i,r,top=b.getBoundingClientRect().top,maxB=0;
var nodes=b.querySelectorAll('*');
for(i=0;i<nodes.length;i++){
r=nodes[i].getBoundingClientRect();
if(r.height>0&&r.bottom>maxB)maxB=r.bottom;
}
r=b.getBoundingClientRect();
if(r.height>0&&r.bottom>maxB)maxB=r.bottom;
h=maxB>top?maxB-top:r.height||0;
h=Math.ceil(h+24);
if(h<80||(h>=SUSP&&h>=EXP*0.6))return;
try{window.parent.postMessage({type:'mail-preview-height',height:h},'*');}catch(e){}
}
function boot(){
report();
if(typeof ResizeObserver!=='undefined'){try{new ResizeObserver(report).observe(document.body);}catch(e){}}
Array.prototype.forEach.call(document.images||[],function(img){
if(!img.complete)img.addEventListener('load',report);
img.addEventListener('error',report);
});
[0,80,200,500,1000,2000,4000,8000].forEach(function(ms){setTimeout(report,ms);});
}
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',boot);}else{boot();}
window.addEventListener('load',boot);
})();`;
doc.body.appendChild(script);
}
function injectPreviewFixIntoDocument(doc) {
if (!doc || !doc.head) return;
doc.head.querySelectorAll('style').forEach((node) => {
if (node.getAttribute('data-mail-preview-fix') != null) return;
node.textContent = stripMediaQueries(node.textContent || '');
});
if (!doc.head.querySelector('style[data-mail-preview-fix]')) {
const fix = doc.createElement('style');
fix.setAttribute('data-mail-preview-fix', '');
fix.textContent = MAIL_PREVIEW_FIX_CSS;
doc.head.appendChild(fix);
}
if (!doc.head.querySelector('meta[charset]')) {
const meta = doc.createElement('meta');
meta.setAttribute('charset', 'utf-8');
doc.head.insertBefore(meta, doc.head.firstChild);
}
injectHeightReporterScript(doc);
}
/**
* 将完整 HTML 邮件转为适合 div[v-html] 的片段(非 iframe 场景)
*/
export function normalizeEmailHtmlForInlineDisplay(html) {
if (!html || typeof html !== 'string') return '';
const decoded = unescapeHtmlEntities(html);
const t = decoded.trim();
if (!isRichHtmlEmail(decoded)) return decoded;
if (!/^<!DOCTYPE|^<\s*html[\s>]/i.test(t)) {
return MAIL_PREVIEW_FIX_STYLE + decoded;
}
try {
const doc = new DOMParser().parseFromString(decoded, 'text/html');
const body = doc && doc.body;
let inner = body && body.innerHTML ? body.innerHTML.trim() : '';
if (!inner) {
inner = extractBodyHtmlFallback(t);
}
if (!inner) return decoded;
const headInject = collectHeadStyles(doc);
return headInject + MAIL_PREVIEW_FIX_STYLE + inner;
} catch (e) {
const inner = extractBodyHtmlFallback(t);
if (inner) return MAIL_PREVIEW_FIX_STYLE + inner;
return decoded;
}
}
/** 测量 iframe 被临时撑到 16000px 时scrollHeight 会虚高,只按内容占位计算 */
const IFRAME_MEASURE_EXPAND_PX = 12000;
const SUSPICIOUS_HEIGHT_PX = 8000;
/**
* 按子元素 getBoundingClientRect 计算真实内容高度(不用 scrollHeight
*/
export function measureEmailDocumentHeight(doc) {
if (!doc || !doc.body) return 200;
const body = doc.body;
const docEl = doc.documentElement;
if (docEl) {
docEl.style.overflow = 'visible';
docEl.style.height = 'auto';
docEl.style.minHeight = '0';
docEl.style.maxHeight = 'none';
}
body.style.overflow = 'visible';
body.style.height = 'auto';
body.style.minHeight = '0';
body.style.maxHeight = 'none';
let maxBottom = 0;
const bodyTop = body.getBoundingClientRect().top;
const nodes = body.querySelectorAll('*');
for (let i = 0; i < nodes.length; i++) {
const r = nodes[i].getBoundingClientRect();
if (r.height > 0 && r.bottom > maxBottom) maxBottom = r.bottom;
}
const bodyRect = body.getBoundingClientRect();
if (bodyRect.height > 0 && bodyRect.bottom > maxBottom) maxBottom = bodyRect.bottom;
const fromRect = maxBottom > bodyTop ? maxBottom - bodyTop : bodyRect.height || 0;
return Math.ceil(Math.max(fromRect, 80) + 24);
}
/** 过滤测量异常值(如误用撑开后的 scrollHeight 得到 16000 */
export function sanitizeMailPreviewHeight(h) {
const n = Math.ceil(Number(h) || 0);
if (n < 80) return 0;
if (n >= SUSPICIOUS_HEIGHT_PX && n >= IFRAME_MEASURE_EXPAND_PX * 0.6) return 0;
return Math.min(n, 20000);
}
/**
* 将 iframe 高度设为与内部文档一致
*/
export function resizeIframeToContent(frame) {
if (!frame) return 200;
let doc;
try {
doc = frame.contentDocument;
} catch (e) {
return 200;
}
if (!doc || !doc.body) return 200;
const prevOverflow = frame.style.overflow;
frame.style.height = IFRAME_MEASURE_EXPAND_PX + 'px';
frame.style.minHeight = '0';
frame.style.overflow = 'hidden';
let h = measureEmailDocumentHeight(doc);
h = sanitizeMailPreviewHeight(h) || measureEmailDocumentHeight(doc);
if (typeof frame.contentWindow !== 'undefined' && frame.contentWindow) {
try {
frame.contentWindow.scrollTo(0, 0);
} catch (e2) {
/* ignore */
}
}
const h2 = sanitizeMailPreviewHeight(measureEmailDocumentHeight(doc));
if (h2 > h) h = h2;
const finalH = sanitizeMailPreviewHeight(h) || 400;
frame.style.height = finalH + 'px';
frame.style.minHeight = finalH + 'px';
frame.style.overflow = prevOverflow || 'hidden';
return finalH;
}
/**
* 生成 iframe srcdoc 用的完整 HTML 文档(样式在 head 内,渲染最稳定)
*/
export function buildEmailPreviewDocument(html) {
if (!html || typeof html !== 'string') return '';
if (typeof DOMParser === 'undefined') return '';
const decoded = unescapeHtmlEntities(html);
const t = decoded.trim();
if (!t || !isRichHtmlEmail(decoded)) return '';
let doc;
try {
if (/^<!DOCTYPE|^<\s*html[\s>]/i.test(t)) {
doc = new DOMParser().parseFromString(decoded, 'text/html');
} else {
doc = new DOMParser().parseFromString(
'<!DOCTYPE html><html><head></head><body></body></html>',
'text/html'
);
doc.body.innerHTML = decoded;
}
} catch (e) {
return '';
}
if (!doc || !doc.documentElement) return '';
injectPreviewFixIntoDocument(doc);
return '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
}
/** 离屏 div 测量 HTML 高度(与 iframe 同宽,作高度兜底) */
export function measureHtmlContentHeight(html, containerWidth) {
if (typeof document === 'undefined' || !html) return 0;
const decoded = unescapeHtmlEntities(html);
if (!isRichHtmlEmail(decoded)) return 0;
const content = normalizeEmailHtmlForInlineDisplay(decoded);
if (!content) return 0;
const w = Math.max(Number(containerWidth) || 640, 280);
const el = document.createElement('div');
el.setAttribute('data-mail-measure', '');
el.style.cssText =
'position:fixed;left:-30000px;top:0;width:' +
w +
'px;visibility:hidden;pointer-events:none;overflow:visible;z-index:-1;box-sizing:border-box;';
el.innerHTML = content;
document.body.appendChild(el);
let maxBottom = 0;
const top = el.getBoundingClientRect().top;
el.querySelectorAll('*').forEach((node) => {
const r = node.getBoundingClientRect();
if (r.height > 0 && r.bottom > maxBottom) maxBottom = r.bottom;
});
const er = el.getBoundingClientRect();
if (er.height > 0 && er.bottom > maxBottom) maxBottom = er.bottom;
const fromRect = maxBottom > top ? maxBottom - top : er.height || 0;
const h = sanitizeMailPreviewHeight(Math.ceil(Math.max(fromRect, 80) + 24));
document.body.removeChild(el);
return h || 0;
}
export const MAIL_PREVIEW_HEIGHT_MSG = 'mail-preview-height';