Commit 50321a59 by pangchong

chore(deps): 添加 @types/diff 类型定义依赖

- 在 package.json 和 package-lock.json 中新增 @types/diff 依赖
- 确保开发环境对 diff 类型的支持和代码智能提示
- 安装包版本为 7.0.2,许可证为 MIT
parent 8128cbff
......@@ -36,6 +36,7 @@
"devDependencies": {
"@commitlint/cli": "^19.8.0",
"@commitlint/config-conventional": "^19.8.0",
"@types/diff": "^7.0.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.14",
"@vitejs/plugin-vue": "^6.0.2",
......@@ -1667,6 +1668,13 @@
"@types/node": "*"
}
},
"node_modules/@types/diff": {
"version": "7.0.2",
"resolved": "https://registry.npmmirror.com/@types/diff/-/diff-7.0.2.tgz",
"integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
......
......@@ -45,6 +45,7 @@
"devDependencies": {
"@commitlint/cli": "^19.8.0",
"@commitlint/config-conventional": "^19.8.0",
"@types/diff": "^7.0.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.14",
"@vitejs/plugin-vue": "^6.0.2",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
<template>
<CommonModal v-model="show" title="导出确认" :width="500">
<div class="p-4 py-6">
<n-form-item label="选择导出范围" label-placement="left">
<n-radio-group v-model:value="exportMode" name="exportMode">
<n-space>
<n-radio value="page">当前页数据</n-radio>
<n-radio value="all">
全量数据
<n-text depth="3" class="text-xs ml-1">(最多 2,147,483,647 条)</n-text>
</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-alert v-if="exportMode === 'all'" title="提示" type="info" class="mt-4">全量导出可能会耗时较长,具体取决于系统数据总量。</n-alert>
<n-progress v-if="loading" type="line" :percentage="exportProgress" :indicator-placement="'inside'" processing class="mt-4" />
</div>
<template #footer>
<n-space justify="end">
<CommonButton @click="show = false" :disabled="loading">取消</CommonButton>
<CommonButton type="primary" :loading="loading" @click="handleConfirm">开始导出</CommonButton>
</n-space>
</template>
</CommonModal>
</template>
<script setup lang="ts">
const show = ref(false)
const loading = ref(false)
const exportProgress = ref(0)
const exportMode = ref<'page' | 'all'>('page')
let progressTimer: any = null
// 导出上下文
const context = ref<{
fileName: string
functionCode: string
params: any
} | null>(null)
/**
* 打开导出选择弹窗
* @param options 导出配置
*/
const open = (options: { fileName: string; functionCode: string; params: any }) => {
context.value = options
exportMode.value = 'page' // 默认当前页
show.value = true
}
/** 执行下载 */
const handleConfirm = async () => {
if (!context.value) return
loading.value = true
exportProgress.value = 0
// 开启模拟进度条定时器
progressTimer = setInterval(() => {
if (exportProgress.value < 90) {
exportProgress.value += Math.floor(Math.random() * 3) + 1
} else if (exportProgress.value < 98) {
exportProgress.value += 0.3
}
}, 200)
const { fileName, functionCode, params } = context.value
const exportParams = {
...params,
functionCode,
fileName,
page: exportMode.value === 'all' ? 1 : params.page || 1,
rows: exportMode.value === 'all' ? 2147483647 : params.rows || 15
}
try {
const success = await service.download('/excel/export', exportParams, `${fileName}_${new Date().getTime()}.xlsx`, {
showLoading: false
})
if (success) {
exportProgress.value = 100
window.$message.success('导出任务已启动,请查看浏览器下载')
// 延迟关闭,让用户看到 100% 进度
setTimeout(() => {
show.value = false
}, 500)
}
} catch (err) {
console.error('Export failed', err)
exportProgress.value = 0
} finally {
loading.value = false
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
}
}
defineExpose({ open })
</script>
......@@ -27,26 +27,10 @@
</div>
<template #footer>
<div class="flex justify-between items-center w-full">
<CommonButton v-if="showDownload" type="primary" secondary @click="handleDownloadTemplate">
<template #icon>
<n-icon><DownloadOutline /></n-icon>
</template>
下载导入模板
</CommonButton>
<div v-else></div>
<n-space>
<CommonButton @click="showModal = false">取消</CommonButton>
<CommonButton
type="primary"
:loading="uploading"
:disabled="!importResult?.success && fileList.length === 0"
@click="handleConfirm"
>
{{ importResult?.success ? '完成' : '确定' }}
</CommonButton>
</n-space>
</div>
<CommonButton @click="showModal = false">取消</CommonButton>
<CommonButton type="primary" :loading="uploading" :disabled="!importResult?.success && fileList.length === 0" @click="handleConfirm">
{{ importResult?.success ? '完成' : '确定' }}
</CommonButton>
</template>
</CommonModal>
</template>
......@@ -78,7 +62,6 @@ const context = ref<ImportOptions | null>(null)
let progressTimer: any = null
const title = computed(() => context.value?.title || '批量导入')
const showDownload = computed(() => !!(context.value?.templateName || context.value?.templateApi))
/**
* 打开导入弹窗
......@@ -185,21 +168,6 @@ const handleConfirm = async () => {
}
}
const handleDownloadTemplate = () => {
if (!context.value?.templateName && !context.value?.templateApi) return
const params = {
...(context.value.templateName ? { filename: context.value.templateName } : {}),
...(context.value.downloadParams || {})
}
const downloadApi = context.value.templateApi || '/v1/plugins/ATTACHMENT_DOWN'
const fileName = context.value.templateTitle || '导入模板.xlsx'
openDownloadModal({
downloadFunc: () => service.download(downloadApi, params, fileName),
fileName,
title: '下载导入模板'
})
}
const emit = defineEmits(['success'])
defineExpose({
......
......@@ -9,7 +9,7 @@
:draggable="{ bounds: 'none' }"
v-bind="$attrs"
:style="modalStyle"
class="app-modal"
:class="['app-modal', { 'is-fullscreen': fullscreen }]"
header-class="!px-[20px] !py-[15px]"
content-class="!p-0"
footer-class="!m-0 !p-0"
......@@ -65,6 +65,7 @@ interface Props {
maxHeight?: string | number
padding?: string | number
scrollable?: boolean
fullscreen?: boolean
}
const props = withDefaults(defineProps<Props>(), {
......@@ -78,12 +79,22 @@ const props = withDefaults(defineProps<Props>(), {
width: '600px',
maxHeight: '75vh',
padding: '15px',
scrollable: true
scrollable: true,
fullscreen: false
})
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const modalStyle = computed(() => {
if (props.fullscreen) {
return {
width: '100vw',
height: '100vh',
maxHeight: '100vh',
margin: 0,
borderRadius: 0
}
}
const w = props.width
return {
width: typeof w === 'number' ? `${w}px` : w
......@@ -104,7 +115,43 @@ const handleCancel = () => {
emit('cancel')
}
</script>
<style></style>
<style>
.app-modal.is-fullscreen {
width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
border-radius: 0 !important;
display: flex !important;
flex-direction: column !important;
}
.app-modal.is-fullscreen > .n-card__content {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
min-height: 0 !important;
padding: 0 !important;
}
.app-modal.is-fullscreen > .n-card__content > .n-spin-container {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
min-height: 0 !important;
}
.app-modal.is-fullscreen > .n-card__content > .n-spin-container > .n-spin-content {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
min-height: 0 !important;
}
.app-modal.is-fullscreen > .n-card__content > .n-spin-container > .n-spin-content > .space-y-4 {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
min-height: 0 !important;
margin: 0 !important;
}
</style>
<style scoped>
:deep(.rule-op) {
@apply text-primary font-bold;
......
......@@ -11,7 +11,6 @@
<!-- 全局弹窗组件,供 window 全局挂载调用 -->
<CommonImportModal ref="globalImportModalRef" />
<CommonExportModal ref="globalExportModalRef" />
<CommonUploadModal ref="globalUploadModalRef" />
<CommonDownloadModal ref="globalDownloadModalRef" />
</div>
......@@ -19,13 +18,10 @@
<script setup lang="ts">
import { useAppStore } from '@/store/app/index'
import { useEditorStore } from '@/store/editor'
import CommonDownloadModal from '@/components/CommonDownloadModal.vue'
const themeVars = useThemeVars()
const appStore = useAppStore()
const globalImportModalRef = ref()
const globalExportModalRef = ref()
const globalAttachmentModalRef = ref()
const globalUploadModalRef = ref()
const globalPreviewModalRef = ref()
......@@ -36,7 +32,6 @@ useKeyboardShortcuts({}, true)
onMounted(() => {
window.$importModal = globalImportModalRef.value
window.$exportModal = globalExportModalRef.value
window.$attachmentModal = globalAttachmentModalRef.value
window.$uploadModal = globalUploadModalRef.value
window.$previewModal = globalPreviewModalRef.value
......
......@@ -242,13 +242,31 @@
<div class="text-sm font-semibold mb-3" :style="{ color: themeVars.textColor2 }">编辑器设置</div>
<!-- 自动展开新增节点 -->
<div class="flex items-center justify-between py-3" :style="{ borderColor: themeVars.dividerColor }">
<div class="flex items-center justify-between py-3 border-b" :style="{ borderColor: themeVars.dividerColor }">
<div>
<div class="text-sm" :style="{ color: themeVars.textColor1 }">插入节点自动展开</div>
<div class="text-xs mt-0.5" :style="{ color: themeVars.textColor3 }">插入/粘贴节点及表格行列时,自动展开相关节点</div>
</div>
<n-switch v-model:value="appStore.autoExpandOnInsert" />
</div>
<!-- 表格新增列默认宽度 -->
<div class="py-3" :style="{ borderColor: themeVars.dividerColor }">
<div class="flex items-center justify-between mb-1.5">
<span class="text-sm" :style="{ color: themeVars.textColor1 }">表格新增列默认宽度比</span>
<div class="w-32">
<n-input-number
v-model:value="appStore.defaultNewColWidth"
:min="0.05"
:max="2.0"
:step="0.05"
size="small"
placeholder="默认 0.2"
/>
</div>
</div>
<div class="text-xs" :style="{ color: themeVars.textColor3 }">设定新增列占常规平分列宽的比例或折算系数</div>
</div>
</div>
</n-tab-pane>
......@@ -410,6 +428,7 @@ const resetPrefs = () => {
appStore.collapsed = false
appStore.transitionName = 'none'
appStore.autoExpandOnInsert = true
appStore.defaultNewColWidth = 0.2
customColor.value = '#165DFF'
applyAndSave()
window.$message.success('已重置为默认设置')
......@@ -425,7 +444,8 @@ const copyPrefs = async () => {
colorWeak: appStore.colorWeak,
grayMode: appStore.grayMode,
transitionName: appStore.transitionName,
autoExpandOnInsert: appStore.autoExpandOnInsert
autoExpandOnInsert: appStore.autoExpandOnInsert,
defaultNewColWidth: appStore.defaultNewColWidth
},
null,
2
......@@ -456,6 +476,7 @@ const importPrefs = async () => {
if (typeof conf.grayMode === 'boolean') appStore.grayMode = conf.grayMode
if (conf.transitionName) appStore.transitionName = conf.transitionName
if (typeof conf.autoExpandOnInsert === 'boolean') appStore.autoExpandOnInsert = conf.autoExpandOnInsert
if (typeof conf.defaultNewColWidth === 'number') appStore.defaultNewColWidth = conf.defaultNewColWidth
applyAndSave()
window.$message.success('偏好设置已成功导入并应用')
......
......@@ -20,7 +20,8 @@ export const useAppStore = defineStore('app', {
transitionName: 'none',
settingsPinned: false,
settingsOpen: false,
autoExpandOnInsert: true
autoExpandOnInsert: true,
defaultNewColWidth: 0.2
}),
actions: {
toggleSidebar() {
......
......@@ -20,4 +20,5 @@ export interface AppState {
settingsPinned: boolean
settingsOpen: boolean
autoExpandOnInsert: boolean // 新增/粘贴节点及表格行列时自动展开
defaultNewColWidth: number // 表格新增列的默认宽度比例 (当前默认 0.2)
}
export interface DiffResult {
value: string
added?: boolean
removed?: boolean
count?: number
}
......@@ -17,6 +17,8 @@ export interface XmlNode {
mixedContent: MixedContentItem[]
/** 父节点 ID(不参与序列化) */
parentId: string | null
/** 比对差异状态 */
diffStatus?: 'added' | 'removed' | 'modified' | 'none'
}
/**
......@@ -30,6 +32,10 @@ export interface MixedContentItem {
text?: string
/** 子元素节点 ID(type=element 时) */
nodeId?: string
/** 比对差异状态 */
diffStatus?: 'added' | 'removed' | 'modified' | 'none'
/** 单词级差异详情 */
diffWords?: { value: string; added?: boolean; removed?: boolean }[]
}
/**
......
import * as Diff from 'diff'
import type { XmlNode } from '@/types/xmlNode'
import type { DiffResult } from '@/types/diff'
import { serializeTreeToXml } from './xmlParser'
/**
* 比较两份工卡(XML 字符串或 XmlNode 树结构)的行差异
* @param oldCard 旧工卡(可以是 XML 字符串或 XmlNode 树结构)
* @param newCard 新工卡(可以是 XML 字符串或 XmlNode 树结构)
* @returns 差异结果数组
*/
export function diffJobCardsLines(
oldCard: string | XmlNode,
newCard: string | XmlNode
): DiffResult[] {
const oldXml = typeof oldCard === 'string' ? oldCard : serializeTreeToXml(oldCard, 0, false)
const newXml = typeof newCard === 'string' ? newCard : serializeTreeToXml(newCard, 0, false)
return Diff.diffLines(oldXml, newXml)
}
/**
* 比较两份工卡(XML 字符串或 XmlNode 树结构)的单词差异
* @param oldCard 旧工卡
* @param newCard 新工卡
* @returns 差异结果数组
*/
export function diffJobCardsWords(
oldCard: string | XmlNode,
newCard: string | XmlNode
): DiffResult[] {
const oldXml = typeof oldCard === 'string' ? oldCard : serializeTreeToXml(oldCard, 0, false)
const newXml = typeof newCard === 'string' ? newCard : serializeTreeToXml(newCard, 0, false)
return Diff.diffWords(oldXml, newXml)
}
/**
* 比较两个 XmlNode 对象的 JSON 结构差异
* @param oldNode 旧节点
* @param newNode 新节点
* @returns 差异结果数组
*/
export function diffJobCardsJson(
oldNode: XmlNode,
newNode: XmlNode
): DiffResult[] {
// 序列化为规范 JSON 字符串,排除 parentId 以免产生循环引用
const replacer = (key: string, value: any) => {
if (key === 'parentId') return undefined
return value
}
const oldJson = JSON.stringify(oldNode, replacer, 2)
const newJson = JSON.stringify(newNode, replacer, 2)
return Diff.diffJson(oldJson, newJson)
}
......@@ -152,6 +152,19 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b
}
/**
* 格式化 XML 字符串(转换为带换行和缩进的排版形式)
*/
export function formatXmlText(xmlStr: string): string {
if (!xmlStr) return ''
try {
const tree = parseXmlToTree(xmlStr)
return serializeTreeToXml(tree, 0, false)
} catch (e) {
return xmlStr
}
}
/**
* 转义 XML 属性值
*/
function escapeXmlAttr(str: string): string {
......
......@@ -496,3 +496,13 @@ export const isAllEinDataSameEffect = (einlst: XmlNode): boolean => {
const firstEff = getEffString(einlst.children[0])
return einlst.children.every((c) => getEffString(c) === firstEff)
}
export const getDiffWordClass = (w: { added?: boolean; removed?: boolean }, notItalic = false): string => {
if (w.added) {
return `bg-success-1 text-success-6 dark:text-success-5 font-bold border-b border-success-3 px-0.5 rounded${notItalic ? ' not-italic' : ''}`
}
if (w.removed) {
return `bg-danger-1 text-danger-6 dark:text-danger-5 line-through border-b border-danger-3 px-0.5 rounded${notItalic ? ' not-italic' : ''}`
}
return ''
}
......@@ -3,13 +3,14 @@
:is="renderInline ? 'span' : 'div'"
:data-node-id="node.id"
:data-tag-name="node.tagName"
:data-diff-status="node.diffStatus"
class="doc-node-wrapper relative transition-all"
:class="[
renderInline ? 'inline align-baseline mx-0.5' : 'block my-1',
isSelected ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/5' : '',
!renderInline && isContainer ? 'py-1 px-1' : ''
]"
:style="node.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
:style="!isDiffMode && node.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
@click.stop="editorStore.setSelectedNodeId(node.id)"
>
<!-- 0. 各类 HEADER 节点特殊处理 (不展示) -->
......@@ -46,51 +47,95 @@
<!-- 2. EFFECT / CONEFFECT (适用性) 特殊处理 -->
<template v-else-if="node.tagName === 'EFFECT' || node.tagName === 'CONEFFECT'">
<span v-if="renderInline" class="font-bold text-xs select-none py-1 uppercase mx-1 italic" style="color: red">
** {{ node.tagName === 'EFFECT' ? 'ON A/C' : 'CONF' }}: {{ formatEff(node.attributes.EFFRG || node.textContent) }}
<template v-if="isDiffMode && node.mixedContent?.[0]?.diffWords && node.mixedContent[0].diffWords.length > 0">
<span
v-for="(w, idx) in node.mixedContent[0].diffWords"
:key="idx"
:class="getDiffWordClass(w, true)"
>{{ w.value }}</span>
</template>
<template v-else>** {{ node.tagName === 'EFFECT' ? 'ON A/C' : 'CONF' }}: {{ formatEff(node.attributes.EFFRG || node.textContent) }}</template>
</span>
<div
v-else
class="font-bold text-xs select-none py-1 uppercase my-1 border-t border-b border-dashed pl-1 italic"
style="color: red; border-color: rgba(255, 0, 0, 0.3)"
>
** {{ node.tagName === 'EFFECT' ? 'ON A/C' : 'CONF' }}: {{ formatEff(node.attributes.EFFRG || node.textContent) }}
<template v-if="isDiffMode && node.mixedContent?.[0]?.diffWords && node.mixedContent[0].diffWords.length > 0">
<span
v-for="(w, idx) in node.mixedContent[0].diffWords"
:key="idx"
:class="getDiffWordClass(w, true)"
>{{ w.value }}</span>
</template>
<template v-else>** {{ node.tagName === 'EFFECT' ? 'ON A/C' : 'CONF' }}: {{ formatEff(node.attributes.EFFRG || node.textContent) }}</template>
</div>
</template>
<!-- 3. SBEFF / SBEFFC (服务通告适用性) 处理 -->
<template v-else-if="node.tagName === 'SBEFF' || node.tagName === 'SBEFFC'">
<span v-if="renderInline" class="font-bold text-xs select-none py-1 uppercase mx-1 italic" style="color: red">
** SB: {{ node.attributes.SBCOND }} SB {{ node.attributes.SBNBR }} for A/C {{ formatEff(node.attributes.EFFRG) }}
<template v-if="isDiffMode && node.mixedContent?.[0]?.diffWords && node.mixedContent[0].diffWords.length > 0">
<span
v-for="(w, idx) in node.mixedContent[0].diffWords"
:key="idx"
:class="getDiffWordClass(w, true)"
>{{ w.value }}</span>
</template>
<template v-else>** SB: {{ node.attributes.SBCOND }} SB {{ node.attributes.SBNBR }} for A/C {{ formatEff(node.attributes.EFFRG) }}</template>
</span>
<div
v-else
class="font-bold text-xs select-none py-1 uppercase my-1 border-t border-b border-dashed pl-1 italic"
style="color: red; border-color: rgba(255, 0, 0, 0.3)"
>
** SB: {{ node.attributes.SBCOND }} SB {{ node.attributes.SBNBR }} for A/C {{ formatEff(node.attributes.EFFRG) }}
<template v-if="isDiffMode && node.mixedContent?.[0]?.diffWords && node.mixedContent[0].diffWords.length > 0">
<span
v-for="(w, idx) in node.mixedContent[0].diffWords"
:key="idx"
:class="getDiffWordClass(w, true)"
>{{ w.value }}</span>
</template>
<template v-else>** SB: {{ node.attributes.SBCOND }} SB {{ node.attributes.SBNBR }} for A/C {{ formatEff(node.attributes.EFFRG) }}</template>
</div>
</template>
<!-- 4. TITLEC (中文标题) 特殊处理 -->
<template v-else-if="node.tagName === 'TITLEC'">
<div
contenteditable="true"
:contenteditable="!isDiffMode"
class="font-bold text-base text-color1 py-1 focus:outline-none focus:bg-fill-3 px-1 rounded transition-colors"
@blur="handleTextBlur"
@keydown.enter.prevent
v-text="node.textContent"
></div>
>
<template v-if="isDiffMode && node.mixedContent?.[0]?.diffWords && node.mixedContent[0].diffWords.length > 0">
<span
v-for="(w, idx) in node.mixedContent[0].diffWords"
:key="idx"
:class="getDiffWordClass(w)"
>{{ w.value }}</span>
</template>
<template v-else>{{ node.textContent }}</template>
</div>
</template>
<!-- 5. TITLE (英文标题) 特殊处理 -->
<template v-else-if="node.tagName === 'TITLE'">
<div
contenteditable="true"
:contenteditable="!isDiffMode"
class="font-bold italic text-sm text-color2 py-1 focus:outline-none focus:bg-fill-3 px-1 rounded transition-colors"
@blur="handleTextBlur"
@keydown.enter.prevent
v-text="node.textContent"
></div>
>
<template v-if="isDiffMode && node.mixedContent?.[0]?.diffWords && node.mixedContent[0].diffWords.length > 0">
<span
v-for="(w, idx) in node.mixedContent[0].diffWords"
:key="idx"
:class="getDiffWordClass(w)"
>{{ w.value }}</span>
</template>
<template v-else>{{ node.textContent }}</template>
</div>
</template>
<!-- 6. PARA / PARAC 段落文本处理 -->
......@@ -106,17 +151,26 @@
<span
v-if="item.type === 'text'"
:data-node-id="`${node.id}-txt-${index}`"
contenteditable="true"
:contenteditable="!isDiffMode"
class="focus:outline-none px-0.5 rounded inline transition-all"
:class="
editorStore.selectedNodeId === `${node.id}-txt-${index}`
:class="[
!isDiffMode && editorStore.selectedNodeId === `${node.id}-txt-${index}`
? 'ring-2 ring-primary ring-offset-1 bg-primary/10'
: 'focus:bg-fill-3'
"
@click.stop="editorStore.setSelectedNodeId(`${node.id}-txt-${index}`)"
: '',
isDiffMode ? '' : 'focus:bg-fill-3'
]"
@click.stop="isDiffMode ? null : editorStore.setSelectedNodeId(`${node.id}-txt-${index}`)"
@blur="handleMixedTextBlur(index, $event)"
v-text="item.text"
></span>
>
<template v-if="isDiffMode && item.diffWords && item.diffWords.length > 0">
<span
v-for="(w, idx) in item.diffWords"
:key="idx"
:class="getDiffWordClass(w)"
>{{ w.value }}</span>
</template>
<template v-else>{{ item.text }}</template>
</span>
<DocNodeRenderer
v-else-if="item.type === 'element' && getChildNode(item.nodeId!)"
:node="getChildNode(item.nodeId!)!"
......@@ -141,12 +195,20 @@
<template v-else>
<component
:is="renderInline ? 'span' : 'div'"
contenteditable="true"
:contenteditable="!isDiffMode"
class="focus:outline-none focus:bg-fill-3 px-1 rounded transition-colors"
:class="[renderInline ? 'inline mx-0.5' : 'w-full']"
@blur="handleTextBlur"
v-text="node.textContent"
></component>
>
<template v-if="isDiffMode && node.mixedContent?.[0]?.diffWords && node.mixedContent[0].diffWords.length > 0">
<span
v-for="(w, idx) in node.mixedContent[0].diffWords"
:key="idx"
:class="getDiffWordClass(w)"
>{{ w.value }}</span>
</template>
<template v-else>{{ node.textContent }}</template>
</component>
</template>
</component>
</template>
......@@ -1040,7 +1102,7 @@ import DocNodeRenderer from './index.vue'
import { ImageOutline, GridOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode'
import TableEditor from '../TableEditor/index.vue'
import { useDocNodeRenderer, getSplitListChildren, getCepTaskNumber, isAllEinDataSameEffect } from './functionals'
import { useDocNodeRenderer, getSplitListChildren, getCepTaskNumber, isAllEinDataSameEffect, getDiffWordClass } from './functionals'
import {
ALERT_BLOCK_TAGS,
PARA_TAGS,
......@@ -1079,14 +1141,24 @@ const props = withDefaults(
parent?: XmlNode | null
isInline?: boolean
insideRefBlock?: boolean
diffMode?: boolean
}>(),
{
parent: null,
isInline: false,
insideRefBlock: false
insideRefBlock: false,
diffMode: false
}
)
import type { Ref } from 'vue'
const injectedDiffMode = inject<boolean | Ref<boolean>>('diffMode', false)
const injectedDiffModeValue = computed(() =>
typeof injectedDiffMode === 'boolean' ? injectedDiffMode : injectedDiffMode.value
)
const isDiffMode = computed(() => props.diffMode || injectedDiffModeValue.value)
provide('diffMode', isDiffMode)
const {
editorStore,
isSelected,
......
export interface DiffMixedContentItem {
type: 'text' | 'element'
text?: string
nodeId?: string
diffStatus?: 'added' | 'removed' | 'modified' | 'none'
diffWords?: { value: string; added?: boolean; removed?: boolean }[]
}
export interface DiffXmlNode {
id: string
tagName: string
attributes: Record<string, string>
children: DiffXmlNode[]
textContent: string
mixedContent: DiffMixedContentItem[]
parentId: string | null
diffStatus: 'added' | 'removed' | 'modified' | 'none'
}
export interface DiffLine {
type: 'added' | 'removed' | 'normal' | 'empty'
num?: number
content: string
}
import { type DiffXmlNode, type DiffMixedContentItem, type DiffLine } from '../constants'
import type { XmlNode } from '@/types/xmlNode'
import * as Diff from 'diff'
import { useEditorStore } from '@/store/editor'
import { serializeTreeToXml, formatXmlText } from '@/utils/xmlParser'
import { openUploadModal } from '@/utils/render'
export function useCompareModal() {
const editorStore = useEditorStore()
const showModal = ref(false)
const loading = ref(false)
const oldXmlText = ref('')
const newXmlText = ref('')
const oldDiffTree = ref<DiffXmlNode | null>(null)
const newDiffTree = ref<DiffXmlNode | null>(null)
// 支持 XML 文本差异和编辑状态控制
const isEditingOld = ref(true)
const isEditingNew = ref(true)
const oldDiffLines = ref<DiffLine[]>([])
const newDiffLines = ref<DiffLine[]>([])
const updateTextDiff = () => {
const { leftLines, rightLines } = generateDoubleFolderDiff(oldXmlText.value, newXmlText.value)
oldDiffLines.value = leftLines
newDiffLines.value = rightLines
}
// 滚动条同步逻辑(XML文本 + 工卡渲染,四向强联同步)
const leftScrollContainer = ref<HTMLElement | null>(null)
const rightScrollContainer = ref<HTMLElement | null>(null)
const leftTextScrollContainer = ref<HTMLElement | null>(null)
const rightTextScrollContainer = ref<HTMLElement | null>(null)
let scrollSource: HTMLElement | null = null
let clearSourceTimer: any = null
const handleScroll = (source: HTMLElement, targets: (HTMLElement | null)[]) => {
if (scrollSource && scrollSource !== source) return
scrollSource = source
if (clearSourceTimer) clearTimeout(clearSourceTimer)
const ratio = source.scrollHeight - source.clientHeight > 0 ? source.scrollTop / (source.scrollHeight - source.clientHeight) : 0
for (const target of targets) {
if (target && target !== source) {
const targetMax = target.scrollHeight - target.clientHeight
target.scrollTop = Math.round(ratio * targetMax)
}
}
clearSourceTimer = setTimeout(() => {
scrollSource = null
}, 80)
}
const handleLeftScroll = () => {
if (!leftScrollContainer.value) return
handleScroll(leftScrollContainer.value, [rightScrollContainer.value])
}
const handleRightScroll = () => {
if (!rightScrollContainer.value) return
handleScroll(rightScrollContainer.value, [leftScrollContainer.value])
}
const handleLeftTextScroll = () => {
if (!leftTextScrollContainer.value) return
handleScroll(leftTextScrollContainer.value, [rightTextScrollContainer.value])
}
const handleRightTextScroll = () => {
if (!rightTextScrollContainer.value) return
handleScroll(rightTextScrollContainer.value, [leftTextScrollContainer.value])
}
// 载入当前编辑器 XML
const loadCurrentXml = () => {
if (!editorStore.xmlTree) {
window.$message.warning('当前编辑器内无正在编辑的工卡')
return
}
oldXmlText.value = serializeTreeToXml(editorStore.xmlTree, 0, false)
window.$message.success('已自动载入当前正在编辑的工卡 XML')
updateTextDiff()
}
// 重新开始对比
const resetDiff = () => {
oldDiffTree.value = null
newDiffTree.value = null
}
const open = (currentXml?: string) => {
showModal.value = true
loading.value = false
oldXmlText.value = currentXml || ''
newXmlText.value = ''
oldDiffTree.value = null
newDiffTree.value = null
isEditingOld.value = true
isEditingNew.value = true
oldDiffLines.value = []
newDiffLines.value = []
// 自动拉取当前编辑器内容填充为旧版本
if (editorStore.xmlTree) {
oldXmlText.value = serializeTreeToXml(editorStore.xmlTree, 0, false)
}
updateTextDiff()
}
const handleCompare = async (showWarning = false) => {
if (!oldXmlText.value.trim() || !newXmlText.value.trim()) {
if (showWarning) {
window.$message.warning('请确保两份工卡的 XML 内容都不为空')
}
return
}
loading.value = true
try {
// 解析 XML 树
const oldTree = parseXmlToTree(oldXmlText.value)
const newTree = parseXmlToTree(newXmlText.value)
// 比对并生成左右两侧差异树
oldDiffTree.value = buildOldDiffTree(oldTree, newTree)
newDiffTree.value = buildNewDiffTree(oldTree, newTree)
window.$message.success('对比成功,已生成差异视图')
} catch (err: any) {
window.$message.error('XML 解析或对比失败: ' + err.message)
oldDiffTree.value = null
newDiffTree.value = null
} finally {
loading.value = false
}
}
const handleXmlImportClick = () => {
openUploadModal({
title: '导入目标工卡 XML',
accept: '.xml',
uploadFunc: (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
resolve(e.target?.result as string)
}
reader.onerror = (err) => reject(err)
reader.readAsText(file)
})
},
onSuccess: (res: string) => {
newXmlText.value = formatXmlText(res)
updateTextDiff()
}
})
}
const leftParentContainer = ref<HTMLElement | null>(null)
const rightParentContainer = ref<HTMLElement | null>(null)
const editingOldLineNum = ref<number | null>(null)
const editingNewLineNum = ref<number | null>(null)
const editingOldText = ref('')
const editingNewText = ref('')
// 监听切换编辑模式时的滚动条定位还原,防止滚回顶部
watch(isEditingOld, (newVal) => {
if (newVal) {
const scrollVal = leftTextScrollContainer.value ? leftTextScrollContainer.value.scrollTop : 0
nextTick(() => {
const textarea = leftParentContainer.value?.querySelector('textarea')
if (textarea) textarea.scrollTop = scrollVal
})
} else {
const textarea = leftParentContainer.value?.querySelector('textarea')
const scrollVal = textarea ? textarea.scrollTop : 0
nextTick(() => {
if (leftTextScrollContainer.value) {
leftTextScrollContainer.value.scrollTop = scrollVal
}
})
}
})
watch(isEditingNew, (newVal) => {
if (newVal) {
const scrollVal = rightTextScrollContainer.value ? rightTextScrollContainer.value.scrollTop : 0
nextTick(() => {
const textarea = rightParentContainer.value?.querySelector('textarea')
if (textarea) textarea.scrollTop = scrollVal
})
} else {
const textarea = rightParentContainer.value?.querySelector('textarea')
const scrollVal = textarea ? textarea.scrollTop : 0
nextTick(() => {
if (rightTextScrollContainer.value) {
rightTextScrollContainer.value.scrollTop = scrollVal
}
})
}
})
const startEditLine = (line: any, isNewSide: boolean) => {
if (line.type === 'empty' || !line.num) return
if (isNewSide) {
editingNewLineNum.value = line.num
editingNewText.value = line.content
nextTick(() => {
const input = document.getElementById(`new-line-input-${line.num}`) as HTMLInputElement
if (input) input.focus()
})
} else {
editingOldLineNum.value = line.num
editingOldText.value = line.content
nextTick(() => {
const input = document.getElementById(`old-line-input-${line.num}`) as HTMLInputElement
if (input) input.focus()
})
}
}
const saveEditLine = (isNewSide: boolean) => {
if (isNewSide) {
if (editingNewLineNum.value === null) return
const lines = newXmlText.value.split('\n')
lines[editingNewLineNum.value - 1] = editingNewText.value
newXmlText.value = lines.join('\n')
// 立即执行一次同步比对,防止防抖延迟导致页面文字旧值闪烁
updateTextDiff()
handleCompare()
editingNewLineNum.value = null
} else {
if (editingOldLineNum.value === null) return
const lines = oldXmlText.value.split('\n')
lines[editingOldLineNum.value - 1] = editingOldText.value
oldXmlText.value = lines.join('\n')
// 立即执行一次同步比对,防止防抖延迟导致页面文字旧值闪烁
updateTextDiff()
handleCompare()
editingOldLineNum.value = null
}
}
const cancelEditLine = (isNewSide: boolean) => {
if (isNewSide) {
editingNewLineNum.value = null
} else {
editingOldLineNum.value = null
}
}
const handleOldXmlBlur = () => {
oldXmlText.value = formatXmlText(oldXmlText.value)
updateTextDiff()
}
const handleNewXmlBlur = () => {
newXmlText.value = formatXmlText(newXmlText.value)
updateTextDiff()
}
// 实时防抖比对与状态切换侦听
const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number) => {
let timer: any = null
return function (this: any, ...args: Parameters<T>) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
watch(
[oldXmlText, newXmlText],
debounce(() => {
updateTextDiff()
handleCompare()
// 核心交互:如果两边都有值,自动将显示状态切为 Diff 高亮
if (oldXmlText.value.trim() && newXmlText.value.trim()) {
isEditingOld.value = false
isEditingNew.value = false
} else {
if (!oldXmlText.value.trim()) isEditingOld.value = true
if (!newXmlText.value.trim()) isEditingNew.value = true
}
}, 300),
{ immediate: true }
)
// 寻找下方对应标签和文本最相似的 DOM 节点并高亮(空间相对比例对齐法)
const highlightTargetNode = (line: DiffLine, isNewSide: boolean) => {
const lineContent = line?.content
if (!lineContent || lineContent.trim().startsWith('<!--')) return
// 1. 提取出当前行的标签名 (支持开始与自闭合)
const tagMatch = lineContent.match(/<\/?([a-zA-Z0-9\-]+)/)
if (!tagMatch) return
const lineTagName = tagMatch[1].toUpperCase()
// 2. 确定我们要搜索的容器
const container = isNewSide ? rightScrollContainer.value : leftScrollContainer.value
if (!container) return
// 3. 计算当前点击行在文本中的相对高度比例
const diffLines = isNewSide ? newDiffLines.value : oldDiffLines.value
const clickedIndex = diffLines.indexOf(line)
if (clickedIndex === -1) return
const lineRatio = clickedIndex / diffLines.length
// 4. 获取当前侧容器下的所有 [data-node-id] 元素
const elements = Array.from(container.querySelectorAll('[data-node-id]')) as HTMLElement[]
if (elements.length === 0) return
let bestScore = -1
let bestCandidates: { el: HTMLElement; domRatio: number }[] = []
for (let i = 0; i < elements.length; i++) {
const el = elements[i]
const elTagName = (el.getAttribute('data-tag-name') || '').toUpperCase()
// 标签名必须完全一致
if (elTagName !== lineTagName) continue
let score = 10 // 基础分
// 匹配属性值
const attrValues = lineContent.match(/="([^"]+)"/g)
if (attrValues) {
for (const val of attrValues) {
const cleanVal = val.replace(/="|"$/g, '')
if (el.outerHTML.includes(cleanVal)) {
score += 20
}
}
}
// 匹配去标签后的文本内容
const cleanLineText = lineContent.replace(/<[^>]+>/g, '').trim()
if (cleanLineText) {
const elText = el.textContent || ''
if (elText.includes(cleanLineText)) {
score += cleanLineText.length * 2
}
}
// 维护得分最高候选者
const domRatio = i / elements.length
if (score > bestScore) {
bestScore = score
bestCandidates = [{ el, domRatio }]
} else if (score === bestScore) {
bestCandidates.push({ el, domRatio })
}
}
// 5. 在所有最高分的候选节点中,寻找其在 DOM 树中的相对百分比位置最接近当前点击行的节点
let bestMatch: HTMLElement | null = null
let minDistance = Infinity
for (const cand of bestCandidates) {
const distance = Math.abs(cand.domRatio - lineRatio)
if (distance < minDistance) {
minDistance = distance
bestMatch = cand.el
}
}
// 6. 执行闪烁高亮与居中滚动
if (bestMatch) {
// 清理当前侧容器中的所有已高亮节点
container.querySelectorAll('.highlight-focused-node').forEach((item) => {
item.classList.remove('highlight-focused-node')
})
bestMatch.classList.add('highlight-focused-node')
bestMatch.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// ─── 差异指示条(minimap)高精度物理对齐 ───
// 核心逻辑:为了实现“滚轴中间压着指示条”,我们需要在考虑滑块最小高度限制(例如 8%)的情况下,
// 使得滑块中点对应的百分比和元素在视口中点对应的百分比进行线性匹配。
//
// 设轨道总百分比高度为 H_track = 100,滑块百分比高度为 hSlider。
// 当 scrollTop = 0 时,滑块顶部在 0,中点在 hSlider / 2。
// 当 scrollTop = maxScrollTop 时,滑块底部在 100,顶部在 100 - hSlider,中点在 100 - hSlider / 2。
//
// 因此,当 scrollTop 在 [0, maxScrollTop] 变化时,滑块顶部的映射公式为:
// sliderTop% = (scrollTop / maxScrollTop) * (100 - hSlider)
// 某一行在 scrollTop = offsetTop - clientHeight / 2 时刚好处于视口垂直正中。
// 我们让这一行对应的 marker 刚好等于此时滑块中点的位置:
// markerTop% = (targetScrollTopClamped / maxScrollTop) * (100 - hSlider) + hSlider / 2
// 辅助映射:将元素的物理 offsetTop 转换为与滑块中心严格对齐的百分比
const mapOffsetToPercent = (y: number, sh: number, ch: number, hSlider: number): number => {
const maxScrollTop = sh - ch
if (maxScrollTop <= 0) {
return (y / sh) * 100
}
// 目标滚动位置:使该元素刚好处于可视区域的正中间
const targetScrollTop = y - ch / 2
const targetScrollTopClamped = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
return (targetScrollTopClamped / maxScrollTop) * (100 - hSlider) + hSlider / 2
}
// 辅助逆映射:点击指示条某处,反推目标 scrollTop 以使该位置的内容处于视口正中
const getScrollTopFromRatio = (ratio: number, sh: number, ch: number, hSlider: number): number => {
const maxScrollTop = sh - ch
if (maxScrollTop <= 0) return 0
const clickPercent = ratio * 100
// 反推:clickPercent = (targetScrollTop / maxScrollTop) * (100 - hSlider) + hSlider / 2
const targetScrollTop = ((clickPercent - hSlider / 2) / (100 - hSlider)) * maxScrollTop
return Math.max(0, Math.min(targetScrollTop, maxScrollTop))
}
// 递归累加 offsetTop,获取元素相对于指定相对定位容器的物理偏移
const getOffsetTop = (el: HTMLElement, container: HTMLElement): number => {
let top = 0
let cur: HTMLElement | null = el
while (cur && cur !== container) {
top += cur.offsetTop
cur = cur.offsetParent as HTMLElement | null
}
return top
}
// 文本差异指示条 markers(高精度中点匹配)
const leftTextDiffMarkers = ref<{ top: number }[]>([])
const rightTextDiffMarkers = ref<{ top: number }[]>([])
const updateTextDiffMarkers = () => {
const lc = leftTextScrollContainer.value
if (lc && lc.scrollHeight > 0) {
const sh = lc.scrollHeight
const ch = lc.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
leftTextDiffMarkers.value = (Array.from(lc.querySelectorAll('.line-row-removed')) as HTMLElement[]).map((row) => ({
top: mapOffsetToPercent(getOffsetTop(row, lc), sh, ch, hSlider)
}))
} else {
leftTextDiffMarkers.value = []
}
const rc = rightTextScrollContainer.value
if (rc && rc.scrollHeight > 0) {
const sh = rc.scrollHeight
const ch = rc.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
rightTextDiffMarkers.value = (Array.from(rc.querySelectorAll('.line-row-added')) as HTMLElement[]).map((row) => ({
top: mapOffsetToPercent(getOffsetTop(row, rc), sh, ch, hSlider)
}))
} else {
rightTextDiffMarkers.value = []
}
}
// 渲染差异指示条 markers(高精度中点匹配)
const leftRenderDiffMarkers = ref<{ type: 'removed' | 'modified'; top: number }[]>([])
const rightRenderDiffMarkers = ref<{ type: 'added' | 'modified'; top: number }[]>([])
const updateRenderDiffMarkers = () => {
const lc = leftScrollContainer.value
if (lc && lc.scrollHeight > 0) {
const sh = lc.scrollHeight
const ch = lc.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
leftRenderDiffMarkers.value = (
Array.from(lc.querySelectorAll('[data-diff-status="removed"],[data-diff-status="modified"]')) as HTMLElement[]
).flatMap((el) => {
const s = el.getAttribute('data-diff-status')
if (!s || s === 'none' || s === 'added') return []
return [{ type: s as 'removed' | 'modified', top: mapOffsetToPercent(getOffsetTop(el, lc), sh, ch, hSlider) }]
})
} else {
leftRenderDiffMarkers.value = []
}
const rc = rightScrollContainer.value
if (rc && rc.scrollHeight > 0) {
const sh = rc.scrollHeight
const ch = rc.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
rightRenderDiffMarkers.value = (
Array.from(rc.querySelectorAll('[data-diff-status="added"],[data-diff-status="modified"]')) as HTMLElement[]
).flatMap((el) => {
const s = el.getAttribute('data-diff-status')
if (!s || s === 'none' || s === 'removed') return []
return [{ type: s as 'added' | 'modified', top: mapOffsetToPercent(getOffsetTop(el, rc), sh, ch, hSlider) }]
})
} else {
rightRenderDiffMarkers.value = []
}
}
// 指示条点击跳转事件处理器(使目标差异完美居中于视口,滚轴中点压住指示条)
const handleLeftTextMinimapClick = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickY = e.clientY - rect.top
const ratio = clickY / rect.height
if (leftTextScrollContainer.value) {
const sh = leftTextScrollContainer.value.scrollHeight
const ch = leftTextScrollContainer.value.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
leftTextScrollContainer.value.scrollTop = getScrollTopFromRatio(ratio, sh, ch, hSlider)
}
}
const handleRightTextMinimapClick = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickY = e.clientY - rect.top
const ratio = clickY / rect.height
if (rightTextScrollContainer.value) {
const sh = rightTextScrollContainer.value.scrollHeight
const ch = rightTextScrollContainer.value.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
rightTextScrollContainer.value.scrollTop = getScrollTopFromRatio(ratio, sh, ch, hSlider)
}
}
const handleLeftRenderMinimapClick = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickY = e.clientY - rect.top
const ratio = clickY / rect.height
if (leftScrollContainer.value) {
const sh = leftScrollContainer.value.scrollHeight
const ch = leftScrollContainer.value.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
leftScrollContainer.value.scrollTop = getScrollTopFromRatio(ratio, sh, ch, hSlider)
}
}
const handleRightRenderMinimapClick = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickY = e.clientY - rect.top
const ratio = clickY / rect.height
if (rightScrollContainer.value) {
const sh = rightScrollContainer.value.scrollHeight
const ch = rightScrollContainer.value.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
rightScrollContainer.value.scrollTop = getScrollTopFromRatio(ratio, sh, ch, hSlider)
}
}
// ─── 模拟滚动滑块与拖拽同步 ───
const leftTextScrollTopPercent = ref(0)
const leftTextScrollHeightPercent = ref(100)
const rightTextScrollTopPercent = ref(0)
const rightTextScrollHeightPercent = ref(100)
const leftRenderScrollTopPercent = ref(0)
const leftRenderScrollHeightPercent = ref(100)
const rightRenderScrollTopPercent = ref(0)
const rightRenderScrollHeightPercent = ref(100)
const updateScrollPercentages = () => {
// 1. 左文本
const ltc = leftTextScrollContainer.value
if (ltc && ltc.scrollHeight > 0) {
const sh = ltc.scrollHeight
const ch = ltc.clientHeight
const maxScrollTop = sh - ch
const hSlider = Math.max(8, (ch / sh) * 100)
leftTextScrollHeightPercent.value = hSlider
leftTextScrollTopPercent.value = maxScrollTop > 0 ? (ltc.scrollTop / maxScrollTop) * (100 - hSlider) : 0
}
// 2. 右文本
const rtc = rightTextScrollContainer.value
if (rtc && rtc.scrollHeight > 0) {
const sh = rtc.scrollHeight
const ch = rtc.clientHeight
const maxScrollTop = sh - ch
const hSlider = Math.max(8, (ch / sh) * 100)
rightTextScrollHeightPercent.value = hSlider
rightTextScrollTopPercent.value = maxScrollTop > 0 ? (rtc.scrollTop / maxScrollTop) * (100 - hSlider) : 0
}
// 3. 左渲染
const lrc = leftScrollContainer.value
if (lrc && lrc.scrollHeight > 0) {
const sh = lrc.scrollHeight
const ch = lrc.clientHeight
const maxScrollTop = sh - ch
const hSlider = Math.max(8, (ch / sh) * 100)
leftRenderScrollHeightPercent.value = hSlider
leftRenderScrollTopPercent.value = maxScrollTop > 0 ? (lrc.scrollTop / maxScrollTop) * (100 - hSlider) : 0
}
// 4. 右渲染
const rrc = rightScrollContainer.value
if (rrc && rrc.scrollHeight > 0) {
const sh = rrc.scrollHeight
const ch = rrc.clientHeight
const maxScrollTop = sh - ch
const hSlider = Math.max(8, (ch / sh) * 100)
rightRenderScrollHeightPercent.value = hSlider
rightRenderScrollTopPercent.value = maxScrollTop > 0 ? (rrc.scrollTop / maxScrollTop) * (100 - hSlider) : 0
}
}
// 模拟滚动滑块拖拽逻辑 (根据精确的缩放比映射 scrollTop)
const startDragScroll = (e: MouseEvent, type: 'leftText' | 'rightText' | 'leftRender' | 'rightRender') => {
e.preventDefault()
const container =
type === 'leftText'
? leftTextScrollContainer.value
: type === 'rightText'
? rightTextScrollContainer.value
: type === 'leftRender'
? leftScrollContainer.value
: rightScrollContainer.value
if (!container) return
const startY = e.clientY
const startScrollTop = container.scrollTop
const sh = container.scrollHeight
const ch = container.clientHeight
const maxScrollTop = sh - ch
const hSlider = Math.max(8, (ch / sh) * 100)
const track = (e.currentTarget as HTMLElement).parentElement
if (!track) return
const trackHeight = track.clientHeight
const onMouseMove = (moveEvent: MouseEvent) => {
const deltaY = moveEvent.clientY - startY
const deltaRatio = deltaY / trackHeight
if (maxScrollTop > 0) {
// 在限制最小滑块高度后,滑块顶部实际的活动百分比区间是 100 - hSlider
// 故 deltaScroll = (deltaRatio * 100 / (100 - hSlider)) * maxScrollTop
const deltaScroll = ((deltaRatio * 100) / (100 - hSlider)) * maxScrollTop
container.scrollTop = Math.max(0, Math.min(startScrollTop + deltaScroll, maxScrollTop))
}
// 同步联锁其它容器
if (type === 'leftText') handleLeftTextScroll()
if (type === 'rightText') handleRightTextScroll()
if (type === 'leftRender') handleLeftScroll()
if (type === 'rightRender') handleRightScroll()
updateScrollPercentages()
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// 包装原生的 @scroll 监听器,确保每次滚动时同步更新模拟滚动滑块的物理占比
const onLeftTextScroll = () => {
handleLeftTextScroll()
updateScrollPercentages()
}
const onRightTextScroll = () => {
handleRightTextScroll()
updateScrollPercentages()
}
const onLeftScroll = () => {
handleLeftScroll()
updateScrollPercentages()
}
const onRightScroll = () => {
handleRightScroll()
updateScrollPercentages()
}
// 侦听渲染树变化 → 重算渲染区 marker
watch(
[oldDiffTree, newDiffTree],
() => {
nextTick(() => {
setTimeout(() => {
updateRenderDiffMarkers()
updateScrollPercentages()
}, 200)
})
},
{ deep: true, immediate: true }
)
// 侦听文本行变化 → 重算文本 marker
watch(
[oldDiffLines, newDiffLines],
() => {
nextTick(() => {
setTimeout(() => {
updateTextDiffMarkers()
updateScrollPercentages()
}, 200)
})
},
{ immediate: true }
)
// 侦听模态框打开,在动画完成后触发一次完整的重算,确保 DOM clientHeight/scrollHeight 能被正确读取
watch(showModal, (newVal) => {
if (newVal) {
nextTick(() => {
setTimeout(() => {
updateTextDiffMarkers()
updateRenderDiffMarkers()
updateScrollPercentages()
}, 350)
})
}
})
return {
showModal,
loading,
oldXmlText,
newXmlText,
oldDiffTree,
newDiffTree,
leftScrollContainer,
rightScrollContainer,
handleLeftScroll,
handleRightScroll,
loadCurrentXml,
resetDiff,
open,
handleCompare,
handleXmlImportClick,
formatXmlText,
isEditingOld,
isEditingNew,
oldDiffLines,
newDiffLines,
updateTextDiff,
leftTextScrollContainer,
rightTextScrollContainer,
handleLeftTextScroll,
handleRightTextScroll,
leftParentContainer,
rightParentContainer,
editingOldLineNum,
editingNewLineNum,
editingOldText,
editingNewText,
startEditLine,
saveEditLine,
cancelEditLine,
handleOldXmlBlur,
handleNewXmlBlur,
highlightTargetNode,
leftTextDiffMarkers,
rightTextDiffMarkers,
leftRenderDiffMarkers,
rightRenderDiffMarkers,
handleLeftTextMinimapClick,
handleRightTextMinimapClick,
handleLeftRenderMinimapClick,
handleRightRenderMinimapClick,
leftTextScrollTopPercent,
leftTextScrollHeightPercent,
rightTextScrollTopPercent,
rightTextScrollHeightPercent,
leftRenderScrollTopPercent,
leftRenderScrollHeightPercent,
rightRenderScrollTopPercent,
rightRenderScrollHeightPercent,
startDragScroll,
onLeftTextScroll,
onRightTextScroll,
onLeftScroll,
onRightScroll
}
}
// ── 内部辅助比对函数 ──
function alignNodes(oldChildren: XmlNode[], newChildren: XmlNode[]): { oldNode: XmlNode | null; newNode: XmlNode | null }[] {
const aligned: { oldNode: XmlNode | null; newNode: XmlNode | null }[] = []
let i = 0
let j = 0
while (i < oldChildren.length || j < newChildren.length) {
if (i >= oldChildren.length) {
aligned.push({ oldNode: null, newNode: newChildren[j] })
j++
} else if (j >= newChildren.length) {
aligned.push({ oldNode: oldChildren[i], newNode: null })
i++
} else {
const oldChild = oldChildren[i]
const newChild = newChildren[j]
if (oldChild.tagName === newChild.tagName) {
aligned.push({ oldNode: oldChild, newNode: newChild })
i++
j++
} else {
// 查找前方是否有匹配的标签
let foundMatch = false
for (let k = i + 1; k < oldChildren.length; k++) {
if (oldChildren[k].tagName === newChild.tagName) {
for (let m = i; m < k; m++) {
aligned.push({ oldNode: oldChildren[m], newNode: null })
}
aligned.push({ oldNode: oldChildren[k], newNode: newChild })
i = k + 1
j++
foundMatch = true
break
}
}
if (!foundMatch) {
aligned.push({ oldNode: null, newNode: newChild })
j++
}
}
}
}
return aligned
}
/**
* 构建旧工卡差异树(仅保留未变更和已被删除的节点,删除的以红色高亮)
*/
function buildOldDiffTree(oldNode: XmlNode | null, newNode: XmlNode | null): DiffXmlNode | null {
if (!oldNode) return null
// 只有旧节点(说明是被删除的元素)
if (oldNode && !newNode) {
return {
id: oldNode.id,
tagName: oldNode.tagName,
attributes: { ...oldNode.attributes },
textContent: oldNode.textContent,
parentId: oldNode.parentId,
diffStatus: 'removed',
mixedContent: (oldNode.mixedContent || []).map((item) => ({
...item,
diffStatus: 'removed'
})),
children: oldNode.children.map((child) => buildOldDiffTree(child, null)).filter((c): c is DiffXmlNode => c !== null)
}
}
// 两者都有
const node = oldNode!
const other = newNode!
let diffStatus: 'added' | 'removed' | 'modified' | 'none' = 'none'
const attrsEqual = JSON.stringify(node.attributes) === JSON.stringify(other.attributes)
const isMetadataTag = ['EFFECT', 'CONEFFECT', 'SBEFF', 'SBEFFC'].includes(node.tagName)
const oldText = isMetadataTag ? getMetadataRenderedText(node) : node.textContent || ''
const newText = isMetadataTag ? getMetadataRenderedText(other) : other.textContent || ''
const textEqual = oldText === newText
if (!attrsEqual || !textEqual) {
diffStatus = 'modified'
}
const mixedContent: DiffMixedContentItem[] = []
if (isMetadataTag) {
// 对于元数据标签,总是把渲染文本放入 mixedContent,这样渲染器就可以走 word diff
const words = Diff.diffWords(oldText, newText)
mixedContent.push({
type: 'text',
text: oldText,
diffWords: words
.filter((w) => !w.added)
.map((w) => ({
value: w.value,
removed: w.removed
}))
})
} else if (node.textContent || other.textContent) {
const words = Diff.diffWords(node.textContent || '', other.textContent || '')
mixedContent.push({
type: 'text',
text: node.textContent || '',
diffWords: words
.filter((w) => !w.added) // 过滤掉新增的单词,旧卡只显示删除的/未改动的
.map((w) => ({
value: w.value,
removed: w.removed
}))
})
}
const aligned = alignNodes(node.children, other.children)
const children = aligned.map((pair) => buildOldDiffTree(pair.oldNode, pair.newNode)).filter((c): c is DiffXmlNode => c !== null)
return {
id: node.id,
tagName: node.tagName,
attributes: { ...node.attributes },
textContent: node.textContent,
parentId: node.parentId,
diffStatus,
mixedContent,
children
}
}
/**
* 构建新工卡差异树(仅保留未变更和新增的节点,新增的以绿色高亮)
*/
function buildNewDiffTree(oldNode: XmlNode | null, newNode: XmlNode | null): DiffXmlNode | null {
if (!newNode) return null
// 只有新节点(说明是新增的元素)
if (!oldNode && newNode) {
return {
id: newNode.id,
tagName: newNode.tagName,
attributes: { ...newNode.attributes },
textContent: newNode.textContent,
parentId: newNode.parentId,
diffStatus: 'added',
mixedContent: (newNode.mixedContent || []).map((item) => ({
...item,
diffStatus: 'added'
})),
children: newNode.children.map((child) => buildNewDiffTree(null, child)).filter((c): c is DiffXmlNode => c !== null)
}
}
// 两者都有
const node = oldNode!
const other = newNode!
let diffStatus: 'added' | 'removed' | 'modified' | 'none' = 'none'
const attrsEqual = JSON.stringify(node.attributes) === JSON.stringify(other.attributes)
const isMetadataTag = ['EFFECT', 'CONEFFECT', 'SBEFF', 'SBEFFC'].includes(node.tagName)
const oldText = isMetadataTag ? getMetadataRenderedText(node) : node.textContent || ''
const newText = isMetadataTag ? getMetadataRenderedText(other) : other.textContent || ''
const textEqual = oldText === newText
if (!attrsEqual || !textEqual) {
diffStatus = 'modified'
}
const mixedContent: DiffMixedContentItem[] = []
if (isMetadataTag) {
const words = Diff.diffWords(oldText, newText)
mixedContent.push({
type: 'text',
text: newText,
diffWords: words
.filter((w) => !w.removed)
.map((w) => ({
value: w.value,
added: w.added
}))
})
} else if (node.textContent || other.textContent) {
const words = Diff.diffWords(node.textContent || '', other.textContent || '')
mixedContent.push({
type: 'text',
text: other.textContent || '',
diffWords: words
.filter((w) => !w.removed) // 过滤掉被删除的单词,新卡只显示新增的/未改动的
.map((w) => ({
value: w.value,
added: w.added
}))
})
}
const aligned = alignNodes(node.children, other.children)
const children = aligned.map((pair) => buildNewDiffTree(pair.oldNode, pair.newNode)).filter((c): c is DiffXmlNode => c !== null)
return {
id: other.id,
tagName: other.tagName,
attributes: { ...other.attributes },
textContent: other.textContent,
parentId: other.parentId,
diffStatus,
mixedContent,
children
}
}
function formatEff(eff: string | undefined): string {
if (!eff) return 'ALL'
const cleaned = eff.replace(/\s+/g, '')
if (cleaned === '001999') return 'ALL'
if (/^\d+$/.test(cleaned) && cleaned.length % 6 === 0) {
const parts: string[] = []
for (let i = 0; i < cleaned.length; i += 6) {
const block = cleaned.substring(i, i + 6)
parts.push(block === '001999' ? 'ALL' : block)
}
return parts.join(', ')
}
return eff
}
function getMetadataRenderedText(n: XmlNode): string {
if (n.tagName === 'EFFECT' || n.tagName === 'CONEFFECT') {
const type = n.tagName === 'EFFECT' ? 'ON A/C' : 'CONF'
const val = n.attributes.EFFRG || n.textContent || ''
return `** ${type}: ${formatEff(val)}`
}
if (n.tagName === 'SBEFF' || n.tagName === 'SBEFFC') {
const cond = n.attributes.SBCOND || ''
const nbr = n.attributes.SBNBR || ''
const eff = formatEff(n.attributes.EFFRG)
return `** SB: ${cond} SB ${nbr} for A/C ${eff}`
}
return ''
}
export function generateDoubleFolderDiff(oldText: string, newText: string) {
const diff = Diff.diffLines(oldText, newText)
const leftLines: DiffLine[] = []
const rightLines: DiffLine[] = []
let leftNum = 1
let rightNum = 1
let i = 0
while (i < diff.length) {
const current = diff[i]
if (!current.added && !current.removed) {
const lines = current.value.split('\n')
if (lines[lines.length - 1] === '') lines.pop()
for (const line of lines) {
leftLines.push({ type: 'normal', num: leftNum++, content: line })
rightLines.push({ type: 'normal', num: rightNum++, content: line })
}
i++
} else {
const leftBlock: string[] = []
const rightBlock: string[] = []
while (i < diff.length && (diff[i].added || diff[i].removed)) {
const block = diff[i]
const lines = block.value.split('\n')
if (lines[lines.length - 1] === '') lines.pop()
if (block.removed) {
leftBlock.push(...lines)
} else {
rightBlock.push(...lines)
}
i++
}
const maxLen = Math.max(leftBlock.length, rightBlock.length)
for (let k = 0; k < maxLen; k++) {
if (k < leftBlock.length) {
leftLines.push({ type: 'removed', num: leftNum++, content: leftBlock[k] })
} else {
leftLines.push({ type: 'empty', content: '' })
}
if (k < rightBlock.length) {
rightLines.push({ type: 'added', num: rightNum++, content: rightBlock[k] })
} else {
rightLines.push({ type: 'empty', content: '' })
}
}
}
}
return { leftLines, rightLines }
}
<template>
<CommonModal
v-model="showModal"
title="工卡 XML 差异实时对比"
fullscreen
:padding="16"
:scrollable="false"
:showFooter="true"
:loading="loading"
:show-confirm="false"
cancel-text="关闭"
class="compare-modal-nobar"
>
<div class="compare-modal-content flex-1 flex flex-col min-h-0 h-full">
<!-- 顶栏:图例说明 -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-3 p-3 bg-fill-2 border border-divider rounded-lg shrink-0">
<div class="flex items-center space-x-4 text-xs font-semibold select-none">
<span class="text-color3">差异图例:</span>
<span class="flex items-center space-x-1">
<span class="w-3 h-3 rounded bg-success-1 border border-success-3"></span>
<span class="text-success-6 dark:text-success-5">新增内容</span>
</span>
<span class="flex items-center space-x-1">
<span class="w-3 h-3 rounded bg-danger-1 border border-danger-3"></span>
<span class="text-danger-6 dark:text-danger-5 line-through">删除内容</span>
</span>
<span class="flex items-center space-x-1">
<span class="w-1 h-3 bg-warning rounded-sm"></span>
<span class="text-warning-6 dark:text-warning-5">属性变更 / 空白占位</span>
</span>
</div>
<div class="text-xs text-color3 italic">上方为 XML 文本对比(双击可切回编辑,失焦自动格式化),下方为工卡实时渲染差异</div>
</div>
<!-- 主内容区:左右分栏 -->
<div class="flex-1 flex gap-4 min-h-0 h-full">
<!-- 左侧栏:旧工卡输入与渲染 -->
<div class="flex-1 flex flex-col min-w-0 min-h-0">
<!-- 输入区 -->
<div class="flex flex-col h-1/3 min-h-[180px] max-h-[320px] mb-3 shrink-0">
<div class="flex items-center justify-between mb-2 shrink-0">
<span class="text-sm font-bold text-color2 flex items-center space-x-1.5 whitespace-nowrap">
<span class="w-2 h-2 rounded-full bg-warning"></span>
<span>源工卡 XML (旧版本)</span>
</span>
<div class="flex items-center space-x-1.5">
<CommonButton
v-if="oldXmlText"
size="tiny"
secondary
:type="isEditingOld ? 'warning' : 'default'"
@click="isEditingOld = !isEditingOld"
>
{{ isEditingOld ? '查看对比' : '编辑 XML' }}
</CommonButton>
<CommonButton size="tiny" secondary type="warning" @click="loadCurrentXml">使用当前打开的工卡</CommonButton>
</div>
</div>
<div ref="leftParentContainer" class="flex-1 flex flex-col min-h-0 relative">
<n-input
v-if="isEditingOld"
v-model:value="oldXmlText"
type="textarea"
placeholder="请粘贴源工卡的完整 XML 内容..."
class="flex-1 font-mono text-xs"
:input-props="{ style: { height: '100%' } }"
@blur="handleOldXmlBlur"
/>
<div
v-else
ref="leftTextScrollContainer"
class="flex-1 border border-divider rounded-lg bg-card overflow-auto p-3 font-mono text-[11px] leading-5 select-text whitespace-pre relative scroll-container text-color1"
title="双击行内容可编辑这一行"
@scroll="onLeftTextScroll"
>
<div
v-for="(line, idx) in oldDiffLines"
:key="idx"
class="flex w-full group hover:bg-fill-2 min-h-[20px] cursor-pointer"
:class="{
'line-row-removed': line.type === 'removed',
'line-row-empty': line.type === 'empty'
}"
@click="highlightTargetNode(line, false)"
@dblclick.stop="startEditLine(line, false)"
>
<span class="line-num select-none w-10 text-right pr-2 border-r border-divider opacity-50">
{{ line.num || '' }}
</span>
<input
v-if="editingOldLineNum === line.num && line.num"
:id="`old-line-input-${line.num}`"
v-model="editingOldText"
class="flex-1 px-2 py-0.5 bg-fill-2 border border-primary text-[11px] font-mono text-color1 outline-none h-[18px] leading-[18px] rounded"
@blur="saveEditLine(false)"
@keyup.enter="saveEditLine(false)"
@keyup.esc="cancelEditLine(false)"
@click.stop
/>
<span v-else class="line-content pl-2 break-all whitespace-pre-wrap flex-1">{{ line.content }}</span>
</div>
</div>
<!-- 左侧文本差异指示条:放在滚动容器外部右侧,加宽并支持点击跳转和模拟拖拽 -->
<div
v-if="!isEditingOld"
class="absolute right-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title="点击定位或拖动滑块"
@click="handleLeftTextMinimapClick"
>
<!-- 差异 markers -->
<div
v-for="(marker, mIdx) in leftTextDiffMarkers"
:key="mIdx"
class="absolute left-0 right-0 h-[3px] bg-[#e74c3c]"
:style="{ top: marker.top + '%' }"
></div>
<!-- 模拟滑块 -->
<div
class="absolute left-[1px] right-[1px] bg-primary/[0.15] hover:bg-primary/[0.25] active:bg-primary/[0.35] rounded border border-primary/20 cursor-grab active:cursor-grabbing transition-colors"
:style="{
top: leftTextScrollTopPercent + '%',
height: leftTextScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'leftText')"
></div>
</div>
</div>
</div>
<!-- 渲染区 -->
<div class="flex-1 flex flex-col min-h-0">
<div class="text-xs font-bold text-color3 mb-2 px-1 flex items-center space-x-1.5 shrink-0">
<span class="w-2 h-2 rounded-full bg-danger"></span>
<span>源工卡渲染 (旧版本)</span>
</div>
<!-- 滚动容器 + 指示条共用一个 relative 包裹 -->
<div class="flex-1 min-h-0 relative">
<div
ref="leftScrollContainer"
class="is-diff-mode h-full overflow-auto border border-divider rounded-lg bg-card p-6 leading-relaxed relative scroll-container"
@scroll="onLeftScroll"
>
<DocNodeRenderer v-if="oldDiffTree" :node="oldDiffTree" :parent="null" />
<div v-else class="h-full flex items-center justify-center text-xs text-color3 select-none">
暂无源工卡数据,请在上方输入框中粘贴或导入
</div>
</div>
<!-- 左侧渲染差异指示条:滚动容器外部右侧,加宽并支持点击跳转和模拟拖拽 -->
<div
v-if="oldDiffTree"
class="absolute right-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title="点击定位或拖动滑块"
@click="handleLeftRenderMinimapClick"
>
<!-- 差异 markers -->
<div
v-for="(marker, mIdx) in leftRenderDiffMarkers"
:key="mIdx"
class="absolute left-0 right-0 h-[3px]"
:class="marker.type === 'removed' ? 'bg-[#e74c3c]' : 'bg-[#f1c40f]'"
:style="{ top: marker.top + '%' }"
></div>
<!-- 模拟滑块 -->
<div
class="absolute left-[1px] right-[1px] bg-primary/[0.15] hover:bg-primary/[0.25] active:bg-primary/[0.35] rounded border border-primary/20 cursor-grab active:cursor-grabbing transition-colors"
:style="{
top: leftRenderScrollTopPercent + '%',
height: leftRenderScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'leftRender')"
></div>
</div>
</div>
</div>
</div>
<!-- 右侧栏:新工卡输入与渲染 -->
<div class="flex-1 flex flex-col min-w-0 min-h-0">
<!-- 输入区 -->
<div class="flex flex-col h-1/3 min-h-[180px] max-h-[320px] mb-3 shrink-0">
<div class="flex items-center justify-between mb-2 shrink-0">
<span class="text-sm font-bold text-color2 flex items-center space-x-1.5 whitespace-nowrap">
<span class="w-2 h-2 rounded-full bg-primary"></span>
<span>目标工卡 XML (新版本)</span>
</span>
<div class="flex items-center space-x-1.5">
<CommonButton
v-if="newXmlText"
size="tiny"
secondary
:type="isEditingNew ? 'primary' : 'default'"
@click="isEditingNew = !isEditingNew"
>
{{ isEditingNew ? '查看对比' : '编辑 XML' }}
</CommonButton>
<CommonButton size="tiny" secondary type="primary" @click="handleXmlImportClick">选择 XML 文件导入</CommonButton>
</div>
</div>
<div ref="rightParentContainer" class="flex-1 flex flex-col min-h-0 relative">
<n-input
v-if="isEditingNew"
v-model:value="newXmlText"
type="textarea"
placeholder="请粘贴目标工卡的完整 XML 内容,或者点击上方按钮导入文件..."
class="flex-1 font-mono text-xs"
:input-props="{ style: { height: '100%' } }"
@blur="handleNewXmlBlur"
/>
<div
v-else
ref="rightTextScrollContainer"
class="flex-1 border border-divider rounded-lg bg-card overflow-auto p-3 font-mono text-[11px] leading-5 select-text whitespace-pre relative scroll-container text-color1"
title="双击行内容可编辑这一行"
@scroll="onRightTextScroll"
>
<div
v-for="(line, idx) in newDiffLines"
:key="idx"
class="flex w-full group hover:bg-fill-2 min-h-[20px] cursor-pointer"
:class="{
'line-row-added': line.type === 'added',
'line-row-empty': line.type === 'empty'
}"
@click="highlightTargetNode(line, true)"
@dblclick.stop="startEditLine(line, true)"
>
<span class="line-num select-none w-10 text-right pr-2 border-r border-divider opacity-50">
{{ line.num || '' }}
</span>
<input
v-if="editingNewLineNum === line.num && line.num"
:id="`new-line-input-${line.num}`"
v-model="editingNewText"
class="flex-1 px-2 py-0.5 bg-fill-2 border border-primary text-[11px] font-mono text-color1 outline-none h-[18px] leading-[18px] rounded"
@blur="saveEditLine(true)"
@keyup.enter="saveEditLine(true)"
@keyup.esc="cancelEditLine(true)"
@click.stop
/>
<span v-else class="line-content pl-2 break-all whitespace-pre-wrap flex-1">{{ line.content }}</span>
</div>
</div>
<!-- 右侧文本差异指示条:放在滚动容器外部左侧,加宽并支持点击跳转和模拟拖拽 -->
<div
v-if="!isEditingNew"
class="absolute left-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title="点击定位或拖动滑块"
@click="handleRightTextMinimapClick"
>
<!-- 差异 markers -->
<div
v-for="(marker, mIdx) in rightTextDiffMarkers"
:key="mIdx"
class="absolute left-0 right-0 h-[3px] bg-[#2ecc71]"
:style="{ top: marker.top + '%' }"
></div>
<!-- 模拟滑块 -->
<div
class="absolute left-[1px] right-[1px] bg-primary/[0.15] hover:bg-primary/[0.25] active:bg-primary/[0.35] rounded border border-primary/20 cursor-grab active:cursor-grabbing transition-colors"
:style="{
top: rightTextScrollTopPercent + '%',
height: rightTextScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'rightText')"
></div>
</div>
</div>
</div>
<!-- 渲染区 -->
<div class="flex-1 flex flex-col min-h-0">
<div class="text-xs font-bold text-color3 mb-2 px-1 flex items-center space-x-1.5 shrink-0">
<span class="w-2 h-2 rounded-full bg-success"></span>
<span>目标工卡渲染 (新版本)</span>
</div>
<!-- 滚动容器 + 指示条共用一个 relative 包裹 -->
<div class="flex-1 min-h-0 relative">
<div
ref="rightScrollContainer"
class="is-diff-mode h-full overflow-auto border border-divider rounded-lg bg-card p-6 leading-relaxed relative scroll-container"
@scroll="onRightScroll"
>
<DocNodeRenderer v-if="newDiffTree" :node="newDiffTree" :parent="null" />
<div v-else class="h-full flex items-center justify-center text-xs text-color3 select-none">
暂无目标工卡数据,请在上方输入框中粘贴或导入
</div>
</div>
<!-- 右侧渲染差异指示条:滚动容器外部左侧,加宽并支持点击跳转和模拟拖拽 -->
<div
v-if="newDiffTree"
class="absolute left-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title="点击定位或拖动滑块"
@click="handleRightRenderMinimapClick"
>
<!-- 差异 markers -->
<div
v-for="(marker, mIdx) in rightRenderDiffMarkers"
:key="mIdx"
class="absolute left-0 right-0 h-[3px]"
:class="marker.type === 'added' ? 'bg-[#2ecc71]' : 'bg-[#f1c40f]'"
:style="{ top: marker.top + '%' }"
></div>
<!-- 模拟滑块 -->
<div
class="absolute left-[1px] right-[1px] bg-primary/[0.15] hover:bg-primary/[0.25] active:bg-primary/[0.35] rounded border border-primary/20 cursor-grab active:cursor-grabbing transition-colors"
:style="{
top: rightRenderScrollTopPercent + '%',
height: rightRenderScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'rightRender')"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
</CommonModal>
</template>
<script setup lang="ts">
import { useCompareModal } from './functionals'
import DocNodeRenderer from '../../../DocNodeRenderer/index.vue'
const {
showModal,
loading,
oldXmlText,
newXmlText,
oldDiffTree,
newDiffTree,
leftScrollContainer,
rightScrollContainer,
handleLeftScroll,
handleRightScroll,
loadCurrentXml,
resetDiff,
open,
handleCompare,
handleXmlImportClick,
formatXmlText,
isEditingOld,
isEditingNew,
oldDiffLines,
newDiffLines,
updateTextDiff,
leftTextScrollContainer,
rightTextScrollContainer,
handleLeftTextScroll,
handleRightTextScroll,
leftParentContainer,
rightParentContainer,
editingOldLineNum,
editingNewLineNum,
editingOldText,
editingNewText,
startEditLine,
saveEditLine,
cancelEditLine,
handleOldXmlBlur,
handleNewXmlBlur,
highlightTargetNode,
leftTextDiffMarkers,
rightTextDiffMarkers,
leftRenderDiffMarkers,
rightRenderDiffMarkers,
handleLeftTextMinimapClick,
handleRightTextMinimapClick,
handleLeftRenderMinimapClick,
handleRightRenderMinimapClick,
leftTextScrollTopPercent,
leftTextScrollHeightPercent,
rightTextScrollTopPercent,
rightTextScrollHeightPercent,
leftRenderScrollTopPercent,
leftRenderScrollHeightPercent,
rightRenderScrollTopPercent,
rightRenderScrollHeightPercent,
startDragScroll,
onLeftTextScroll,
onRightTextScroll,
onLeftScroll,
onRightScroll
} = useCompareModal()
// 注入 diffMode: true 到下级所有的 DocNodeRenderer 中
provide('diffMode', true)
defineExpose({
open
})
</script>
<style>
.app-modal.is-fullscreen .scroll-container {
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.015) 1px, transparent 1px);
background-size: 24px 24px;
}
/* 覆盖 n-input 文本域的高度以填充容器 */
.app-modal.is-fullscreen .n-input.n-input--textarea {
height: 100% !important;
}
.app-modal.is-fullscreen .n-input.n-input--textarea .n-input-wrapper {
height: 100% !important;
}
.app-modal.is-fullscreen .n-input.n-input--textarea textarea {
height: 100% !important;
flex: 1 !important;
}
/* 比对模式样式覆写 */
.is-diff-mode [contenteditable] {
pointer-events: none !important;
user-select: text !important;
}
.is-diff-mode .ring-2,
.is-diff-mode .ring-primary,
.is-diff-mode .focus\:bg-fill-3 {
box-shadow: none !important;
background-color: transparent !important;
}
/* 移除 hover 背景色 */
.is-diff-mode .doc-node-wrapper:hover {
background-color: transparent !important;
}
/* 隐藏对比弹窗内滚动容器的原生垂直滚动条,使用自定义指示条滑块代替;水平滚动条保留并美化 */
.scroll-container::-webkit-scrollbar {
width: 0 !important;
}
.scroll-container::-webkit-scrollbar:horizontal {
height: 6px !important;
display: block !important;
}
.scroll-container::-webkit-scrollbar-thumb:horizontal {
background-color: var(--divider-color, rgba(0, 0, 0, 0.15)) !important;
border-radius: 3px !important;
}
.scroll-container::-webkit-scrollbar-thumb:horizontal:hover {
background-color: var(--primary-color) !important;
}
.scroll-container {
-ms-overflow-style: -ms-autohiding-scrollbar !important;
}
/* 差异标记样式 */
.is-diff-mode [data-diff-status='added'] {
background-color: color-mix(in srgb, var(--success-color) 8%, transparent) !important;
border-left: 3px solid var(--success-color) !important;
padding-left: 4px !important;
margin-left: -7px !important;
}
.is-diff-mode [data-diff-status='removed'] {
background-color: color-mix(in srgb, var(--error-color) 8%, transparent) !important;
border-left: 3px solid var(--error-color) !important;
text-decoration: line-through !important;
opacity: 0.75 !important;
padding-left: 4px !important;
margin-left: -7px !important;
}
.is-diff-mode [data-diff-status='modified'] {
background-color: color-mix(in srgb, var(--warning-color) 12%, transparent) !important;
box-shadow:
inset 3px 0 0 var(--warning-color),
0 0 0 1.5px color-mix(in srgb, var(--warning-color) 40%, transparent) !important;
border-radius: 2px !important;
padding: 0 2px !important;
}
/* block 元素补充左侧缩进,避免 box-shadow 被裁剪 */
div.doc-node-wrapper[data-diff-status='modified'] {
margin-left: -5px !important;
padding-left: 5px !important;
}
/* 新的 XML 文本比对行级差异高亮 */
.line-row-removed {
background-color: color-mix(in srgb, var(--error-color) 10%, transparent) !important;
border-left: 3px solid var(--error-color) !important;
color: color-mix(in srgb, var(--error-color) 90%, var(--text-color-1)) !important;
}
.line-row-added {
background-color: color-mix(in srgb, var(--success-color) 10%, transparent) !important;
border-left: 3px solid var(--success-color) !important;
color: color-mix(in srgb, var(--success-color) 90%, var(--text-color-1)) !important;
}
.line-row-empty {
background-image: repeating-linear-gradient(-45deg, var(--divider-color) 0, var(--divider-color) 1px, transparent 0, transparent 50%);
background-size: 8px 8px;
opacity: 0.4;
user-select: none !important;
}
/* XML行选中对应的工卡渲染节点高亮闪烁效果 */
@keyframes highlight-glow {
0% {
box-shadow: 0 0 0 0px rgba(255, 125, 0, 0.4);
background-color: rgba(255, 125, 0, 0.1) !important;
}
50% {
box-shadow: 0 0 10px 4px rgba(255, 125, 0, 0.6);
background-color: rgba(255, 125, 0, 0.25) !important;
}
100% {
box-shadow: 0 0 0 0px rgba(255, 125, 0, 0.4);
background-color: rgba(255, 125, 0, 0.1) !important;
}
}
.highlight-focused-node {
animation: highlight-glow 1.5s ease-in-out infinite;
border-radius: 4px;
transition: all 0.3s ease;
}
/* 隐藏比对弹框的头部 */
.app-modal.compare-modal-nobar > .n-card-header {
display: none !important;
}
</style>
export const CELL_CHILD_OPTIONS = [
{ label: 'PARAC (中文)', value: 'PARAC' },
{ label: 'PARA (英文)', value: 'PARA' }
]
import type { FormInst, FormRules } from 'naive-ui'
import { CHINESE_FIRST_PARA_TAGS } from '@/configs/xmlTags'
export const useCreateTableModal = (
emit: (event: 'confirm', rows: number, cols: number, cellChildTags: string[]) => void
) => {
const show = ref(false)
const formRef = ref<FormInst | null>(null)
const form = reactive({
rows: 3,
cols: 3,
cellChildTags: [...CHINESE_FIRST_PARA_TAGS]
})
const rules: FormRules = {
rows: { type: 'number', required: true, message: '请指定行数', trigger: ['blur', 'change'] },
cols: { type: 'number', required: true, message: '请指定列数', trigger: ['blur', 'change'] }
}
const open = () => {
form.rows = 3
form.cols = 3
form.cellChildTags = [...CHINESE_FIRST_PARA_TAGS]
show.value = true
}
const handleConfirm = async () => {
try {
await formRef.value?.validate()
emit('confirm', form.rows, form.cols, form.cellChildTags)
show.value = false
} catch (err) {
// validation failed
}
}
return {
show,
formRef,
form,
rules,
open,
handleConfirm
}
}
......@@ -30,7 +30,7 @@
</div>
<n-form-item label="单元格初始化段落节点" path="cellChildTags">
<div class="w-full bg-fill-2 p-3 rounded border border-divider">
<CommonCheckbox v-model:value="form.cellChildTags" :options="cellChildOptions" :space-size="24" />
<CommonCheckbox v-model:value="form.cellChildTags" :options="CELL_CHILD_OPTIONS" :space-size="24" />
</div>
</n-form-item>
</div>
......@@ -39,48 +39,14 @@
</template>
<script setup lang="ts">
import type { FormInst, FormRules } from 'naive-ui'
import { CHINESE_FIRST_PARA_TAGS } from '@/configs/xmlTags'
import { CELL_CHILD_OPTIONS } from './constants'
import { useCreateTableModal } from './functionals'
const emit = defineEmits<{
confirm: [rows: number, cols: number, cellChildTags: string[]]
}>()
const cellChildOptions = [
{ label: 'PARAC (中文)', value: 'PARAC' },
{ label: 'PARA (英文)', value: 'PARA' }
]
const show = ref(false)
const formRef = ref<FormInst | null>(null)
const form = reactive({
rows: 3,
cols: 3,
cellChildTags: [...CHINESE_FIRST_PARA_TAGS]
})
const rules: FormRules = {
rows: { type: 'number', required: true, message: '请指定行数', trigger: ['blur', 'change'] },
cols: { type: 'number', required: true, message: '请指定列数', trigger: ['blur', 'change'] }
}
const open = () => {
form.rows = 3
form.cols = 3
form.cellChildTags = [...CHINESE_FIRST_PARA_TAGS]
show.value = true
}
const handleConfirm = async () => {
try {
await formRef.value?.validate()
emit('confirm', form.rows, form.cols, form.cellChildTags)
show.value = false
} catch (err) {
// validation failed
}
}
const { show, formRef, form, rules, open, handleConfirm } = useCreateTableModal(emit)
defineExpose({ open })
</script>
......@@ -91,6 +91,14 @@
<n-icon class="text-base"><search-outline /></n-icon>
<span class="text-xs font-medium">搜索翻译</span>
</button>
<button
type="button"
class="toolbar-btn flex items-center gap-1.5 px-2.5 h-7 rounded text-color2 hover:bg-fill-2 active:bg-fill-3 transition-colors select-none focus:outline-none flex-shrink-0"
@click="compareModalRef?.open()"
>
<n-icon class="text-base"><git-compare-outline /></n-icon>
<span class="text-xs font-medium">对比工卡</span>
</button>
</div>
<div class="w-px h-5 flex-shrink-0 mx-3" style="background: #d0d0d0"></div>
......@@ -223,6 +231,9 @@
<!-- 搜索翻译数据库弹窗 -->
<SearchTranslateModal ref="searchTranslateModalRef" />
<!-- 工卡对比弹窗 -->
<CompareModal ref="compareModalRef" />
</div>
</template>
......@@ -241,7 +252,8 @@ import {
CodeWorkingOutline,
SyncOutline,
CheckmarkCircleOutline,
SettingsOutline
SettingsOutline,
GitCompareOutline
} from '@vicons/ionicons5'
import { useEditorToolbar } from './functionals'
import { GREEN_BUTTONS } from './constants'
......@@ -252,6 +264,7 @@ import CreateSignoffModal from './components/CreateSignoffModal/index.vue'
import BatchTranslateModal from './components/BatchTranslateModal/index.vue'
import ExtractTranslateModal from './components/ExtractTranslateModal/index.vue'
import SearchTranslateModal from './components/SearchTranslateModal/index.vue'
import CompareModal from './components/CompareModal/index.vue'
const emit = defineEmits(['save', 'validate', 'export', 'preview', 'download-html'])
......@@ -276,6 +289,7 @@ const {
} = useEditorToolbar(emit)
const insertFragmentModalRef = ref<any>(null)
const compareModalRef = ref<any>(null)
</script>
<style scoped>
......
import { viewXmlVisible, viewXmlTitle, viewXmlContent } from '../../../functionals'
export const useViewXmlModal = () => {
const handleCopyXml = async () => {
try {
await navigator.clipboard.writeText(viewXmlContent.value)
window.$message?.success('XML 片段已成功复制到剪贴板')
} catch (err) {
window.$message?.error('复制失败,请手动选择复制')
}
}
const open = (title: string, content: string) => {
viewXmlTitle.value = title
viewXmlContent.value = content
viewXmlVisible.value = true
}
return {
viewXmlVisible,
viewXmlTitle,
viewXmlContent,
handleCopyXml,
open
}
}
......@@ -25,22 +25,9 @@
<script setup lang="ts">
import { ClipboardOutline } from '@vicons/ionicons5'
import { viewXmlVisible, viewXmlTitle, viewXmlContent } from '../../functionals'
import { useViewXmlModal } from './functionals'
const handleCopyXml = async () => {
try {
await navigator.clipboard.writeText(viewXmlContent.value)
window.$message?.success('XML 片段已成功复制到剪贴板')
} catch (err) {
window.$message?.error('复制失败,请手动选择复制')
}
}
const open = (title: string, content: string) => {
viewXmlTitle.value = title
viewXmlContent.value = content
viewXmlVisible.value = true
}
const { viewXmlVisible, viewXmlTitle, viewXmlContent, handleCopyXml, open } = useViewXmlModal()
defineExpose({ open })
</script>
......
......@@ -420,15 +420,9 @@ export function useNodeTree(
return lines
})
// 仅渲染可视区域的垂直虚线
// 渲染所有计算出的垂直虚线,防止视口尺寸监控失效或滚动缓冲区外节点连线断裂
const visibleVerticalLines = computed(() => {
const sTop = scrollTop.value
const vHeight = viewportHeight.value
const sBottom = sTop + vHeight
return verticalLines.value.filter((line) => {
const lineBottom = line.top + line.height
return lineBottom >= sTop && line.top <= sBottom
})
return verticalLines.value
})
// 展开/折叠逻辑
......
......@@ -56,7 +56,7 @@
</div>
<!-- 可见列表节点 -->
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0 space-y-0.5">
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0">
<div
v-for="item in visibleItems"
:key="item.id"
......
......@@ -45,6 +45,8 @@ const setDeepText = (node: XmlNode, text: string): void => {
export function useTableEditor(props: { node: XmlNode }) {
const store = useEditorStore()
const appStore = useAppStore()
const isDiffModeRaw = inject<any>('diffMode', false)
const isDiffMode = computed(() => typeof isDiffModeRaw === 'boolean' ? isDiffModeRaw : (isDiffModeRaw?.value ?? false))
const structure = ref<TableStructureModel>({
cols: 0,
......@@ -552,12 +554,44 @@ export function useTableEditor(props: { node: XmlNode }) {
}
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
// 计算新增列的自适应默认宽度,从偏好设置 store 中读取比重系数,防止权重失衡
const ratio = appStore.defaultNewColWidth ?? 0.2
let defaultWidth = `${ratio}*`
const activeWidths = colSpecs
.map((spec) => spec.attributes.COLWIDTH || spec.attributes.colwidth || '')
.filter((w) => !!w)
if (activeWidths.length > 0) {
const starWidths = activeWidths
.filter((w) => w.includes('*'))
.map((w) => parseFloat(w.replace('*', '')))
.filter((v) => !isNaN(v))
if (starWidths.length > 0) {
const avgStar = starWidths.reduce((sum, v) => sum + v, 0) / starWidths.length
// 基于偏好设置的比例,星号下限根据设定的比例安全限制
const starLimit = Math.max(0.05, ratio / 2)
defaultWidth = `${Math.max(starLimit, Math.round(avgStar * ratio * 10) / 10)}*`
} else {
const absWidths = activeWidths
.map((w) => parseFloat(w))
.filter((v) => !isNaN(v))
if (absWidths.length > 0) {
const avgAbs = absWidths.reduce((sum, v) => sum + v, 0) / absWidths.length
// 基于偏好设置的像素比例,下限为 20px
defaultWidth = `${Math.max(20, Math.round(avgAbs * ratio))}px`
}
}
}
const newColSpec: XmlNode = {
id: crypto.randomUUID(),
tagName: 'COLSPEC',
attributes: {
COLNAME: `col_temp_${Date.now()}`,
COLNUM: ''
COLNUM: '',
COLWIDTH: defaultWidth
},
children: [],
textContent: '',
......@@ -1008,7 +1042,22 @@ export function useTableEditor(props: { node: XmlNode }) {
allCells.push(...row.cells)
}
const matchedCell = allCells.find((c) => c.id === newId || c.paragraphs.some((p) => p.id === newId))
// 优先通过 nodeMap 向上回溯祖先链寻找最近的 ENTRY 节点,以支持嵌套复杂子节点时的精确定位
let entryId: string | null = null
if (allCells.some((c) => c.id === newId)) {
entryId = newId
} else {
let curr = store.nodeMap.get(newId)
while (curr) {
if (curr.node.tagName === 'ENTRY') {
entryId = curr.node.id
break
}
curr = curr.parent ? store.nodeMap.get(curr.parent.id) : undefined
}
}
const matchedCell = entryId ? allCells.find((c) => c.id === entryId) : null
if (matchedCell) {
if (selectedCellIds.value.includes(matchedCell.id)) {
return
......@@ -1078,31 +1127,7 @@ export function useTableEditor(props: { node: XmlNode }) {
}
const expectedArea = (maxCol - minCol + 1) * (maxRow - minRow + 1)
if (actualArea !== expectedArea) return false
const mask = Array.from({ length: rows }, () => Array(cols).fill(false))
for (const cell of cells) {
const cIdx = cell.colIdx ?? 0
const rIdx = cell.rowIdx ?? 0
const cSpan = cell.colspan ?? 1
const rSpan = cell.rowspan ?? 1
for (let dr = 0; dr < rSpan; dr++) {
for (let dc = 0; dc < cSpan; dc++) {
if (rIdx + dr < rows && cIdx + dc < cols) {
mask[rIdx + dr][cIdx + dc] = true
}
}
}
}
for (let r = minRow; r <= maxRow; r++) {
for (let c = minCol; c <= maxCol; c++) {
if (!mask[r][c]) return false
}
}
return true
return actualArea === expectedArea
})
const canSplitSelected = computed(() => {
......@@ -1122,6 +1147,8 @@ export function useTableEditor(props: { node: XmlNode }) {
}
if (selectedCellIds.value.length > 0) {
store.setSelectedNodeId(selectedCellIds.value[selectedCellIds.value.length - 1])
} else {
store.setSelectedNodeId(props.node.id)
}
} else {
selectedCellIds.value = [cell.id]
......@@ -1130,14 +1157,41 @@ export function useTableEditor(props: { node: XmlNode }) {
}
const handleCellClick = (cell: TableCellModel, e: MouseEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (preventCellClick.value) return
// 如果是 Ctrl 键点击,多选逻辑已在捕获阶段处理,冒泡阶段直接忽略
if (isCtrl) return
selectCellLogic(cell, e)
}
const handleCellClickCapture = (cell: TableCellModel, e: MouseEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isDiffMode.value) return
if (preventCellClick.value) return
if (isCtrl) {
e.stopImmediatePropagation()
e.stopPropagation()
e.preventDefault()
selectCellLogic(cell, e)
}
}
const handleCellMousedownCapture = (e: MouseEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isDiffMode.value) return
if (isCtrl) {
e.preventDefault()
}
}
const handleParaClick = (para: any, cell: TableCellModel, e: MouseEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (preventCellClick.value) return
store.setSelectedNodeId(para.id)
selectCellLogic(cell, e)
// 只有非 Ctrl 点击(单选)且单元格已被选中时,才将全局选中节点联动为该段落,避免多选时被段落选中状态干扰
if (!isCtrl && selectedCellIds.value.includes(cell.id)) {
store.setSelectedNodeId(para.id)
}
}
const handleCellBlur = (cellId: string, e: FocusEvent) => {
......@@ -1815,6 +1869,8 @@ export function useTableEditor(props: { node: XmlNode }) {
canSplitSelected,
selectedCells,
handleCellClick,
handleCellClickCapture,
handleCellMousedownCapture,
handleParaClick,
handleCellBlur,
handleRowAddSelect,
......
......@@ -4,7 +4,8 @@
<div class="flex items-center pb-2 border-b border-divider space-x-2">
<CommonTag type="info" size="small">{{ node.tagName }}</CommonTag>
<span class="text-xs text-color3">{{ CELL_EDIT_TIP }}</span>
<span class="text-xs text-color3 select-none">· 右键单元格可快速操作</span>
<span v-if="!isDiffMode" class="text-xs text-color3 select-none">· 右键单元格可快速操作</span>
<span v-else class="text-xs text-color3 select-none">· 比对只读模式</span>
</div>
<!-- 可视化二维表格视图 -->
......@@ -12,6 +13,7 @@
<table
class="w-full border-collapse text-sm table-fixed min-w-[600px] transition-all"
:data-node-id="structure.tgroupId"
data-tag-name="TGROUP"
:class="[isNodeSelected(structure.tgroupId) ? 'ring-2 ring-primary ring-offset-2 rounded' : '', tableFrameClass]"
>
<colgroup>
......@@ -24,6 +26,7 @@
<thead
v-if="structure.theadRows.length > 0"
:data-node-id="structure.theadId"
data-tag-name="THEAD"
@click.stop="editorStore.setSelectedNodeId(structure.theadId)"
class="transition-all"
:class="[isNodeSelected(structure.theadId) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/5' : '']"
......@@ -31,7 +34,7 @@
<template v-for="(row, rIdx) in structure.theadRows" :key="row.id">
<!-- 如果 ROW 拥有非 ENTRY 子节点 (如 EFFECT, CONEFFECT, REVST, REVEND) -->
<tr v-if="getRowNonEntryChildren(row.id).length > 0" class="bg-fill-1">
<th class="p-1 border border-divider bg-fill-4"></th>
<th v-if="!isDiffMode" class="p-1 border border-divider bg-fill-4"></th>
<th :colspan="structure.cols" class="p-1 border border-divider text-left align-middle space-x-1 font-normal">
<DocNodeRenderer
v-for="child in getRowNonEntryChildren(row.id)"
......@@ -46,6 +49,7 @@
<tr
:data-node-id="row.id"
data-tag-name="ROW"
class="border-b border-divider hover:bg-fill-3 group transition-colors cursor-pointer transition-all"
:class="[
isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '',
......@@ -58,7 +62,7 @@
class="p-2 border border-divider text-center select-none cursor-pointer transition-colors bg-fill-4 text-color3 font-bold"
:class="[isNodeSelected(row.id) ? 'bg-primary text-white' : 'hover:bg-fill-3']"
@click.stop="editorStore.setSelectedNodeId(row.id)"
@contextmenu.prevent="handleRowContextMenu(row.id, $event)"
@contextmenu.prevent="isDiffMode ? null : handleRowContextMenu(row.id, $event)"
>
H{{ structure.theadRows.length > 1 ? rIdx + 1 : '' }}
</th>
......@@ -68,15 +72,19 @@
:colspan="cell.colspan"
:rowspan="cell.rowspan"
:data-node-id="cell.id"
class="p-2 text-left font-bold bg-fill-4 border border-divider text-color1 transition-colors cursor-pointer relative"
data-tag-name="ENTRY"
class="p-2 text-left font-bold bg-fill-4 border border-divider text-color1 transition-colors relative"
:class="[
isCellSelected(cell, cell.colIdx ?? 0) ? 'bg-link-1 outline outline-2 outline-link-6 outline-offset-[-2px]' : '',
isDiffMode ? 'cursor-default' : 'cursor-pointer',
isCellSelected(cell, cell.colIdx ?? 0) ? 'cell-selected-highlight' : '',
isNodeSelected(structure.theadId) ? 'bg-primary/10 border-primary/40' : '',
insertedCellIds.has(cell.id) ? 'inserted-cell-highlight' : ''
]"
:style="getCellStyle(cell)"
@click.stop="handleCellClick(cell, $event)"
@contextmenu.prevent="handleCellContextMenu(cell, row.id, $event)"
@click.capture="handleCellClickCapture(cell, $event)"
@mousedown.capture="handleCellMousedownCapture($event)"
@click.stop="isDiffMode ? null : handleCellClick(cell, $event)"
@contextmenu.prevent="isDiffMode ? null : handleCellContextMenu(cell, row.id, $event)"
>
<template v-if="cell.rawNode && cell.rawNode.children && cell.rawNode.children.length > 0">
<DocNodeRenderer v-for="child in cell.rawNode!.children" :key="child.id" :node="child" :parent="cell.rawNode" />
......@@ -86,22 +94,24 @@
v-for="para in cell.paragraphs"
:key="para.id"
:data-node-id="para.id"
contenteditable="true"
:class="[isNodeSelected(para.id) ? 'ring-2 ring-primary bg-primary/5 font-bold' : 'hover:bg-fill-3']"
data-tag-name="PARA"
:contenteditable="!isDiffMode"
:class="[isNodeSelected(para.id) ? 'ring-2 ring-primary bg-primary/5 font-bold' : (!isDiffMode ? 'hover:bg-fill-3 cursor-text' : 'cursor-default')]"
:style="para.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
@click.stop="handleParaClick(para, cell, $event)"
@click.stop="isDiffMode ? null : handleParaClick(para, cell, $event)"
@blur="(e) => handleCellBlur(para.id, e)"
v-text="para.text"
></div>
</template>
<!-- 拖拽调整列宽手柄 -->
<div
v-if="!isDiffMode"
class="absolute -right-1 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-20 select-none"
@mousedown.stop.prevent="handleResizeStart(cell, $event)"
></div>
</th>
<!-- 表头操作栏 -->
<th class="p-2 border border-divider bg-fill-4 text-center">
<th v-if="!isDiffMode" class="p-2 border border-divider bg-fill-4 text-center">
<CommonButton size="tiny" quaternary circle type="error" @click.stop="handleRowDelete(row.id)">
<template #icon>
<n-icon><trash-outline /></n-icon>
......@@ -115,6 +125,7 @@
<!-- TBODY 渲染 -->
<tbody
:data-node-id="structure.tbodyId"
data-tag-name="TBODY"
@click.stop="editorStore.setSelectedNodeId(structure.tbodyId)"
class="transition-all"
:class="[isNodeSelected(structure.tbodyId) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/5' : '']"
......@@ -122,7 +133,7 @@
<template v-for="(row, rIdx) in structure.tbodyRows" :key="row.id">
<!-- 如果 ROW 拥有非 ENTRY 子节点 (如 EFFECT, CONEFFECT, REVST, REVEND) -->
<tr v-if="getRowNonEntryChildren(row.id).length > 0" class="bg-fill-1">
<td class="p-1 border border-divider bg-fill-4"></td>
<td v-if="!isDiffMode" class="p-1 border border-divider bg-fill-4"></td>
<td :colspan="structure.cols" class="p-1 border border-divider text-left align-middle space-x-1">
<DocNodeRenderer
v-for="child in getRowNonEntryChildren(row.id)"
......@@ -132,11 +143,12 @@
is-inline
/>
</td>
<td class="p-1 border border-divider"></td>
<td v-if="!isDiffMode" class="p-1 border border-divider"></td>
</tr>
<tr
:data-node-id="row.id"
data-tag-name="ROW"
class="border-b border-divider hover:bg-fill-3 group transition-colors cursor-pointer transition-all"
:class="[
isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '',
......@@ -149,7 +161,7 @@
class="p-2 border border-divider text-center select-none cursor-pointer transition-colors font-bold"
:class="[isNodeSelected(row.id) ? 'bg-primary text-white' : 'bg-fill-4 text-color3 hover:bg-fill-3']"
@click.stop="editorStore.setSelectedNodeId(row.id)"
@contextmenu.prevent="handleRowContextMenu(row.id, $event)"
@contextmenu.prevent="isDiffMode ? null : handleRowContextMenu(row.id, $event)"
>
{{ rIdx + 1 }}
</td>
......@@ -159,15 +171,19 @@
:colspan="cell.colspan"
:rowspan="cell.rowspan"
:data-node-id="cell.id"
class="p-2 border border-divider text-color2 transition-colors cursor-pointer relative"
data-tag-name="ENTRY"
class="p-2 border border-divider text-color2 transition-colors relative"
:class="[
isCellSelected(cell, cell.colIdx ?? 0) ? 'bg-link-1 outline outline-2 outline-link-6 outline-offset-[-2px]' : '',
isDiffMode ? 'cursor-default' : 'cursor-pointer',
isCellSelected(cell, cell.colIdx ?? 0) ? 'cell-selected-highlight' : '',
isNodeSelected(structure.tbodyId) ? 'bg-primary/5 border-primary/30' : '',
insertedCellIds.has(cell.id) ? 'inserted-cell-highlight' : ''
]"
:style="getCellStyle(cell)"
@click.stop="handleCellClick(cell, $event)"
@contextmenu.prevent="handleCellContextMenu(cell, row.id, $event)"
@click.capture="handleCellClickCapture(cell, $event)"
@mousedown.capture="handleCellMousedownCapture($event)"
@click.stop="isDiffMode ? null : handleCellClick(cell, $event)"
@contextmenu.prevent="isDiffMode ? null : handleCellContextMenu(cell, row.id, $event)"
>
<template v-if="cell.rawNode && cell.rawNode.children && cell.rawNode.children.length > 0">
<DocNodeRenderer v-for="child in cell.rawNode!.children" :key="child.id" :node="child" :parent="cell.rawNode" />
......@@ -177,22 +193,24 @@
v-for="para in cell.paragraphs"
:key="para.id"
:data-node-id="para.id"
contenteditable="true"
:class="[isNodeSelected(para.id) ? 'ring-2 ring-primary bg-primary/5' : 'hover:bg-fill-3']"
data-tag-name="PARA"
:contenteditable="!isDiffMode"
:class="[isNodeSelected(para.id) ? 'ring-2 ring-primary bg-primary/5' : (!isDiffMode ? 'hover:bg-fill-3 cursor-text' : 'cursor-default')]"
:style="para.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
@click.stop="handleParaClick(para, cell, $event)"
@click.stop="isDiffMode ? null : handleParaClick(para, cell, $event)"
@blur="(e) => handleCellBlur(para.id, e)"
v-text="para.text"
></div>
</template>
<!-- 拖拽调整列宽手柄 -->
<div
v-if="!isDiffMode"
class="absolute -right-1 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-20 select-none"
@mousedown.stop.prevent="handleResizeStart(cell, $event)"
></div>
</td>
<!-- 行删除按钮 -->
<td class="p-2 border border-divider text-center">
<td v-if="!isDiffMode" class="p-2 border border-divider text-center">
<CommonButton size="tiny" quaternary circle type="error" @click.stop="handleRowDelete(row.id)">
<template #icon>
<n-icon><trash-outline /></n-icon>
......@@ -203,7 +221,7 @@
</template>
<!-- 列删除快捷按钮行 -->
<tr class="hover:bg-transparent">
<tr v-if="!isDiffMode" class="hover:bg-transparent">
<td class="p-1 bg-transparent border-none"></td>
<td v-for="(_, cIdx) in structure.cols" :key="cIdx" class="p-1 text-center bg-transparent border-none">
<CommonButton
......@@ -245,6 +263,7 @@
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { TrashOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode'
import { useTableEditor, useTableBatchActions } from './functionals'
......@@ -258,10 +277,13 @@ const props = defineProps<{
}>()
const editorStore = useEditorStore()
const isDiffMode = inject('diffMode', false)
const {
structure,
selectedCellIds,
handleCellClick,
handleCellClickCapture,
handleCellMousedownCapture,
handleParaClick,
handleCellBlur,
handleRowAddSelect,
......@@ -364,4 +386,11 @@ table.frame-top tbody tr:nth-last-child(2) > *,
table.frame-sides tbody tr:nth-last-child(2) > * {
border-bottom: none !important;
}
/* 单元格合并选中时的黄色主题高亮 */
.cell-selected-highlight {
background-color: color-mix(in srgb, var(--warning-color, #f0a020) 10%, transparent) !important;
outline: 2px solid var(--warning-color, #f0a020) !important;
outline-offset: -2px !important;
}
</style>
......@@ -81,7 +81,7 @@
:options="attrDef.enumValues.map((v: any) => ({ label: v, value: v }))"
size="tiny"
class="flex-1"
@update:value="syncMixedContent"
@change="syncMixedContent"
/>
<n-input v-else v-model:value="item.attributes[attrName]" size="tiny" class="flex-1" @input="syncMixedContent" />
</div>
......
......@@ -5,7 +5,7 @@ import { DEFAULT_FILE_NAME } from '../constants'
// 静态导入 DTD JSON
import dtdJson from '@/assets/json/dtd.json'
// 导入原始 XML 文本 (?raw)
import xmlText from '@/assets/file/AMEA-A282400-02-1_0_0.xml?raw'
import xmlText from '@/assets/file/AMEA-A282400-02-1_0_0_updated.xml?raw'
/**
* 工卡 XML 编辑器核心业务逻辑 Hook
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment