This commit is contained in:
2026-04-08 13:03:54 +08:00
parent 36f6c02376
commit 0d35b76c3a
11 changed files with 3866 additions and 589 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,17 @@
<template>
<div class="tinymce-container editor-container">
<textarea class="tinymce-textarea" :id="tinymceId"></textarea>
<div class="tinymce-editor-surface">
<textarea class="tinymce-textarea" :id="tinymceId"></textarea>
</div>
<div
v-if="showRefButton && hasReferencesForAutoLink"
ref="autoLinkFooterWrap"
class="tinymce-autolink-footer"
>
<span @click="handleAutoLinkRefsClick" style="cursor: pointer;color: #409eff;font: 12px;font-weight: 700;">
<i class="el-icon-link"></i> {{ $t('wordCite.matchBracketRefs') }}
</span>
</div>
</div>
</template>
<script>
@@ -67,17 +78,57 @@ export default {
chanFerForm: {
type: Array,
default: () => []
},
/** 由父级根据全文(稿面 + Edit Content 草稿合并)扫描得到,与 word.vue citeMap 一致 */
bodyCiteIdOrder: {
type: Array,
default: () => []
},
/** 为 false 时不显示 Ref 工具栏按钮,也不自动在 LateX 后注入 insertRef用于图表标题等 */
showRefButton: {
type: Boolean,
default: true
}
},
computed: {
citeMap() {
const map = {};
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
refs.forEach((row, idx) => {
const key = row && row.p_refer_id != null ? String(row.p_refer_id) : '';
if (key) map[key] = idx + 1;
const refIdSet = new Set(
refs.map((r) => (r && r.p_refer_id != null ? String(r.p_refer_id) : '')).filter(Boolean)
);
if (!Array.isArray(this.bodyCiteIdOrder) || this.bodyCiteIdOrder.length === 0) {
const map = {};
refs.forEach((row, idx) => {
const key = row && row.p_refer_id != null ? String(row.p_refer_id) : '';
if (key) map[key] = idx + 1;
});
return map;
}
const orderedIds = this.bodyCiteIdOrder.map(String);
const filtered = [];
orderedIds.forEach((id) => {
if (refIdSet.has(id) && !filtered.includes(id)) filtered.push(id);
});
const map = {};
filtered.forEach((id, idx) => {
map[id] = idx + 1;
});
let next = filtered.length + 1;
refs.forEach((r) => {
const key = r && r.p_refer_id != null ? String(r.p_refer_id) : '';
if (!key || map[key] != null) return;
map[key] = next++;
});
return map;
},
/** 有参考文献列表时才显示「自动链接」:与 insertRef 一致,依赖 chanFerForm 中有效 p_refer_id */
hasReferencesForAutoLink() {
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
return refs.some((r) => r && r.p_refer_id != null && String(r.p_refer_id).trim() !== '');
}
},
data() {
@@ -139,7 +190,9 @@ export default {
hasChange: false,
hasInit: false,
editorInstance: null,
tinymceId: this.id || 'vue-tinymce-' + +new Date()
tinymceId: this.id || 'vue-tinymce-' + +new Date(),
/** 将底部栏移入 TinyMCE 容器前,记录原 DOM 位置以便销毁时移回 */
_autoLinkFooterRestore: null
};
},
watch: {
@@ -162,6 +215,27 @@ export default {
});
},
deep: true
},
bodyCiteIdOrder: {
handler() {
this.$nextTick(() => {
if (this.editorInstance) {
this.renderAutociteInEditor(this.editorInstance);
}
});
},
deep: true
},
hasReferencesForAutoLink(val) {
if (!val) {
this.restoreAutoLinkFooterDom();
return;
}
if (this.showRefButton && this.editorInstance) {
this.$nextTick(() => {
this.mountAutoLinkFooterInsideEditor(this.editorInstance);
});
}
}
},
mounted() {
@@ -185,24 +259,6 @@ export default {
this.destroyTinymce();
},
methods: {
/** 与正文一致:两项连续写作 1, 2三项及以上连续写作 1-3 */
formatCiteNumbers(nums) {
if (!nums || !nums.length) return '';
const sorted = [...new Set(nums)].sort((a, b) => a - b);
const result = [];
let i = 0;
while (i < sorted.length) {
let j = i;
while (j < sorted.length - 1 && sorted[j + 1] === sorted[j] + 1) j++;
if (j - i >= 2) {
result.push(`${sorted[i]}${sorted[j]}`);
} else {
for (let k = i; k <= j; k++) result.push(sorted[k]);
}
i = j + 1;
}
return result.join(', ');
},
parseAutociteDataIds(attr) {
if (!attr || typeof attr !== 'string') return [];
return attr
@@ -225,10 +281,205 @@ export default {
return String(a).localeCompare(String(b));
});
},
/** 同 word.vue多 id 均在列表中时对全局序号做 13 区间合并 */
formatCiteNumbers(nums) {
if (!nums || !nums.length) return '';
const sorted = [...new Set(nums)].sort((a, b) => a - b);
const result = [];
let i = 0;
while (i < sorted.length) {
let j = i;
while (j < sorted.length - 1 && sorted[j + 1] === sorted[j] + 1) j++;
if (j - i >= 2) {
result.push(`${sorted[i]}${sorted[j]}`);
} else {
for (let k = i; k <= j; k++) result.push(sorted[k]);
}
i = j + 1;
}
return result.join(', ');
},
/** 全局角标序号 n → p_refer_id用于将正文中的 [n] 转为 mycite */
buildNumToRefIdMap() {
const citeMap = this.citeMap || {};
const map = {};
Object.keys(citeMap).forEach((id) => {
const n = citeMap[id];
if (n != null && n !== '' && !Number.isNaN(Number(n))) {
map[Number(n)] = String(id);
}
});
return map;
},
/** 解析 [1]、[1,2]、[14] 等括号内数字列表(不含方括号) */
parseBracketInnerToNumbers(inner) {
if (!inner || typeof inner !== 'string') return [];
const t = inner.trim().replace(//g, ',');
const range = t.match(/^(\d+)\s*[-–—]\s*(\d+)$/);
if (range) {
const a = Number(range[1]);
const b = Number(range[2]);
if (Number.isNaN(a) || Number.isNaN(b)) return [];
const lo = Math.min(a, b);
const hi = Math.max(a, b);
const out = [];
for (let i = lo; i <= hi; i++) out.push(i);
return out;
}
if (/[,]/.test(t)) {
return t
.split(/[,]/)
.map((x) => parseInt(String(x).trim(), 10))
.filter((n) => !Number.isNaN(n));
}
const n = parseInt(t, 10);
return Number.isNaN(n) ? [] : [n];
},
/**
* 将编辑器内纯文本形式的 [1]、[13]、[1, 2] 按当前 citeMap 转为 <mycite>(跳过已有 mycite/wmath 内文字)
* @returns {{ replaced: number }}
*/
convertPlainBracketCitesToAutocite() {
const ed = this.editorInstance;
if (!ed) return { replaced: 0 };
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
if (!refs.length) {
this.$message.warning(this.$t('wordCite.noRefs'));
return { replaced: 0 };
}
const numToId = this.buildNumToRefIdMap();
if (Object.keys(numToId).length === 0) {
this.$message.warning(this.$t('wordCite.noRefs'));
return { replaced: 0 };
}
const doc = ed.getDoc();
const body = ed.getBody();
if (!body) return { replaced: 0 };
let replaced = 0;
ed.undoManager.transact(() => {
replaced = this._replaceBracketCitesInNode(body, doc, numToId);
});
this.renderAutociteInEditor(ed);
ed.fire('change');
return { replaced };
},
/** 底部「自动链接参考文献」按钮,与原先工具栏 autoLinkRefs 行为一致 */
handleAutoLinkRefsClick() {
const r = this.convertPlainBracketCitesToAutocite();
if (!r) return;
if (r.replaced > 0) {
this.$message.success(this.$t('wordCite.matchBracketRefsDone', { n: r.replaced }));
return;
}
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
const numToId = this.buildNumToRefIdMap();
if (refs.length && Object.keys(numToId).length) {
this.$message.info(this.$t('wordCite.matchBracketRefsNone'));
}
},
/** 把「自动链接」栏挂到 TinyMCE 外壳 .tox-tinymce 内部底部,与工具栏/编辑区同一外框 */
mountAutoLinkFooterInsideEditor(ed) {
if (!this.showRefButton || !this.hasReferencesForAutoLink) return;
this.$nextTick(() => {
const el = this.$refs.autoLinkFooterWrap;
if (!el || !ed) return;
const container = ed.getContainer && ed.getContainer();
if (!container) return;
if (el.parentNode === container) return;
this._autoLinkFooterRestore = {
parent: el.parentNode,
next: el.nextSibling
};
container.appendChild(el);
});
},
/** 销毁编辑器前必须移回,否则节点会随 .tox-tinymce 一起被移除 */
restoreAutoLinkFooterDom() {
const el = this.$refs.autoLinkFooterWrap;
const r = this._autoLinkFooterRestore;
this._autoLinkFooterRestore = null;
if (!el || !r || !r.parent) return;
try {
if (r.next && r.next.parentNode === r.parent) {
r.parent.insertBefore(el, r.next);
} else {
r.parent.appendChild(el);
}
} catch (e) {
/* ignore */
}
},
_replaceBracketCitesInNode(node, doc, numToId) {
if (node.nodeType === 3) {
return this._processTextNodeForBracketCites(node, doc, numToId);
}
if (node.nodeType !== 1) return 0;
const name = node.nodeName;
if (name === 'AUTOCITE' || name === 'WMATH' || name === 'SCRIPT' || name === 'STYLE') {
return 0;
}
let total = 0;
const children = Array.from(node.childNodes);
for (let i = 0; i < children.length; i++) {
total += this._replaceBracketCitesInNode(children[i], doc, numToId);
}
return total;
},
_processTextNodeForBracketCites(textNode, doc, numToId) {
const text = textNode.textContent;
const re = /\[([\d\s,\-–—]+)\]/g;
let m;
let lastIndex = 0;
const pieces = [];
let replaced = 0;
while ((m = re.exec(text)) !== null) {
const nums = this.parseBracketInnerToNumbers(m[1]);
const ids = [];
const seen = new Set();
nums.forEach((n) => {
const id = numToId[n];
if (id && !seen.has(id)) {
seen.add(id);
ids.push(id);
}
});
pieces.push({ type: 'text', s: text.slice(lastIndex, m.index) });
if (ids.length > 0) {
const sorted = this.sortAutociteIdsByCiteNumber(ids);
pieces.push({ type: 'cite', ids: sorted });
replaced++;
} else {
pieces.push({ type: 'text', s: m[0] });
}
lastIndex = m.index + m[0].length;
}
if (replaced === 0) return 0;
pieces.push({ type: 'text', s: text.slice(lastIndex) });
const parent = textNode.parentNode;
if (!parent) return 0;
const frag = doc.createDocumentFragment();
pieces.forEach((p) => {
if (p.type === 'text') {
frag.appendChild(doc.createTextNode(p.s));
} else {
const ac = doc.createElement('mycite');
ac.setAttribute('data-id', p.ids.join(','));
ac.setAttribute('contenteditable', 'false');
ac.appendChild(doc.createTextNode('\u200b'));
frag.appendChild(ac);
}
});
parent.replaceChild(frag, textNode);
return replaced;
},
renderAutociteInEditor(ed) {
const body = ed.getBody();
if (!body) return;
const allAutocites = Array.from(body.querySelectorAll('autocite'));
const allAutocites = Array.from(
ed.dom && typeof ed.dom.select === 'function'
? ed.dom.select('mycite', body)
: body.querySelectorAll('mycite')
);
if (!allAutocites.length) return;
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
@@ -238,25 +489,63 @@ export default {
if (key) refMap[key] = item;
});
const citeMap = this.citeMap || {};
allAutocites.forEach((el) => {
ed.dom.setAttrib(el, 'contenteditable', 'false');
el.style.display = '';
const ids = this.parseAutociteDataIds(el.getAttribute('data-id'));
const sortedIds = this.sortAutociteIdsByCiteNumber(ids);
const nums = sortedIds.map((id) => this.citeMap[String(id)]).filter((n) => n != null);
const label = nums.length > 0 ? this.formatCiteNumbers(nums) : '?';
const sortedAll = this.sortAutociteIdsByCiteNumber(
this.parseAutociteDataIds(el.getAttribute('data-id'))
);
/** 已删除的文献 id 从角标中忽略,并写回 data-id仅保留仍存在于列表中的 id */
const validIds = sortedAll.filter((id) => refMap[String(id)]);
if (validIds.length < sortedAll.length) {
ed.dom.setAttrib(el, 'data-id', validIds.length ? validIds.join(',') : '');
}
const sortedIds = validIds.length ? this.sortAutociteIdsByCiteNumber(validIds) : [];
const lines = sortedIds.map((id) => {
const no = this.citeMap[String(id)] != null ? this.citeMap[String(id)] : '?';
/** 角标只展示仍绑定且已有全局序号的文献 */
const parts = sortedIds.map((id) => {
const ref = refMap[String(id)];
if (!ref) return this.$t('wordCite.notFoundById', { id: no === '?' ? id : no });
const content = ref.refer_frag || [ref.author, ref.title, ref.joura, ref.dateno].filter(Boolean).join(' ').trim() || '[?]';
const doi = ref.doilink || ref.isbn || ref.doi || '[?]';
return `[${no}] ${content}\nDOI: ${doi}`;
const no = ref ? citeMap[String(id)] : null;
const num = no != null && no !== '' ? String(no) : null;
return { id, ref, num };
});
const numsForLabel = parts
.map((p) => p.num)
.filter((n) => n != null && n !== '')
.map(Number);
const label =
numsForLabel.length > 0 ? this.formatCiteNumbers(numsForLabel) : '';
const lines = parts
.filter((p) => p.ref)
.map((p) => {
const ref = p.ref;
const content =
ref.refer_frag ||
[ref.author, ref.title, ref.joura, ref.dateno].filter(Boolean).join(' ').trim() ||
'';
const doi = ref.doilink || ref.isbn || ref.doi || '';
const numLabel = p.num;
if (numLabel) {
return `[${numLabel}] ${content}\nDOI: ${doi}`;
}
return `${content}\nDOI: ${doi}`;
});
/** 无可展示序号或全部 id 已失效时,直接移除节点,不保留空标签 */
if (!label) {
ed.dom.remove(el);
return;
}
el.textContent = `[${label}]`;
el.style.display = '';
ed.dom.setAttrib(el, 'title', lines.join('\n'));
/** 不再使用 data-cite-missing 红底提示,角标样式与普通引用一致 */
ed.dom.setAttrib(el, 'data-cite-missing', null);
});
this.padAutociteCaretPlaceholder(ed);
},
@@ -265,7 +554,7 @@ export default {
const doc = ed.getDoc();
const body = doc.body;
if (!body) return;
body.querySelectorAll('autocite').forEach((el) => {
body.querySelectorAll('mycite').forEach((el) => {
const next = el.nextSibling;
if (next === null) {
el.parentNode.appendChild(doc.createTextNode('\u200b'));
@@ -294,7 +583,7 @@ export default {
if (this._refBookmark) {
ed.selection.moveToBookmark(this._refBookmark);
}
ed.insertContent(`<autocite data-id="${escaped}" contenteditable="false"></autocite>&#8203;`);
ed.insertContent(`<mycite data-id="${escaped}" contenteditable="false"></mycite>&#8203;`);
}
this.renderAutociteInEditor(ed);
},
@@ -306,7 +595,7 @@ export default {
ed.fire('change');
},
/**
* 从当前编辑的 autocite 中去掉指定 p_refer_id去掉后无 id 则删除整段标签。
* 从当前编辑的 mycite 中去掉指定 p_refer_id去掉后无 id 则删除整段标签。
* ids 为空:删除整段(与 removeAutocite 一致)。
*/
stripAutociteIds(idsToRemove) {
@@ -331,13 +620,14 @@ export default {
}
ed.fire('change');
},
/** TinyMCE 会剔除「空」的行内标签;空 autocite 必须在入编辑器前占位,否则合并引用 [13] 等整段消失 */
/** TinyMCE 会剔除「空」的行内标签;空 mycite 必须在入编辑器前占位,否则合并引用 [13] 等整段消失 */
normalizeAutociteHtmlForEditor(html) {
if (!html || typeof html !== 'string') return html;
let out = html.replace(/<autocite([^>]*)>\s*<\/autocite>/gi, '<autocite$1>&#8203;</autocite>');
// 与 getSafeContent 标签边界逻辑一致autocite 左右水平空白改为 &nbsp;,不换行符(避免吃掉段间 \n
out = out.replace(/[^\S\r\n]+(?=<autocite\b)/gi, '&nbsp;');
out = out.replace(/(?<=<\/autocite>)[^\S\r\n]+/gi, '&nbsp;');
/** 角标显示一律由 renderAutociteInEditor 根据 data-id 生成,禁止保留库内遗留的 [1-4]、[14] 等旧文案 */
let out = html.replace(/<mycite([^>]*)>[\s\S]*?<\/mycite>/gi, '<mycite$1>&#8203;</mycite>');
// 外侧:连续空格 / &nbsp; 合并为单个 &nbsp;,避免「普通空格 + &nbsp;」叠成大缝
out = out.replace(/(?:\s|&nbsp;|&#160;)+(?=<mycite\b)/gi, '&nbsp;');
out = out.replace(/(?<=<\/mycite>)(?:\s|&nbsp;|&#160;)+/gi, '&nbsp;');
return out;
},
handleSetContent(val) {
@@ -351,8 +641,11 @@ export default {
this.editorInstance.setContent(finalContent);
// 渲染数学公式
// SetContent 回调里会 renderAutociteInEditor再补一次 nextTick避免时序下仍显示库内旧 [1-4]
this.$nextTick(() => {
if (this.editorInstance) {
this.renderAutociteInEditor(this.editorInstance);
}
if (window.renderMathJax) {
window.renderMathJax(this.tinymceId);
}
@@ -611,7 +904,7 @@ export default {
return new Blob([u8arr], { type: mime });
},
formatHtml(val) {
const rawValue = this.normalizeAutociteHtmlForEditor(val || ''); // 须先于 cleanEmptyTags否则空 autocite 被删
const rawValue = this.normalizeAutociteHtmlForEditor(val || ''); // 须先于 cleanEmptyTags否则空 mycite 被删
const cleanEmptyTags = /<([a-zA-Z1-6]+)\b[^>]*><\/\1>/g;
const replaceSpaces = /\s+(?=<)|(?<=>)\s+/g;
const removeBr = /<br\s*\/?>/gi; // 移除所有 br 标签
@@ -677,10 +970,10 @@ export default {
window.tinymce.init({
..._this.tinymceOtherInit,
trim_span_elements: false, // 禁止修剪内联标签周围的空格
extended_valid_elements: 'blue[*],autocite[*]',
extended_valid_elements: 'blue[*],mycite[*]',
/* ~ 前缀:按行内(类似 span处理否则默认当块级会拆段导致引用后强制换行 */
custom_elements: 'blue,~autocite',
valid_children: '+blue[#text|i|em|b|strong|span],+body[blue|autocite],+p[blue|autocite]',
custom_elements: 'blue,~mycite',
valid_children: '+blue[#text|i|em|b|strong|span],+body[blue|mycite],+p[blue|mycite]',
inline: false, // 使用 iframe 模式
selector: `#${this.tinymceId}`,
@@ -690,7 +983,7 @@ export default {
valid_elements:
this.type == 'table'
? '*[*]'
: `img[src|alt|width|height],strong,em,sub,sup,blue,table,b,i,myfigure,mytable,wmath,autocite[data-id|contenteditable|title]${this.valid_elements}`, // 允许的标签和属性
: `img[src|alt|width|height],strong,em,sub,sup,blue,table,b,i,myfigure,mytable,wmath,mycite[data-id|contenteditable|title|data-cite-missing|style]${this.valid_elements}`, // 允许的标签和属性
// valid_elements: '*[*]', // 允许所有 HTML 标签
noneditable_editable_class: 'MathJax',
height: this.height,
@@ -723,7 +1016,7 @@ export default {
}
/* inline 与 blue 引用一致,避免 inline-block 在行尾产生多余换行感 */
autocite {
mycite {
display: inline;
vertical-align: baseline;
color: rgb(0, 130, 170);
@@ -736,10 +1029,19 @@ export default {
font-size: 12px;
white-space: nowrap;
}
autocite:hover {
mycite:hover {
background-color: rgba(0, 130, 170, 0.2);
text-shadow: 0 0 3px rgba(0, 130, 170, 0.3);
}
mycite[data-cite-missing] {
background-color: rgba(0, 130, 170, 0.08) !important;
color: rgb(0, 130, 170) !important;
text-shadow: 0 0 3px rgba(0, 130, 170, 0.3);
box-shadow: none !important;
}
mycite[data-cite-missing]:hover {
background-color: rgba(0, 130, 170, 0.2) !important;
}
@keyframes blueGlow {
0%,
@@ -764,17 +1066,31 @@ export default {
const hasCustom =
(Array.isArray(tb) && tb.length > 0) ||
(typeof tb === 'string' && tb.length > 0);
/* 与 content.vue 默认一致,且含 insertRef避免未传 toolbar 时引用到未定义的变量 */
const refInsert = _this.showRefButton ? ' insertRef' : '';
/* 自动匹配 [n] 为角标:底部蓝色按钮,见模板 tinymce-autolink-footer */
/* 与 content.vue 默认一致;含 insertRef 由 showRefButton 控制 */
const defaultToolbar = [
'bold italic |customBlue removeBlue|LateX insertRef| myuppercase myuppercasea Line MoreSymbols|subscript superscript|clearButton|searchreplace'
`bold italic |customBlue removeBlue|LateX${refInsert}| myuppercase myuppercasea Line MoreSymbols|subscript superscript|clearButton|searchreplace`
];
if (!hasCustom) return defaultToolbar;
const rows = Array.isArray(tb) ? tb : [tb];
return rows.map((row) =>
typeof row === 'string' && row.indexOf('insertRef') === -1
? row.replace(/\|LateX\|/g, '|LateX insertRef|').replace(/\|\s*LateX\s*\|/g, '| LateX insertRef |')
: row
);
return rows.map((row) => {
if (typeof row !== 'string') return row;
let out = row;
if (!_this.showRefButton) {
out = out
.replace(/\binsertRef\b/g, '')
.replace(/\bautoLinkRefs\b/g, '')
.replace(/\|\s*\|/g, '|')
.replace(/\|\s*$/g, '')
.replace(/^\s*\|/g, '');
} else if (out.indexOf('insertRef') === -1) {
out = out
.replace(/\|LateX\|/g, '|LateX insertRef|')
.replace(/\|\s*LateX\s*\|/g, '| LateX insertRef |');
}
return out;
});
})(),
menubar: false, // 启用菜单栏并保持必要的项目
statusbar: false, // 关闭底部状态栏
@@ -844,7 +1160,7 @@ export default {
var currentWmathElement = null;
ed.ui.registry.addButton('insertRef', {
text: 'Ref',
text: 'Reference',
tooltip: 'Insert Reference',
onAction: function () {
const refs = Array.isArray(_this.chanFerForm) ? _this.chanFerForm : [];
@@ -859,7 +1175,7 @@ export default {
});
ed.on('click', function (e) {
const autociteEl = e.target.closest('autocite');
const autociteEl = e.target.closest('mycite');
if (autociteEl) {
const refs = Array.isArray(_this.chanFerForm) ? _this.chanFerForm : [];
if (refs.length === 0) {
@@ -1003,6 +1319,8 @@ export default {
subtree: true,
characterData: true
});
_this.mountAutoLinkFooterInsideEditor(ed);
});
// 定义自定义按钮
@@ -1045,11 +1363,11 @@ export default {
ed.on('GetContent', function (e) {
e.content = e.content.replace(/<b>/g, '<strong>').replace(/<\/b>/g, '</strong>');
e.content = e.content.replace(/<i>/g, '<em>').replace(/<\/em>/g, '</em>');
e.content = e.content.replace(/<\/autocite>\s*&#8203;/gi, '</autocite>');
e.content = e.content.replace(/<\/autocite>\s*\u200b/g, '</autocite>');
e.content = e.content.replace(/<autocite([^>]*)>[^<]*<\/autocite>/gi, function (match, attrs) {
e.content = e.content.replace(/<\/mycite>\s*&#8203;/gi, '</mycite>');
e.content = e.content.replace(/<\/mycite>\s*\u200b/g, '</mycite>');
e.content = e.content.replace(/<mycite([^>]*)>[^<]*<\/mycite>/gi, function (match, attrs) {
var clean = attrs.replace(/\s*style="[^"]*"/gi, '').replace(/\s*title="[^"]*"/gi, '').replace(/\s*contenteditable="[^"]*"/gi, '');
return '<autocite' + clean + '></autocite>';
return '<mycite' + clean + '></mycite>';
});
});
},
@@ -1210,6 +1528,7 @@ export default {
//销毁富文本
destroyTinymce() {
this.onClear();
this.restoreAutoLinkFooterDom();
if (window.tinymce.get(this.tinymceId)) {
window.tinymce.get(this.tinymceId).destroy();
}
@@ -1226,7 +1545,8 @@ export default {
content = content.replace(/<span[^>]*>/g, '').replace(/<\/span>/g, ''); // 去除span标签
content = content.replace(/<strong>/g, '<b>').replace(/<\/strong>/g, '</b>');
content = content.replace(/<em>/g, '<i>').replace(/<\/em>/g, '</i>');
content = content.replace(/&nbsp;/g, ' '); // 将所有 &nbsp; 替换为空格
content = content.replace(/&nbsp;/g, ' '); // 先统一为空格
content = this.normalizeAutociteHtmlForEditor(content); // mycite 两侧水平空白以 &nbsp; 存库(与入编辑器一致)
this.$emit('getContent', type, content);
},
@@ -1311,6 +1631,26 @@ export default {
};
</script>
<style scoped>
.tinymce-editor-surface {
width: 100%;
}
.tinymce-autolink-footer {
display: flex;
justify-content: flex-end;
align-items: center;
padding-top: 8px;
margin-top: 4px;
border-top: 1px solid #ebeef5;
}
/* 移入 .tox-tinymce 内部后:贴编辑区下沿、与编辑器同宽同框 */
::v-deep .tox-tinymce > .tinymce-autolink-footer {
margin-top: 0;
padding: 0px 10px;
box-sizing: border-box;
border-top: 1px solid #dcdfe6;
background: #fafafa;
flex-shrink: 0;
}
::v-deep .tox-tinymce-aux {
z-index: 9999 !important;
}

View File

@@ -10,12 +10,15 @@
ref="tinymceChild1"
:wordStyle="wordStyle"
:isAutomaticUpdate="isAutomaticUpdate"
:showRefButton="showRefButton"
@getContent="getContent"
@openLatexEditor="openLatexEditor"
@openRefSelector="openRefSelector"
@updateChange="updateChange"
@input="onTinymceInput"
:value="value"
:chanFerForm="chanFerForm"
:bodyCiteIdOrder="bodyCiteIdOrder"
:typesettingType="typesettingType"
class="paste-area text-container"
:toolbar="toolbarConfig"
@@ -38,14 +41,29 @@
<script>
import Tinymce from '@/components/page/components/Tinymce';
export default {
props: ['value', 'isAutomaticUpdate', 'height', 'id', 'chanFerForm'],
props: {
value: {},
isAutomaticUpdate: {},
height: {},
id: {},
chanFerForm: {},
/** 全文 mycite 顺序,与稿面 word.vue 的 bodyCiteIdOrder 一致,用于弹窗内 [1][2] 与参考文献重排 */
bodyCiteIdOrder: {
type: Array,
default: () => []
},
/** false标题等场景不显示 Ref */
showRefButton: {
type: Boolean,
default: true
}
},
components: {
Tinymce
},
computed: {
toolbarConfig() {
// Ref 必须始终出现在配置里TinyMCE 只在 init 时读一次 toolbar若首屏 chanFerForm 为空则按钮会永久缺失
const refBtn = ' insertRef';
const refBtn = this.showRefButton ? ' insertRef' : '';
if (!this.isAutomaticUpdate) {
return [`bold italic |customBlue removeBlue|LateX${refBtn}| myuppercase myuppercasea Line MoreSymbols|subscript superscript|clearButton|searchreplace`];
}
@@ -102,24 +120,35 @@ export default {
openRefSelector(data) {
this.$emit('openRefSelector', data);
},
onTinymceInput(html) {
this.$emit('editorInput', html);
},
insertAutocite(refId) {
this.$refs.tinymceChild1.insertAutocite(refId);
},
removeAutocite() {
this.$refs.tinymceChild1.removeAutocite();
},
/** 从当前 autocite 的 data-id 中移除指定 id无剩余则删标签未选 id 时删整段 */
/** 从当前 mycite 的 data-id 中移除指定 id无剩余则删标签未选 id 时删整段 */
stripAutociteIds(ids) {
if (this.$refs.tinymceChild1 && typeof this.$refs.tinymceChild1.stripAutociteIds === 'function') {
this.$refs.tinymceChild1.stripAutociteIds(ids);
}
},
/** 参考文献异步到达后刷新编辑器内 autocite 数字(供父组件在打开弹窗 / fetch 完成后调用) */
/** 参考文献异步到达后刷新编辑器内 mycite 数字(供父组件在打开弹窗 / fetch 完成后调用) */
refreshAutociteDisplay() {
const t = this.$refs.tinymceChild1;
if (t && typeof t.renderAutociteInEditor === 'function' && t.editorInstance) {
t.renderAutociteInEditor(t.editorInstance);
}
},
/** 将正文中的 [1]、[13] 等按全局序号匹配为 <mycite> */
convertBracketRefsToAutocite() {
const t = this.$refs.tinymceChild1;
if (t && typeof t.convertPlainBracketCitesToAutocite === 'function') {
return t.convertPlainBracketCitesToAutocite();
}
return { replaced: 0 };
}
}
};

View File

@@ -5,8 +5,13 @@
:articleId="articleId"
ref="tinymceChild1"
:wordStyle="wordStyle"
:show-ref-button="showRefButton"
:chanFerForm="chanFerForm"
:bodyCiteIdOrder="bodyCiteIdOrder"
@getContent="getContent"
@openLatexEditor="openLatexEditor"
@openRefSelector="$emit('openRefSelector', $event)"
@input="onTableEditorInput"
:height="calcDynamicWidth()"
:value="updatedHtml"
:typesettingType="typesettingType"
@@ -22,7 +27,24 @@
<script>
import Tinymce from '@/components/page/components/Tinymce';
export default {
props: ['lineStyle', 'articleId'],
props: {
lineStyle: {},
articleId: {},
/** 与稿面/段落编辑一致,供 Ref 插入;未传时 TinyMCE 内为空会提示「参考文献尚未加载」 */
chanFerForm: {
type: Array,
default: () => []
},
bodyCiteIdOrder: {
type: Array,
default: () => []
},
/** 与 content.vue 一致:为 true 时显示 Reference、Auto-link References稿面表格编辑需文献角标时保持 true */
showRefButton: {
type: Boolean,
default: true
}
},
components: {
Tinymce
},
@@ -91,6 +113,9 @@ export default {
}
},
methods: {
onTableEditorInput(html) {
this.$emit('editorInput', html);
},
openLatexEditor(data) {
this.$emit('openLatexEditor', data);
},
@@ -121,6 +146,22 @@ export default {
} else {
this.$emit('getContent', type, { html_data: '', table: [] });
}
},
insertAutocite(refIds) {
if (this.$refs.tinymceChild1 && typeof this.$refs.tinymceChild1.insertAutocite === 'function') {
this.$refs.tinymceChild1.insertAutocite(refIds);
}
},
stripAutociteIds(ids) {
if (this.$refs.tinymceChild1 && typeof this.$refs.tinymceChild1.stripAutociteIds === 'function') {
this.$refs.tinymceChild1.stripAutociteIds(ids);
}
},
refreshAutociteDisplay() {
const t = this.$refs.tinymceChild1;
if (t && typeof t.renderAutociteInEditor === 'function' && t.editorInstance) {
t.renderAutociteInEditor(t.editorInstance);
}
}
}
};

View File

@@ -103,22 +103,21 @@
v-if="
!isPreview &&
isEditComment &&
item.proof_read_num &&
item.proof_read_num > 0 &&
item.is_proofread == 1 &&
isProofreadEnabled(item) &&
resolvedProofreadNum(item) > 0 &&
item.type == 0 &&
item.content != ''
"
class="proofreading-num"
>{{ item.proof_read_num }}</span
>{{ resolvedProofreadNum(item) }}</span
>
<span
@click="handleClickProofreadingList([item.am_id])"
v-if="
!isPreview &&
isEditComment &&
item.proof_read_num == -1 &&
item.is_proofread == 1 &&
isProofreadEnabled(item) &&
resolvedProofreadNum(item) === -1 &&
item.type == 0 &&
item.content != ''
"
@@ -131,8 +130,8 @@
v-if="
!isPreview &&
isEditComment &&
item.proof_read_num == 0 &&
item.is_proofread == 1 &&
isProofreadEnabled(item) &&
resolvedProofreadNum(item) === 0 &&
item.type == 0 &&
item.content != ''
"
@@ -140,19 +139,11 @@
style="color: #006699e0"
><i class="el-icon-success"></i
></span>
<span
@click="handleClickProofreadingList([item.am_id])"
v-if="!isPreview && isEditComment && item.is_proofread == 2 && item.type == 0 && item.content != ''"
class="Proofreadingstatus"
style="color: #e6a23c"
><i class="el-icon-warning"></i
></span>
<span
@click="handleClickProofreadingList([item.am_id])"
class="isRemark"
:remark-main-id="item.am_id"
v-if="(item.proof_read_num > 0 || item.proof_read_num == 0) && !isPreview && !isShowPiZhu"
v-if="(resolvedProofreadNum(item) > 0 || resolvedProofreadNum(item) === 0) && !isPreview && !isShowPiZhu"
>
<img
class="isRemark base-margin"
@@ -982,13 +973,22 @@
<div v-if="isEditComment" class="menu-item comment-feat" @mousedown="cacheSelection" @click="menuAction('comment')">
<i class="el-icon-chat-line-square"></i><span>{{ $t('commonTable.Annotations') }}</span>
</div>
<div class="row-divider" v-if="currentData.type == 0 && (isEditComment || manuscriptAutociteContext)"></div>
<div
class="row-divider"
v-if="(currentData.type == 0 || currentData.type == 1) && (isEditComment || manuscriptAutociteContext)"
></div>
<template v-if="manuscriptAutociteContext && currentData && currentData.type == 0">
<template
v-if="
manuscriptAutociteContext &&
currentData &&
(currentData.type == 0 || currentData.type == 1 || currentData.type == 2)
"
>
<div class="menu-item menu-autocite-ref" @click.stop="menuAction('editRefCite')">
<i class="el-icon-edit-outline"></i><span>{{ $t('wordCite.modifyRef') }}</span>
</div>
<div class="menu-item menu-autocite-ref" @click.stop="menuAction('removeRefCite')">
<div class="menu-item danger" @click.stop="menuAction('removeRefCite')">
<i class="el-icon-remove-outline"></i><span>{{ $t('wordCite.removeRefTag') }}</span>
</div>
</template>
@@ -1053,6 +1053,7 @@ const toolbar = 'addImageButton ';
import { TableUtils } from '@/common/js/TableUtils';
import { debounce, throttle } from '@/common/js/debounce';
import { tableStyle, commonWordStyle } from '@/utils/tinymceStyles';
import { extractAutociteIdsFromHtmlString } from '@/utils/autociteHtml.js';
export default {
name: 'tinymce',
components: {},
@@ -1097,6 +1098,11 @@ export default {
return [];
}
},
/** 父组件传入的正文 mycite 顺序(与 GenerateCharts 的 articleCiteIdOrder 一致);未传时仍用稿面扫描得到的 bodyCiteIdOrder */
citeOrderFromParent: {
type: Array,
default: null
},
comments: {
type: [Array, Object], // 允许数组或对象
@@ -1180,8 +1186,11 @@ export default {
editorsInitialized: {}, // 用于存储每个编辑器实例
mediaUrl: mediaUrl, //
lastTag: null,
/** 稿面点击 <autocite> 时记录,用于浮动条「修改引用 / 移除引用」 */
/** 稿面点击 <mycite> 时记录,用于浮动条「修改引用 / 移除引用」 */
manuscriptAutociteContext: null,
/** 正文 DOM 中 mycite 首次出现顺序(唯一 p_refer_id驱动 citeMap 与参考文献列表排序 */
bodyCiteIdOrder: [],
_syncRefOrderTimer: null,
isEditComment: false,
isUserEditComment: false,
typesettingType: 1,
@@ -1256,11 +1265,24 @@ export default {
this.$nextTick(() => {
window.renderMathJax(); // 主动触发 MathJax 渲染
this.getCommentsData();
this.scheduleSyncRefOrder();
});
}
},
deep: true // 启用深度监听
},
wordList: {
handler() {
this.scheduleSyncRefOrder();
},
deep: true
},
chanFerForm: {
handler() {
this.scheduleSyncRefOrder();
},
deep: true
},
totalNumbers: {
handler(val) {
if (val == 0) {
@@ -1272,45 +1294,66 @@ export default {
},
computed: {
citeMap() {
// 1) 优先使用参考文献列表顺序(与右侧 References 一致)
const mapFromRefs = {};
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
refs.forEach((row, idx) => {
const key = row && row.p_refer_id != null ? String(row.p_refer_id) : '';
if (key) mapFromRefs[key] = idx + 1;
});
if (Object.keys(mapFromRefs).length > 0) return mapFromRefs;
const refIdSet = new Set(
refs.map((r) => (r && r.p_refer_id != null ? String(r.p_refer_id) : '')).filter(Boolean)
);
// 2) 兜底:按正文首次出现顺序编号(支持 data-id 含多个逗号分隔 id
const order = [];
const re = /<autocite\s+data-id="([^"]+)"/gi;
const paragraphs =
Array.isArray(this.wordList) && this.wordList.length > 0
? this.wordList
: Array.isArray(this.contentList)
? this.contentList
: [];
let orderedIds = [];
if (Array.isArray(this.citeOrderFromParent) && this.citeOrderFromParent.length > 0) {
orderedIds = this.citeOrderFromParent.map(String);
} else if (Array.isArray(this.bodyCiteIdOrder) && this.bodyCiteIdOrder.length > 0) {
orderedIds = this.bodyCiteIdOrder.map(String);
} else {
const order = [];
const paragraphs =
Array.isArray(this.wordList) && this.wordList.length > 0
? this.wordList
: Array.isArray(this.contentList)
? this.contentList
: [];
paragraphs.forEach((p) => {
const candidates = [];
if (p && typeof p.text === 'string') candidates.push(p.text);
if (p && typeof p.content === 'string') candidates.push(p.content);
candidates.forEach((raw) => {
re.lastIndex = 0;
let m;
while ((m = re.exec(raw)) !== null) {
m[1].split(',').forEach((part) => {
const id = part.trim();
if (id && !order.includes(id)) order.push(id);
paragraphs.forEach((p) => {
const candidates = [];
if (p && typeof p.text === 'string') candidates.push(p.text);
if (p && typeof p.content === 'string') candidates.push(p.content);
candidates.forEach((raw) => {
extractAutociteIdsFromHtmlString(raw).forEach((id) => {
if (!order.includes(id)) order.push(id);
});
}
});
});
orderedIds = order.map(String);
}
// 无正文顺序时:按参考文献表当前行序编号(与列表 # 列一致)
if (orderedIds.length === 0) {
const map = {};
refs.forEach((row, idx) => {
const key = row && row.p_refer_id != null ? String(row.p_refer_id) : '';
if (key) map[key] = idx + 1;
});
return map;
}
/** 仅对「当前列表里真实存在」的 id 依正文首次出现顺序编号;正文曾出现但列表已删的 id 不占号 */
const filtered = [];
orderedIds.forEach((id) => {
if (refIdSet.has(id) && !filtered.includes(id)) filtered.push(id);
});
return order.reduce((acc, id, idx) => {
acc[id] = idx + 1;
return acc;
}, {});
const map = {};
filtered.forEach((id, idx) => {
map[id] = idx + 1;
});
let next = filtered.length + 1;
refs.forEach((r) => {
const key = r && r.p_refer_id != null ? String(r.p_refer_id) : '';
if (!key || map[key] != null) return;
map[key] = next++;
});
return map;
},
sortedProofreadingList() {
const order = [2, 1, 3];
@@ -1339,6 +1382,7 @@ export default {
this.$nextTick(() => {
window.renderMathJax(); // 主动触发 MathJax 渲染
this.getCommentsData();
this.scheduleSyncRefOrder();
});
this.$refs.scrollDiv.addEventListener('scroll', this.divOnScroll, { passive: true });
@@ -1397,6 +1441,7 @@ export default {
this.$nextTick(() => {
window.renderMathJax(); // 主动触发 MathJax 渲染
this.getCommentsData();
this.scheduleSyncRefOrder();
});
this.$refs.scrollDiv.addEventListener('scroll', this.divOnScroll, { passive: true });
},
@@ -1415,17 +1460,31 @@ export default {
// window.removeEventListener('resize', this.calcMarkers);
if (this.resizeObs) this.resizeObs.disconnect();
if (this.mutObs) this.mutObs.disconnect();
// 页面销毁前清理定时器和事件监听器
if (this._syncRefOrderTimer) clearTimeout(this._syncRefOrderTimer);
},
destroy() {
this.destroyTinymce();
this.editors = {};
},
methods: {
// 1. 引用序号合并:两项连续 1, 2三项及以上连续 1-3与富文本 autocite data-id 多 id 一致)
/** 合并引用 tooltip / title 行按 citeMap 排序 */
sortAutociteIdsByCiteNumber(ids) {
const uniq = [...new Set(ids)];
const map = this.citeMap || {};
return uniq.sort((a, b) => {
const na = map[a];
const nb = map[b];
const ha = na != null && na !== '';
const hb = nb != null && nb !== '';
if (ha && hb) return Number(na) - Number(nb);
if (ha) return -1;
if (hb) return 1;
return String(a).localeCompare(String(b));
});
},
/** 多项连续时合并为 13仅用于「同组多 id 且均在列表中」的全局序号展示 */
formatCiteNumbers(nums) {
if (!nums || !nums.length) return "";
if (!nums || !nums.length) return '';
const sorted = [...new Set(nums)].sort((a, b) => a - b);
const result = [];
let i = 0;
@@ -1442,28 +1501,17 @@ formatCiteNumbers(nums) {
return result.join(', ');
},
/** 合并引用 tooltip / title 行按正文序号排列,与 [24, 6] 标签一致 */
sortAutociteIdsByCiteNumber(ids) {
const uniq = [...new Set(ids)];
const map = this.citeMap || {};
return uniq.sort((a, b) => {
const na = map[a];
const nb = map[b];
const ha = na != null && na !== '';
const hb = nb != null && nb !== '';
if (ha && hb) return Number(na) - Number(nb);
if (ha) return -1;
if (hb) return 1;
return String(a).localeCompare(String(b));
});
},
// 2. 最终引用标签渲染器 (将 autocite 转换为 [n]);支持 data-id="a,b,c" 单标签 或 多个相邻单 id 标签(旧数据)
// 2. 最终引用标签渲染器 (将 mycite 转换为 [n]);支持 data-id="a,b,c" 单标签 或 多个相邻单 id 标签(旧数据)
renderCiteLabels(html) {
const citeGroupRe = /(?:<autocite\s+data-id="([^"]+)"\s*><\/autocite>\s*)+/gi;
return html.replace(citeGroupRe, (groupMatch) => {
// 库内若曾写入 &nbsp;[1]&nbsp; 等,与外侧空格叠缝;先规范为空标签再按组合并渲染
let normalized = String(html || '').replace(
/<mycite\s+data-id="([^"]*)"[^>]*>[\s\S]*?<\/mycite>/gi,
'<mycite data-id="$1"></mycite>'
);
const citeGroupRe = /(?:<mycite\s+data-id="([^"]*)"\s*><\/mycite>\s*)+/gi;
return normalized.replace(citeGroupRe, (groupMatch) => {
const ids = [];
const innerRe = /<autocite\s+data-id="([^"]+)"\s*><\/autocite>/gi;
const innerRe = /<mycite\s+data-id="([^"]*)"\s*><\/mycite>/gi;
let m;
while ((m = innerRe.exec(groupMatch)) !== null) {
m[1].split(',').forEach((part) => {
@@ -1478,34 +1526,56 @@ renderCiteLabels(html) {
return acc;
}, {});
const sortedIds = this.sortAutociteIdsByCiteNumber(ids);
const nums = sortedIds.map((id) => this.citeMap && this.citeMap[id]).filter((n) => n != null);
const label = nums.length > 0 ? this.formatCiteNumbers(nums) : '?';
const sortedAll = this.sortAutociteIdsByCiteNumber(ids);
const validIds = sortedAll.filter((id) => refMap[id]);
const sortedIds = validIds.length ? this.sortAutociteIdsByCiteNumber(validIds) : [];
const citeMap = this.citeMap || {};
const lines = sortedIds.map((id) => {
const no = this.citeMap && this.citeMap[id] != null ? this.citeMap[id] : '?';
/** 与 Tinymce已删除的 id 不进入角标与 title仅保留仍存在于列表中的文献 */
const parts = sortedIds.map((id) => {
const ref = refMap[id];
if (!ref) {
return this.$t('wordCite.notFoundById', { id: no === '?' ? id : no });
}
const content =
ref.refer_frag ||
[ref.author, ref.title, ref.joura, ref.dateno].filter(Boolean).join(' ').trim() ||
'[?]';
const doi = ref.doilink || ref.isbn || ref.doi || '[?]';
return `[${no}] ${content}&#10;DOI: ${doi}`;
const no = ref ? citeMap[id] : null;
const num = no != null && no !== '' ? String(no) : null;
return { id, ref, num };
});
const numsForLabel = parts
.map((p) => p.num)
.filter((n) => n != null && n !== '')
.map(Number);
const label =
numsForLabel.length > 0 ? this.formatCiteNumbers(numsForLabel) : '';
const lines = parts
.filter((p) => p.ref)
.map((p) => {
const ref = p.ref;
const content =
ref.refer_frag ||
[ref.author, ref.title, ref.joura, ref.dateno].filter(Boolean).join(' ').trim() ||
'';
const doi = ref.doilink || ref.isbn || ref.doi || '';
const numLabel = p.num;
if (numLabel) {
return `[${numLabel}] ${content}\nDOI: ${doi}`;
}
return `${content}\nDOI: ${doi}`;
});
const escAttr = (s) =>
String(s || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
if (!label) {
return '';
}
const dataIdAttr = escAttr(sortedIds.join(','));
const titleAttr = escAttr(lines.join('\n'));
return `<autocite data-id="${dataIdAttr}" title="${titleAttr}">[${label}]</autocite>`;
});
return `<mycite data-id="${dataIdAttr}" title="${titleAttr}">[${label}]</mycite>`;
})
/** 库内遗留:空 data-id 或仅零宽字符的 mycite 整段去掉 */
.replace(/<mycite[^>]*data-id=""[^>]*>[\s\S]*?<\/mycite>/gi, '');
},
getInvolvedPMain(range) {
// 1. 找到起始节点所属的 .pMain
@@ -2110,11 +2180,25 @@ renderCiteLabels(html) {
});
return parts.join(',');
},
/** 按 data-id 替换第一段匹配的 <autocite>;兼容稿面已排序、正文存储未排序的情况 */
/** 按 data-id 替换第一段匹配的 <mycite>;兼容稿面已排序、正文存储未排序的情况 */
replaceFirstAutociteByDataId(html, dataId, replacement) {
if (!html || typeof html !== 'string' || dataId == null || dataId === '') return html;
if (!html || typeof html !== 'string') return html;
/** 删除整段且 data-id 为空:移除第一个 data-id 为空的 <mycite>(与浮动条「移除引用」一致) */
if (replacement === '' && (dataId == null || dataId === '')) {
let done = false;
return html.replace(/<mycite\s([^>]*)>([\s\S]*?)<\/mycite>/gi, function (full, attrs) {
if (done) return full;
const dm =
attrs.match(/\bdata-id\s*=\s*"([^"]*)"/i) || attrs.match(/\bdata-id\s*=\s*'([^']*)'/i);
const idVal = dm ? String(dm[1]).trim() : '';
if (idVal !== '') return full;
done = true;
return '';
});
}
if (dataId == null || dataId === '') return html;
const esc = String(dataId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const reExact = new RegExp(`<autocite\\s[^>]*\\bdata-id="${esc}"[^>]*>[\\s\\S]*?<\\/autocite>`, 'i');
const reExact = new RegExp(`<mycite\\s[^>]*\\bdata-id="${esc}"[^>]*>[\\s\\S]*?<\\/mycite>`, 'i');
let out = html.replace(reExact, replacement);
if (out !== html) return out;
@@ -2123,7 +2207,7 @@ renderCiteLabels(html) {
const _this = this;
let done = false;
return html.replace(/<autocite\s([^>]*)>([\s\S]*?)<\/autocite>/gi, function (full, attrs) {
return html.replace(/<mycite\s([^>]*)>([\s\S]*?)<\/mycite>/gi, function (full, attrs) {
if (done) return full;
const dm = attrs.match(/\bdata-id\s*=\s*"([^"]*)"/i) || attrs.match(/\bdata-id\s*=\s*'([^']*)'/i);
if (!dm) return full;
@@ -2146,14 +2230,177 @@ renderCiteLabels(html) {
.filter(Boolean);
this.$emit('openRefSelector', { currentRefIds: parts, source: 'manuscript' });
},
/** 表格局部:按表头→表体、从左到右遍历单元格 */
forEachTableCellInOrder(item, fn) {
const t = item.table;
if (!t) return;
const walk = (rows) => {
if (!Array.isArray(rows)) return;
for (let r = 0; r < rows.length; r++) {
const row = rows[r];
if (!Array.isArray(row)) continue;
for (let c = 0; c < row.length; c++) {
const cell = row[c];
if (cell && typeof cell.text === 'string') {
if (fn(cell) === true) return true;
}
}
}
return false;
};
if (walk(t.tableHeader)) return;
walk(t.tableContent);
},
mergeTableDataForSave(item) {
const t = item.table;
if (!t) return [];
const cloneRow = (row) => {
if (!Array.isArray(row)) return row;
return row.map((cell) => {
if (!cell || typeof cell !== 'object') return cell;
const { rowId, cellId, ...rest } = cell;
return { ...rest };
});
};
return [...(t.tableHeader || []).map(cloneRow), ...(t.tableContent || []).map(cloneRow)];
},
applyFirstAutociteReplaceInTable(item, ctx, newTag) {
let changed = false;
this.forEachTableCellInOrder(item, (cell) => {
const n = this.replaceFirstAutociteByDataId(cell.text, ctx.dataId, newTag);
if (n !== cell.text) {
cell.text = n;
changed = true;
return true;
}
});
if (changed) return true;
const t = item.table;
if (!t) return false;
for (const field of ['title', 'note']) {
if (typeof t[field] !== 'string' || !t[field]) continue;
const n = this.replaceFirstAutociteByDataId(t[field], ctx.dataId, newTag);
if (n !== t[field]) {
this.$set(t, field, n);
return true;
}
}
return false;
},
stripAutociteInHtmlString(html, ctx, removeSet) {
if (!html || typeof html !== 'string') return html;
if (removeSet.size === 0) {
return this.replaceFirstAutociteByDataId(html, ctx.dataId, '');
}
const keyWant = this.normalizeAutociteDataIdKey(ctx.dataId);
const _this = this;
let done = false;
return html.replace(/<mycite\s([^>]*)>([\s\S]*?)<\/mycite>/gi, function (full, attrs) {
if (done) return full;
const dm = attrs.match(/\bdata-id\s*=\s*"([^"]*)"/i) || attrs.match(/\bdata-id\s*=\s*'([^']*)'/i);
if (!dm) return full;
if (_this.normalizeAutociteDataIdKey(dm[1]) !== keyWant) return full;
done = true;
const parts = dm[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const remaining = parts.filter((id) => !removeSet.has(String(id)));
if (remaining.length === 0) return '';
const sorted = _this.sortAutociteIdsByCiteNumber(remaining);
const newDataId = sorted.join(',');
return `<mycite data-id="${_this.escapeHtmlAttr(newDataId)}"></mycite>`;
});
},
applyStripToFirstMatchingTableCell(item, ctx, removeSet) {
let changed = false;
this.forEachTableCellInOrder(item, (cell) => {
const nh = this.stripAutociteInHtmlString(cell.text, ctx, removeSet);
if (nh !== cell.text) {
cell.text = nh;
changed = true;
return true;
}
});
if (changed) return true;
const t = item.table;
if (!t) return false;
for (const field of ['title', 'note']) {
if (typeof t[field] !== 'string' || !t[field]) continue;
const nh = this.stripAutociteInHtmlString(t[field], ctx, removeSet);
if (nh !== t[field]) {
this.$set(t, field, nh);
return true;
}
}
return false;
},
/** 图片说明/标题中的 autocite与表格 note 同理 */
applyStripToFirstMatchingImageField(item, ctx, removeSet) {
const img = item.image;
if (!img) return false;
for (const field of ['note', 'title']) {
if (typeof img[field] !== 'string' || !img[field]) continue;
const nh = this.stripAutociteInHtmlString(img[field], ctx, removeSet);
if (nh !== img[field]) {
this.$set(img, field, nh);
return true;
}
}
return false;
},
emitSaveTableManuscript(item) {
this.manuscriptAutociteContext = null;
this.$emit('saveTableManuscript', {
amt_id: item.amt_id,
am_id: item.am_id,
table_data: JSON.stringify(this.mergeTableDataForSave(item)),
title: (item.table && item.table.title) || '',
note: (item.table && item.table.note) || '',
html_data: (item.table && item.table.html_data) || ''
});
},
applyManuscriptAutocite(refIds) {
const ctx = this.manuscriptAutociteContext;
if (!ctx || !ctx.am_id) return;
const item = this.wordList.find((w) => w.am_id == ctx.am_id);
if (!item || typeof item.content !== 'string') return;
if (!item) return;
const ids = (Array.isArray(refIds) ? refIds : [refIds]).map(String);
const dataId = ids.join(',');
const newTag = `<autocite data-id="${this.escapeHtmlAttr(dataId)}"></autocite>`;
const newTag = `<mycite data-id="${this.escapeHtmlAttr(dataId)}"></mycite>`;
if (item.type === 2 && item.table) {
if (!this.applyFirstAutociteReplaceInTable(item, ctx, newTag)) {
this.$message.warning(this.$t('wordCite.citeUpdateFail'));
return;
}
this.emitSaveTableManuscript(item);
return;
}
if (item.type === 1 && item.image) {
const img = item.image;
for (const field of ['note', 'title']) {
if (typeof img[field] !== 'string' || !img[field]) continue;
const n = this.replaceFirstAutociteByDataId(img[field], ctx.dataId, newTag);
if (n !== img[field]) {
this.$set(img, field, n);
this.manuscriptAutociteContext = null;
this.$emit('saveImageManuscript', {
ami_id: item.ami_id,
am_id: item.am_id,
note: img.note || '',
title: img.title || '',
url: img.url || ''
});
return;
}
}
this.$message.warning(this.$t('wordCite.citeUpdateFail'));
return;
}
if (typeof item.content !== 'string') return;
const newContent = this.replaceFirstAutociteByDataId(item.content, ctx.dataId, newTag);
if (newContent === item.content) {
this.$message.warning(this.$t('wordCite.citeUpdateFail'));
@@ -2165,18 +2412,50 @@ renderCiteLabels(html) {
this.$emit('saveContent', newContent, ctx.am_id);
},
removeManuscriptAutocite() {
if (!this.manuscriptAutociteContext || !this.manuscriptAutociteContext.am_id) {
this.$message.warning(this.$t('wordCite.removeRefNeedClickCite'));
return;
}
this.stripManuscriptAutociteIds([]);
},
/**
* 从稿面当前 autocite 的 data-id 中移除给定 id移除后无 id 则删掉整段标签。
* 从稿面当前 mycite 的 data-id 中移除给定 id移除后无 id 则删掉整段标签。
* ids 为空:删除整段引用(稿面「移除引用」浮动按钮)。
*/
stripManuscriptAutociteIds(idsToRemove) {
const ctx = this.manuscriptAutociteContext;
if (!ctx || !ctx.am_id) return;
const item = this.wordList.find((w) => w.am_id == ctx.am_id);
if (!item || typeof item.content !== 'string') return;
if (!item) return;
const removeSet = new Set((idsToRemove || []).map((id) => String(id)));
if (item.type === 2 && item.table) {
if (!this.applyStripToFirstMatchingTableCell(item, ctx, removeSet)) {
this.$message.warning(this.$t('wordCite.citeUpdateFail'));
return;
}
this.emitSaveTableManuscript(item);
return;
}
if (item.type === 1 && item.image) {
if (!this.applyStripToFirstMatchingImageField(item, ctx, removeSet)) {
this.$message.warning(this.$t('wordCite.citeUpdateFail'));
return;
}
this.manuscriptAutociteContext = null;
const img = item.image;
this.$emit('saveImageManuscript', {
ami_id: item.ami_id,
am_id: item.am_id,
note: img.note || '',
title: img.title || '',
url: img.url || ''
});
return;
}
if (typeof item.content !== 'string') return;
if (removeSet.size === 0) {
const newContent = this.replaceFirstAutociteByDataId(item.content, ctx.dataId, '');
if (newContent === item.content) {
@@ -2193,7 +2472,7 @@ renderCiteLabels(html) {
const keyWant = this.normalizeAutociteDataIdKey(ctx.dataId);
const _this = this;
let done = false;
const newHtml = item.content.replace(/<autocite\s([^>]*)>([\s\S]*?)<\/autocite>/gi, function (full, attrs) {
const newHtml = item.content.replace(/<mycite\s([^>]*)>([\s\S]*?)<\/mycite>/gi, function (full, attrs) {
if (done) return full;
const dm = attrs.match(/\bdata-id\s*=\s*"([^"]*)"/i) || attrs.match(/\bdata-id\s*=\s*'([^']*)'/i);
if (!dm) return full;
@@ -2207,7 +2486,7 @@ renderCiteLabels(html) {
if (remaining.length === 0) return '';
const sorted = _this.sortAutociteIdsByCiteNumber(remaining);
const newDataId = sorted.join(',');
return `<autocite data-id="${_this.escapeHtmlAttr(newDataId)}"></autocite>`;
return `<mycite data-id="${_this.escapeHtmlAttr(newDataId)}"></mycite>`;
});
if (!done || newHtml === item.content) {
@@ -2219,6 +2498,65 @@ renderCiteLabels(html) {
this.manuscriptAutociteContext = null;
this.$emit('saveContent', newHtml, ctx.am_id);
},
/** 正文变化后防抖扫描 autocite更新 bodyCiteIdOrder 并通知父组件重排参考文献 */
scheduleSyncRefOrder() {
if (this._syncRefOrderTimer) clearTimeout(this._syncRefOrderTimer);
this._syncRefOrderTimer = setTimeout(() => {
this._syncRefOrderTimer = null;
this.$nextTick(() => {
this.syncRefOrder();
});
}, 0);
},
syncRefOrder() {
const root = this.$refs.scrollDiv;
if (!root) return;
const nodes = root.querySelectorAll('mycite');
const seen = new Set();
const order = [];
nodes.forEach((el) => {
const raw = el.getAttribute('data-id') || '';
raw.split(',').forEach((part) => {
const id = part.trim();
if (!id) return;
if (!seen.has(id)) {
seen.add(id);
order.push(id);
}
});
});
const prev = this.bodyCiteIdOrder || [];
const sameBody =
order.length === prev.length && order.every((id, i) => String(id) === String(prev[i]));
const listOk = order.length === 0 || this.refListMatchesBodyOrder(order);
if (sameBody && listOk) return;
this.bodyCiteIdOrder = order.slice();
if (order.length > 0) {
this.$emit('reorderReferencesByBody', order.slice());
}
},
/** 参考文献数组顺序是否与正文 mycite 首次出现顺序一致(未在正文出现的条目须在末尾) */
refListMatchesBodyOrder(order) {
const refs = Array.isArray(this.chanFerForm) ? this.chanFerForm : [];
if (refs.length === 0) return true;
const idSet = new Set(order.map(String));
const expectedHead = order.filter((id) => refs.some((r) => r && String(r.p_refer_id) === String(id)));
let hi = 0;
let seenTail = false;
for (const r of refs) {
if (!r || r.p_refer_id == null) continue;
const id = String(r.p_refer_id);
const cited = idSet.has(id);
if (cited) {
if (seenTail) return false;
if (hi >= expectedHead.length || expectedHead[hi] !== id) return false;
hi++;
} else {
seenTail = true;
}
}
return hi === expectedHead.length;
},
handleAIProofreading() {
if (this.currentId) {
this.$api
@@ -2346,7 +2684,7 @@ renderCiteLabels(html) {
let selectedText = tempDiv.innerText.trim().replace(/\s+/g, ' ');
const allowedTags = ['sup', 'sub', 'strong', 'em', 'b', 'i', 'blue', 'autocite', 'tr', 'td'];
const allowedTags = ['sup', 'sub', 'strong', 'em', 'b', 'i', 'blue', 'mycite', 'tr', 'td'];
function preserveTags(node) {
if (node.nodeType === 3) return node.nodeValue; // 文本节点
if (node.nodeType === 1 && allowedTags.includes(node.nodeName.toLowerCase())) {
@@ -3091,12 +3429,13 @@ renderCiteLabels(html) {
this.currentTag = '';
this.currentTagData = null; // 必须重置,防止带入旧数据
this.manuscriptAutociteContext = null;
const clickedAutocite = event.target.closest('autocite');
const clickedAutocite = event.target.closest('mycite');
if (clickedAutocite && !this.isPreview) {
const dataId = clickedAutocite.getAttribute('data-id') || '';
if (dataId) {
this.manuscriptAutociteContext = { am_id: id, dataId };
}
const dataId = clickedAutocite.getAttribute('data-id');
this.manuscriptAutociteContext = {
am_id: id,
dataId: dataId != null && dataId !== '' ? String(dataId) : ''
};
}
const clickedTag = event.target.closest('myfigure, mytable, myh3');
if (clickedTag) {
@@ -3307,15 +3646,19 @@ renderCiteLabels(html) {
},
highlightText(item, annotations, type) {
var text = item.text;
const raw = item && (item.text != null ? item.text : item.content);
var text = raw;
if (!this.isPreview) {
if (this.isShowPiZhu) {
text = this.highlightText1(item.text, annotations, type, item.am_id);
text = this.highlightText1(raw, annotations, type, item.am_id);
} else if (!this.isShowPiZhu && this.proofreadingList.length > 0 && this.currentSelectProofreadingId == item.am_id) {
text = this.highlightText2(item.text, this.proofreadingList[0].data, type, item.am_id);
text = this.highlightText2(raw, this.proofreadingList[0].data, type, item.am_id);
} else {
text = this.highlightText3(item.text, [], type, item.am_id);
text = this.highlightText3(raw, [], type, item.am_id);
}
} else {
// 预览与编辑「显示批注」同源:先切 wmath/autocite、再 renderCiteLabels避免仅跑片段导致角标/公式不一致
text = this.highlightText1(String(raw || ''), annotations || [], type);
}
// const finalHtml = text.replace(/<(?!(\/?(span|p|div|table|tr|td|th|b|i|strong|em|ul|ol|li|br|img|myh3|myfigure|mytable|blue|wmath)))/gi, '&lt;');
@@ -3337,7 +3680,7 @@ renderCiteLabels(html) {
const src = String(text || '');
// 1) 用一个全局、区分大小写不敏感的 wmath 提取正则
const wmathRe = /<(wmath|autocite)\b[^>]*>[\s\S]*?<\/(wmath|autocite)\s*>/gi;
const wmathRe = /<(wmath|mycite)\b[^>]*>[\s\S]*?<\/(wmath|mycite)\s*>/gi;
// 2) 把原文切成: [非wmath片段, wmath片段, 非wmath片段, wmath片段, ...]
const parts = [];
@@ -3443,7 +3786,7 @@ renderCiteLabels(html) {
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 1) 切分出 wmath 与非 wmath 片段
const wmathRe = /<(wmath|autocite)\b[^>]*>[\s\S]*?<\/(wmath|autocite)\s*>/gi;
const wmathRe = /<(wmath|mycite)\b[^>]*>[\s\S]*?<\/(wmath|mycite)\s*>/gi;
const parts = [];
let lastIdx = 0,
m;
@@ -3526,7 +3869,7 @@ renderCiteLabels(html) {
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 1) 切分出 wmath 与非 wmath 片段
const wmathRe = /<(wmath|autocite)\b[^>]*>[\s\S]*?<\/(wmath|autocite)\s*>/gi;
const wmathRe = /<(wmath|mycite)\b[^>]*>[\s\S]*?<\/(wmath|mycite)\s*>/gi;
const parts = [];
let lastIdx = 0,
m;
@@ -3684,6 +4027,29 @@ renderCiteLabels(html) {
}
},
/**
* 是否开启 AI 校对条目标记(与接口 is_proofread 对齐,兼容字符串 "1")。
* 未下发 is_proofread 时:编辑端仍显示每段状态,避免本地/测试数据与线上一致性断裂。
*/
isProofreadEnabled(item) {
if (!item) return false;
const v = item.is_proofread;
if (v === 0 || v === false || v === '0') return false;
if (v === 1 || v === true || v === '1') return true;
if (v === undefined || v === null || v === '') return !!this.isEditComment;
return false;
},
/**
* 本地/测试接口常漏掉 proof_read_numundefined/null 时 JS 里 item.proof_read_num==0 为 false导致右侧 Proofreadingstatus 整段不渲染。
* 缺省按 0 处理(与「无待处理项」档一致),保证每段在开启校对时都能显示状态。
*/
resolvedProofreadNum(item) {
if (!item) return 0;
const raw = item.proof_read_num;
if (raw === undefined || raw === null || raw === '') return 0;
const n = Number(raw);
return Number.isNaN(n) ? 0 : n;
},
isShowEditComment() {
if (localStorage.getItem('U_role')) {
var identity = localStorage.getItem('U_role');
@@ -4557,7 +4923,7 @@ renderCiteLabels(html) {
}
.proofreading-num {
z-index: 2;
background: #e61a12;
background: #0082aa;
color: rgb(255, 255, 255);
border-radius: 20px;
padding: 0px 6px;
@@ -4582,6 +4948,7 @@ renderCiteLabels(html) {
.table_Box {
padding: 8px 15px;
box-sizing: border-box;
overflow-x: auto;
}
.table-box tr:first-child td {
border-top: 1px solid #000 !important;
@@ -4703,7 +5070,7 @@ wmath {
}
/* 正文预览:引用直接用语义标签 autocite原 blue 包装已移除) */
::v-deep autocite {
::v-deep mycite {
display: inline;
vertical-align: baseline;
color: rgb(0, 130, 170) !important;
@@ -4714,6 +5081,15 @@ wmath {
background-color: rgba(0, 130, 170, 0.08);
}
/* 历史 HTML 可能仍带 data-cite-missing与正文引用同色不再使用红底 */
::v-deep mycite[data-cite-missing] {
background-color: rgba(0, 130, 170, 0.08) !important;
color: rgb(0, 130, 170) !important;
box-shadow: none !important;
padding: 0 2px;
border-radius: 3px;
}
::v-deep myfigure *,
::v-deep mytable * {
color: inherit !important;

View File

@@ -11,10 +11,11 @@
<div
:data-id="`ref-${item.p_refer_id}`"
v-for="(item, index) in chanFerForm"
:key="index"
:key="item.p_refer_id != null ? item.p_refer_id : `ref-row-${index}`"
class="ref-item-row"
:class="{ 'has-change': item.is_change == 1, 'is-repeat': item.is_repeat == 1 }"
>
<div class="ref-content-area">
<div v-if="item.refer_type == 'journal'" class="reference-item">
<b class="ref-number-prefix">{{ index + 1 }}.</b>
@@ -42,12 +43,12 @@
</div>
</div>
<div class="ref-actions-area">
<div class="ref-actions-area" v-if="!isPreview">
<i class="el-icon-edit action-icon primary" @click="change(item, 'Edit')"></i>
<i class="el-icon-circle-plus-outline action-icon success" @click="addLine(item, 'Add')"></i>
<div class="order-icons">
<div v-if="!refOrderFollowsBody" class="order-icons">
<i
class="el-icon-caret-top action-icon"
:class="{ disabled: index === 0 }"
@@ -61,7 +62,7 @@
</div>
<i class="el-icon-delete action-icon danger" @click="deleteLine(item)"></i>
<img v-if="role == 'editor' && item.is_ai_check == 1" src="@/assets/img/ai.png" class="ai-mini-tag" />
</div>
</div>
@@ -312,6 +313,10 @@ export default {
type: null,
required: true
},
isPreview: {
type: Boolean,
default: false
},
chanFerFormRepeatList: {
type: Array,
default: () => []
@@ -327,9 +332,30 @@ export default {
role: {
type: String,
default: () => 'editor'
},
/** 为 true 时顺序由正文引用决定,隐藏手动上下移(与 sortRefer 冲突) */
refOrderFollowsBody: {
type: Boolean,
default: false
}
},
methods: {
/** 与当前 chanFerForm 数组顺序一致:行内 index 置为 1-based 序号(排序/正文重排后同步) */
syncReferenceRowIndices() {
const list = this.chanFerForm;
if (!Array.isArray(list)) return;
list.forEach((row, i) => {
if (row && typeof row === 'object') {
const n = i;
if (row.index !== n) {
this.$set(row, 'index', n);
}
}
});
},
searchTitleByDOI(doi) {
this.$commonJS.searchTitleByDOI(doi);
},
formatTitle(title) {
if (!title) return '';
const reg = /\b(Retracted|Retraction)\b/gi;
@@ -369,11 +395,11 @@ export default {
// 2. 借鉴 EndNote对处理完批注的 HTML 进行引用联动渲染
// 支持 data-id="a,b,c" 单标签 或 多个相邻单 id 标签
const citeGroupRe = /(?:<autocite\s+data-id="([^"]+)"\s*><\/autocite>\s*)+/gi;
const citeGroupRe = /(?:<mycite\s+data-id="([^"]+)"\s*><\/mycite>\s*)+/gi;
return html.replace(citeGroupRe, (groupMatch) => {
const ids = [];
const innerRe = /<autocite\s+data-id="([^"]+)"\s*><\/autocite>/gi;
const innerRe = /<mycite\s+data-id="([^"]+)"\s*><\/mycite>/gi;
let m;
while ((m = innerRe.exec(groupMatch)) !== null) {
m[1].split(',').forEach((part) => {
@@ -1018,6 +1044,15 @@ export default {
}
},
watch: {
chanFerForm: {
handler() {
this.$nextTick(() => {
this.syncReferenceRowIndices();
});
},
immediate: true,
deep: true
},
SourceType: {
handler(newVal, oldVal) {
this.$nextTick(() => {
@@ -1990,9 +2025,12 @@ export default {
.action-icon.success:hover {
color: #67c23a;
}
.action-icon.danger:hover {
.action-icon.danger {
color: #f56c6c;
}
.action-icon.danger:hover {
color: #e85a5a;
}
.action-icon.disabled {
color: #e4e7ed;
cursor: not-allowed;