Commit 16a92c5e by pangchong

style(global): 优化全局加载蒙层及消息提示规范

- 重构全局阻塞加载蒙层,替换为自定义动画实现
- 添加亮暗主题下全局加载背景及模糊效果过渡
- 强制使用全局统一消息提示对象,禁止从 naive-ui 直接导入 useMessage
- 规范全局按键事件监听,提高编辑器撤销重做体验
- 更新 XML 编辑器状态管理,增强节点映射与撤销重做机制
- 优化节点操作方法,新增 XML 片段插入与文本搜索替换功能
- 禁止直接导入定义状态管理的 defineStore
- 统一所有选择框控件使用 CommonSelect,禁止直接用原生 n-select
- 统一上传、下载组件接口支持自定义 Promise 函数及本地 Blob 处理
- 细化渲染文档节点的颜色和样式规范,强制部分节点使用物理样式精确定制
- 调整弹窗组件宽度绑定方式,避免样式警告与布局异常
- 替换 CommonTableSelect 中原生按钮为项目封装按钮组件
- 移除不必要的无用引入,精简部分代码实现
parent ff03e1af
...@@ -69,6 +69,7 @@ ...@@ -69,6 +69,7 @@
- **边框色**`border-color1` ~ `border-color4``border-divider``border-base` - **边框色**`border-color1` ~ `border-color4``border-divider``border-base`
- **文字色**`text-color1` ~ `text-color4``text-primary``text-regular``text-secondary` - **文字色**`text-color1` ~ `text-color4``text-primary``text-regular``text-secondary`
- **状态/提示色**:使用 `text-danger` (或 `text-danger-x``text-danger-6` 对应系统错误色)、`text-success` (成功色)、`text-warning` (警示色),严禁使用 `text-red-600` 等固定原生色。 - **状态/提示色**:使用 `text-danger` (或 `text-danger-x``text-danger-6` 对应系统错误色)、`text-success` (成功色)、`text-warning` (警示色),严禁使用 `text-red-600` 等固定原生色。
- **特定技术手册/PDF呈现例外**:在渲染特定技术手册节点(如 `DocNodeRenderer` 内的 `WARNING`, `CAUTION`, `NOTE`, `EFFECT`, `CONEFFECT`, `SBEFF`, `SBEFFC`, `REFBLOCK`, `REFINT`, `REFEXT`, `GRPHCREF`, `EIN` 以及 PDF 特定的表格、签字点等)时,其渲染样式必须严格以手册/PDF规范文件下的物理颜色、物理边框或字体样式定义为主(例如:警告 `WARNING` 必须为固定红 `red`,警戒 `CAUTION` 必须为 `#ff6a00`,注意 `NOTE` 必须为蓝色 `blue`,适用性说明 `EFFECT` / `SBEFF` 等必须为红色斜体 `color: red; font-style: italic;`,引用及功能号 `REFBLOCK` / `REFINT` / `REFEXT` / `GRPHCREF` / `EIN` 等必须为蓝色 `color: blue`,且功能号 `EIN` 必须带下划线,签字表必须是硬编码的 `border-black` 黑实线等)。对于此类具有强制色彩与表现形式规范的节点,**必须使用具体的物理样式属性(如 style="color: red;")进行精确定制,严禁使用 Tailwind/Naive UI 随暗黑模式等主题自适应的颜色类名或变量(如 text-primary, text-danger, text-red-600 等)**,以确保手册呈现的技术准确性与 PDF 导出的一致性。其他常规 UI 框架及骨架情况方可采用系统内置主题配置。
### 主题配置参考 ### 主题配置参考
系统内置的亮暗主题颜色配置如下,可通过主题变量自适应: 系统内置的亮暗主题颜色配置如下,可通过主题变量自适应:
...@@ -88,6 +89,7 @@ ...@@ -88,6 +89,7 @@
--- ---
## 7. Dialog 与 UI 反馈规范 ## 7. Dialog 与 UI 反馈规范
- **消息提示规范 (强制)**:全项目**禁止**在任何业务组件、Hook 或 Store 中从 `naive-ui` 导入并使用 `useMessage()`,也**不要**使用 `@/hooks/useMessage.ts`**强制要求统一使用全局挂载的 `window.$message`**(如 `window.$message.success``window.$message.error``window.$message.warning` 等),以避免多余的 Provider 上下文依赖。
- **Promise 风格确认弹窗 (强制)**:删除、禁用、重置等敏感确认操作**必须**使用 `await window.$dialog.warning({ ... })` 进行阻塞调用。 - **Promise 风格确认弹窗 (强制)**:删除、禁用、重置等敏感确认操作**必须**使用 `await window.$dialog.warning({ ... })` 进行阻塞调用。
- **禁止 Callback 模式**:严禁使用 `onPositiveClick``onNegativeClick` 等回调函数形式。 - **禁止 Callback 模式**:严禁使用 `onPositiveClick``onNegativeClick` 等回调函数形式。
- **配置约定**:底层 Hook 已封装默认配置,业务调用时**禁止**手动重复设置 `positiveText: '确定'``negativeText: '取消'` - **配置约定**:底层 Hook 已封装默认配置,业务调用时**禁止**手动重复设置 `positiveText: '确定'``negativeText: '取消'`
...@@ -175,3 +177,21 @@ ...@@ -175,3 +177,21 @@
``` ```
- **禁止手动导入 `defineStore`**`defineStore` 已配置为全局自动导入,在 `index.ts` 中直接使用即可,无需编写 `import { defineStore } from 'pinia'` - **禁止手动导入 `defineStore`**`defineStore` 已配置为全局自动导入,在 `index.ts` 中直接使用即可,无需编写 `import { defineStore } from 'pinia'`
---
## 15. XML 编辑器树与编辑区联动定位规范
- **最近块定位 (最近渲染块容器)**
- 定位编辑区时,虚拟滚动的块位置定位**严禁**使用顶层大块(如 `TOPIC` 等远祖先容器)进行直接命中,否则会导致视图跳到整个章节开头。
- **必须**使用自底向上的祖先链遍历方法(如 `findNearestBlockIdx`),查找最近(即最深层)的已注册块容器(如 `SUBTASK`, `PRETOPIC` 等),以确保精确定位到目标节点所在的子块。
- **未独立渲染节点的定位与选中回退 (选中状态同步)**
-`DocNodeRenderer` 中部分节点被合并渲染或剔除了独立 DOM(即自身在 DOM 树中没有 `data-node-id` 标签,如 `CONNBR`, `CONNAME` ),当左侧树点击此类节点时,定位逻辑应由下至上沿着路径(`nodePath`)查找第一个已挂载的父/祖先 DOM 节点,并直接对该父/祖先 DOM 节点进行滚动居中。
- 在定位到父/祖先 DOM 节点后,**必须**同步将 `editorStore.setSelectedNodeId` 更新为该实际高亮/定位的父/祖先节点 ID,以便右侧编辑区在该父/祖先节点区域上呈现选中高亮状态,左侧树也同步选中该节点,保持焦点一致。
---
## 16. 全局选择框与输入框占位符规范
- **统一使用 `CommonSelect`**:在所有业务界面中,**禁止**直接使用 Naive UI 原生的 `n-select`**强制要求**统一使用项目封装好的全局组件 `CommonSelect`
- **禁用冗余的 placeholder 占位符**
- 在编写 `CommonSelect``n-input` 组件时,对于普通属性、枚举选择或内容输入,**禁止**手动添加 `placeholder="请选择"``placeholder="选择"``placeholder="请输入"` 等冗余的占位字符。
- 只在具有明确指示性特指文案的业务场景下(如“请选择切换动画”等特有场景),方可添加具体的占位文本。
- **事件监听规范**:对 `CommonSelect` 组件监听值变更回调时,**必须**绑定组件导出的 **`@change`** 事件(如 `@change="onTagChange"`),**禁止**使用 `@update:value` 监听器,以保障数据联动更新的生命周期一致性。
...@@ -14,14 +14,13 @@ ...@@ -14,14 +14,13 @@
<router-view></router-view> <router-view></router-view>
</div> </div>
<!-- 全局阻塞式加载蒙层 (极简版) --> <!-- 全局阻塞式加载蒙层 -->
<transition name="loading-fade"> <transition name="loading-fade">
<div v-if="appStore.loading" class="global-loading-overlay"> <div v-if="appStore.loading" class="global-loading-overlay">
<n-spin size="large"> <div class="custom-loader-wrapper">
<template #description> <div class="custom-loader-ring"></div>
<span class="loading-text">{{ appStore.loadingText }}</span> <div class="custom-loading-text">{{ appStore.loadingText }}</div>
</template> </div>
</n-spin>
</div> </div>
</transition> </transition>
</n-dialog-provider> </n-dialog-provider>
...@@ -198,15 +197,49 @@ body.gray-mode { ...@@ -198,15 +197,49 @@ body.gray-mode {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: rgba(0, 0, 0, 0.2); background-color: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(4px); backdrop-filter: blur(10px);
transition:
background-color 0.3s,
backdrop-filter 0.3s;
}
.dark .global-loading-overlay {
background-color: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(10px);
}
.custom-loader-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.custom-loader-ring {
width: 44px;
height: 44px;
border: 3px solid color-mix(in srgb, var(--primary-color) 12%, transparent);
border-top: 3px solid var(--primary-color);
border-radius: 50%;
animation: loader-spin 0.85s linear infinite;
}
@keyframes loader-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
.loading-text { .custom-loading-text {
margin-top: 12px; margin-top: 14px;
font-size: 14px; font-size: 14px;
color: var(--primary-color); color: var(--primary-color);
letter-spacing: 1px; font-weight: 500;
letter-spacing: 0.5px;
} }
.loading-fade-enter-active, .loading-fade-enter-active,
......
...@@ -66,11 +66,12 @@ ...@@ -66,11 +66,12 @@
import { DownloadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5' import { DownloadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
interface DownloadOptions { interface DownloadOptions {
api?: string
params?: any
fileName?: string fileName?: string
title?: string title?: string
callback?: (success: boolean) => void callback?: (success: boolean) => void
localBlob?: Blob
/** 自定义下载 Promise 函数 */
downloadFunc?: () => Promise<any>
} }
const themeVars = useThemeVars() const themeVars = useThemeVars()
...@@ -96,33 +97,40 @@ const open = async (options: DownloadOptions) => { ...@@ -96,33 +97,40 @@ const open = async (options: DownloadOptions) => {
// 开启模拟进度条定时器 // 开启模拟进度条定时器
progressTimer = setInterval(() => { progressTimer = setInterval(() => {
if (progress.value < 90) { if (progress.value < 90) {
progress.value += Math.floor(Math.random() * 5) + 2 progress.value += Math.floor(Math.random() * 8) + 4
} else if (progress.value < 98) {
progress.value += 0.5
} }
}, 150) }, 120)
const { api = '/v1/plugins/ATTACHMENT_DOWN', params = {}, fileName: name } = options const name = options.fileName
try { try {
const success = await service.download(api, params, name, { showLoading: false }) if (options.downloadFunc) {
await options.downloadFunc()
} else if (options.localBlob) {
// 如果提供了本地 Blob,直接触发本地下载
const downloadUrl = window.URL.createObjectURL(options.localBlob)
const a = document.createElement('a')
a.href = downloadUrl
a.download = name || `export_${new Date().getTime()}.xml`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(downloadUrl)
document.body.removeChild(a)
} else {
// 兜底模拟
await new Promise((resolve) => setTimeout(resolve, 800))
}
if (success) {
progress.value = 100 progress.value = 100
window.$message.success('下载任务已完成') window.$message.success('下载任务已完成')
// 延迟关闭,让用户看到 100% 成功状态
setTimeout(() => { setTimeout(() => {
show.value = false show.value = false
options.callback?.(true) options.callback?.(true)
}, 800) }, 800)
} else { } catch (err: any) {
show.value = false
options.callback?.(false)
}
} catch (err) {
console.error('Download failed', err) console.error('Download failed', err)
progress.value = 0 progress.value = 0
window.$message.error('下载失败,请重试') window.$message.error(err.message || '下载失败')
show.value = false show.value = false
options.callback?.(false) options.callback?.(false)
} finally { } finally {
......
<template> <template>
<CommonModal v-model="show" title="导出确认" style="width: 500px"> <CommonModal v-model="show" title="导出确认" :width="500">
<div class="p-4 py-6"> <div class="p-4 py-6">
<n-form-item label="选择导出范围" label-placement="left"> <n-form-item label="选择导出范围" label-placement="left">
<n-radio-group v-model:value="exportMode" name="exportMode"> <n-radio-group v-model:value="exportMode" name="exportMode">
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { service } from '@/api/index'
const show = ref(false) const show = ref(false)
const loading = ref(false) const loading = ref(false)
......
<template> <template>
<CommonModal v-model="showModal" :title="title" style="width: 500px"> <CommonModal v-model="showModal" :title="title" :width="500">
<div class="p-6"> <div class="p-6">
<n-upload v-model:file-list="fileList" :default-upload="false" :max="1" action="#" @before-upload="beforeUpload" @remove="handleRemove"> <n-upload v-model:file-list="fileList" :default-upload="false" :max="1" action="#" @before-upload="beforeUpload" @remove="handleRemove">
<n-upload-dragger v-if="fileList.length === 0"> <n-upload-dragger v-if="fileList.length === 0">
...@@ -54,7 +54,7 @@ ...@@ -54,7 +54,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { CloudUploadOutline, DownloadOutline } from '@vicons/ionicons5' import { CloudUploadOutline, DownloadOutline } from '@vicons/ionicons5'
import type { UploadFileInfo } from 'naive-ui' import type { UploadFileInfo } from 'naive-ui'
import { service } from '@/api/index'
interface ImportOptions { interface ImportOptions {
title?: string title?: string
...@@ -193,10 +193,10 @@ const handleDownloadTemplate = () => { ...@@ -193,10 +193,10 @@ const handleDownloadTemplate = () => {
...(context.value.downloadParams || {}) ...(context.value.downloadParams || {})
} }
const downloadApi = context.value.templateApi || '/v1/plugins/ATTACHMENT_DOWN' const downloadApi = context.value.templateApi || '/v1/plugins/ATTACHMENT_DOWN'
const fileName = context.value.templateTitle || '导入模板.xlsx'
openDownloadModal({ openDownloadModal({
api: downloadApi, downloadFunc: () => service.download(downloadApi, params, fileName),
params, fileName,
fileName: context.value.templateTitle || '导入模板.xlsx',
title: '下载导入模板' title: '下载导入模板'
}) })
} }
......
...@@ -128,7 +128,7 @@ ...@@ -128,7 +128,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAttrs } from 'vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui' import type { DataTableColumns } from 'naive-ui'
import { PlayBackOutline, ChevronBackOutline, ChevronForwardOutline, PlayForwardOutline, RefreshOutline } from '@vicons/ionicons5' import { PlayBackOutline, ChevronBackOutline, ChevronForwardOutline, PlayForwardOutline, RefreshOutline } from '@vicons/ionicons5'
...@@ -419,7 +419,6 @@ const mergedColumns = computed(() => { ...@@ -419,7 +419,6 @@ const mergedColumns = computed(() => {
title: '序号', title: '序号',
key: 'index', key: 'index',
width: 80, width: 80,
align: 'center',
render: (_: any, index: number) => { render: (_: any, index: number) => {
return (currentPage.value - 1) * currentPageSize.value + index + 1 return (currentPage.value - 1) * currentPageSize.value + index + 1
} }
......
...@@ -38,7 +38,7 @@ ...@@ -38,7 +38,7 @@
size="small" size="small"
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
/> />
<n-button type="primary" size="small" @click="handleSearch">搜索</n-button> <CommonButton type="primary" size="small" @click="handleSearch">搜索</CommonButton>
</n-input-group> </n-input-group>
</div> </div>
<div class="flex-1 min-h-0"> <div class="flex-1 min-h-0">
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { service } from '@/api/index'
import type { PropType, ComponentPublicInstance } from 'vue' import type { PropType, ComponentPublicInstance } from 'vue'
const props = defineProps({ const props = defineProps({
......
<template> <template>
<CommonModal v-model="show" :title="title" style="width: 500px"> <CommonModal v-model="show" :title="title" :width="500">
<div class="p-6"> <div class="p-6">
<n-upload <n-upload
v-model:file-list="fileList" v-model:file-list="fileList"
...@@ -38,19 +38,17 @@ ...@@ -38,19 +38,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { CloudUploadOutline } from '@vicons/ionicons5' import { CloudUploadOutline } from '@vicons/ionicons5'
import type { UploadFileInfo } from 'naive-ui' import type { UploadFileInfo } from 'naive-ui'
import { service } from '@/api/index'
export interface UploadOptions { export interface UploadOptions {
/** 弹窗标题 */ /** 弹窗标题 */
title?: string title?: string
/** 上传接口地址 (FunctionCode), 不传则默认为 /v1/plugins/ATTACHMENT_UPLOAD */
api?: string
/** 允许选择的文件类型,例如 'image/*' */ /** 允许选择的文件类型,例如 'image/*' */
accept?: string accept?: string
/** 接口所需的其它业务参数 */
data?: Record<string, any>
/** 上传成功后的回调 */ /** 上传成功后的回调 */
onSuccess?: (res?: any) => void onSuccess?: (res?: any, file?: File | null) => void
/** 自定义上传 Promise 函数 */
uploadFunc?: (file: File) => Promise<any>
} }
const show = ref(false) const show = ref(false)
...@@ -84,47 +82,43 @@ const handleRemove = () => { ...@@ -84,47 +82,43 @@ const handleRemove = () => {
const handleConfirm = async () => { const handleConfirm = async () => {
const ctx = context.value const ctx = context.value
if (fileList.value.length === 0 || !ctx) return if (fileList.value.length === 0 || !ctx) return
const uploadApi = ctx.api || '/v1/plugins/ATTACHMENT_UPLOAD'
uploading.value = true uploading.value = true
uploadProgress.value = 0 uploadProgress.value = 0
// 模拟进度 // 开启模拟进度条定时器
progressTimer = setInterval(() => { progressTimer = setInterval(() => {
if (uploadProgress.value < 90) { if (uploadProgress.value < 90) {
uploadProgress.value += Math.floor(Math.random() * 5) + 1 uploadProgress.value += Math.floor(Math.random() * 8) + 4
} }
}, 200) }, 150)
try { try {
const formData = new FormData()
const file = fileList.value[0]?.file const file = fileList.value[0]?.file
if (file) { let res = null
formData.append('file', file) if (ctx.uploadFunc && file) {
} res = await ctx.uploadFunc(file)
} else {
// 合并业务参数(cat, sourceId 等) // 纯前端模拟演示,延迟一小段时间代表上传
if (ctx.data) { await new Promise((resolve) => setTimeout(resolve, 800))
Object.entries(ctx.data).forEach(([key, val]) => {
formData.append(key, String(val))
})
} }
const res = await service.post(uploadApi, formData, { showLoading: false })
if (res.code === 200) {
uploadProgress.value = 100 uploadProgress.value = 100
window.$message.success('上传成功') window.$message.success('上传成功')
ctx.onSuccess?.(res) ctx.onSuccess?.(res, file)
setTimeout(() => { setTimeout(() => {
show.value = false show.value = false
}, 500) }, 500)
}
} catch (e: any) { } catch (e: any) {
console.error('Upload failed', e) console.error('Upload failed', e)
uploadProgress.value = 0 uploadProgress.value = 0
window.$message.error(e.message || '上传失败')
} finally { } finally {
uploading.value = false uploading.value = false
if (progressTimer) clearInterval(progressTimer) if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
} }
} }
......
import mitt from 'mitt' import mitt from 'mitt'
import { onBeforeUnmount } from 'vue'
type Fn = (...args: any[]) => void type Fn = (...args: any[]) => void
......
...@@ -19,10 +19,12 @@ ...@@ -19,10 +19,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAppStore } from '@/store/app/index' import { useAppStore } from '@/store/app/index'
import { useEditorStore } from '@/store/editor'
import CommonDownloadModal from '@/components/CommonDownloadModal.vue' import CommonDownloadModal from '@/components/CommonDownloadModal.vue'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const appStore = useAppStore() const appStore = useAppStore()
const editorStore = useEditorStore()
const globalImportModalRef = ref() const globalImportModalRef = ref()
const globalExportModalRef = ref() const globalExportModalRef = ref()
const globalAttachmentModalRef = ref() const globalAttachmentModalRef = ref()
...@@ -35,16 +37,46 @@ const handleGlobalKeydown = (e: KeyboardEvent) => { ...@@ -35,16 +37,46 @@ const handleGlobalKeydown = (e: KeyboardEvent) => {
const ctrl = e.ctrlKey || e.metaKey const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey const shift = e.shiftKey
// 如果当前焦点在普通文本输入框中,保留其原生的行内打字撤销行为
const target = e.target as HTMLElement
const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
// Ctrl+Shift+D: 切换深色/浅色主题 // Ctrl+Shift+D: 切换深色/浅色主题
if (ctrl && shift && e.key.toLowerCase() === 'd') { if (ctrl && shift && e.key.toLowerCase() === 'd') {
e.preventDefault() e.preventDefault()
appStore.isDark = !appStore.isDark appStore.isDark = !appStore.isDark
return return
} }
if (!isInput) {
// Ctrl+Z: 全局撤销
if (ctrl && !shift && e.key.toLowerCase() === 'z') {
e.preventDefault()
if (document.activeElement && (document.activeElement as HTMLElement).isContentEditable) {
;(document.activeElement as HTMLElement).blur()
}
setTimeout(() => {
editorStore.undo()
}, 0)
return
}
// Ctrl+Y 或 Ctrl+Shift+Z: 全局重做
if ((ctrl && e.key.toLowerCase() === 'y') || (ctrl && shift && e.key.toLowerCase() === 'z')) {
e.preventDefault()
if (document.activeElement && (document.activeElement as HTMLElement).isContentEditable) {
;(document.activeElement as HTMLElement).blur()
}
setTimeout(() => {
editorStore.redo()
}, 0)
return
}
}
} }
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', handleGlobalKeydown) window.addEventListener('keydown', handleGlobalKeydown, true)
window.$importModal = globalImportModalRef.value window.$importModal = globalImportModalRef.value
window.$exportModal = globalExportModalRef.value window.$exportModal = globalExportModalRef.value
window.$attachmentModal = globalAttachmentModalRef.value window.$attachmentModal = globalAttachmentModalRef.value
...@@ -57,7 +89,7 @@ onMounted(() => { ...@@ -57,7 +89,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown) window.removeEventListener('keydown', handleGlobalKeydown, true)
}) })
</script> </script>
......
...@@ -168,7 +168,7 @@ ...@@ -168,7 +168,7 @@
<!-- 页面切换动画 --> <!-- 页面切换动画 -->
<div class="mb-5"> <div class="mb-5">
<div class="text-sm font-semibold mb-3" :style="{ color: themeVars.textColor2 }">页面切换动画</div> <div class="text-sm font-semibold mb-3" :style="{ color: themeVars.textColor2 }">页面切换动画</div>
<n-select v-model:value="appStore.transitionName" :options="transitionOptions" placeholder="请选择切换动画" /> <CommonSelect v-model:value="appStore.transitionName" :options="transitionOptions" placeholder="请选择切换动画" />
</div> </div>
<n-divider class="my-4" /> <n-divider class="my-4" />
......
import { findNodeById, serializeTreeToXml, findParentNode, parseXmlToTree } from '@/utils/xmlParser' import { markRaw, type Ref } from 'vue'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import type { EditorState } from './types' import type { EditorState } from './types'
import { createDefaultAttributes } from '@/utils/dtdManager' import { createDefaultAttributes, canAddChild } from '@/utils/dtdManager'
import { parseXmlToTree } from '@/utils/xmlParser'
export const nodeSelectedRefs = new Map<string, Ref<boolean>>()
/** /**
* 辅助函数:根据标签名创建具有完整子结构的默认 XML 节点对象 * 辅助函数:根据标签名创建具有完整子结构的默认 XML 节点对象
...@@ -26,9 +29,33 @@ const createDefaultNodeStructure = (tagName: string): XmlNode => { ...@@ -26,9 +29,33 @@ const createDefaultNodeStructure = (tagName: string): XmlNode => {
tagName: 'TGROUP', tagName: 'TGROUP',
attributes: { COLS: '3' }, attributes: { COLS: '3' },
children: [ children: [
{ id: crypto.randomUUID(), tagName: 'COLSPEC', attributes: { COLNAME: 'col1', COLNUM: '1', COLWIDTH: '1*' }, children: [], textContent: '', mixedContent: [], parentId: tgroupId }, {
{ id: crypto.randomUUID(), tagName: 'COLSPEC', attributes: { COLNAME: 'col2', COLNUM: '2', COLWIDTH: '1*' }, children: [], textContent: '', mixedContent: [], parentId: tgroupId }, id: crypto.randomUUID(),
{ id: crypto.randomUUID(), tagName: 'COLSPEC', attributes: { COLNAME: 'col3', COLNUM: '3', COLWIDTH: '1*' }, children: [], textContent: '', mixedContent: [], parentId: tgroupId }, tagName: 'COLSPEC',
attributes: { COLNAME: 'col1', COLNUM: '1', COLWIDTH: '1*' },
children: [],
textContent: '',
mixedContent: [],
parentId: tgroupId
},
{
id: crypto.randomUUID(),
tagName: 'COLSPEC',
attributes: { COLNAME: 'col2', COLNUM: '2', COLWIDTH: '1*' },
children: [],
textContent: '',
mixedContent: [],
parentId: tgroupId
},
{
id: crypto.randomUUID(),
tagName: 'COLSPEC',
attributes: { COLNAME: 'col3', COLNUM: '3', COLWIDTH: '1*' },
children: [],
textContent: '',
mixedContent: [],
parentId: tgroupId
},
{ {
id: theadId, id: theadId,
tagName: 'THEAD', tagName: 'THEAD',
...@@ -39,9 +66,33 @@ const createDefaultNodeStructure = (tagName: string): XmlNode => { ...@@ -39,9 +66,33 @@ const createDefaultNodeStructure = (tagName: string): XmlNode => {
tagName: 'ROW', tagName: 'ROW',
attributes: {}, attributes: {},
children: [ children: [
{ id: crypto.randomUUID(), tagName: 'ENTRY', attributes: {}, children: [], textContent: '列头 1', mixedContent: [], parentId: rowId1 }, {
{ id: crypto.randomUUID(), tagName: 'ENTRY', attributes: {}, children: [], textContent: '列头 2', mixedContent: [], parentId: rowId1 }, id: crypto.randomUUID(),
{ id: crypto.randomUUID(), tagName: 'ENTRY', attributes: {}, children: [], textContent: '列头 3', mixedContent: [], parentId: rowId1 } tagName: 'ENTRY',
attributes: {},
children: [],
textContent: '列头 1',
mixedContent: [],
parentId: rowId1
},
{
id: crypto.randomUUID(),
tagName: 'ENTRY',
attributes: {},
children: [],
textContent: '列头 2',
mixedContent: [],
parentId: rowId1
},
{
id: crypto.randomUUID(),
tagName: 'ENTRY',
attributes: {},
children: [],
textContent: '列头 3',
mixedContent: [],
parentId: rowId1
}
], ],
textContent: '', textContent: '',
mixedContent: [], mixedContent: [],
...@@ -62,9 +113,33 @@ const createDefaultNodeStructure = (tagName: string): XmlNode => { ...@@ -62,9 +113,33 @@ const createDefaultNodeStructure = (tagName: string): XmlNode => {
tagName: 'ROW', tagName: 'ROW',
attributes: {}, attributes: {},
children: [ children: [
{ id: crypto.randomUUID(), tagName: 'ENTRY', attributes: {}, children: [], textContent: '内容 1-1', mixedContent: [], parentId: rowId2 }, {
{ id: crypto.randomUUID(), tagName: 'ENTRY', attributes: {}, children: [], textContent: '内容 1-2', mixedContent: [], parentId: rowId2 }, id: crypto.randomUUID(),
{ id: crypto.randomUUID(), tagName: 'ENTRY', attributes: {}, children: [], textContent: '内容 1-3', mixedContent: [], parentId: rowId2 } tagName: 'ENTRY',
attributes: {},
children: [],
textContent: '内容 1-1',
mixedContent: [],
parentId: rowId2
},
{
id: crypto.randomUUID(),
tagName: 'ENTRY',
attributes: {},
children: [],
textContent: '内容 1-2',
mixedContent: [],
parentId: rowId2
},
{
id: crypto.randomUUID(),
tagName: 'ENTRY',
attributes: {},
children: [],
textContent: '内容 1-3',
mixedContent: [],
parentId: rowId2
}
], ],
textContent: '', textContent: '',
mixedContent: [], mixedContent: [],
...@@ -175,8 +250,24 @@ const createDefaultNodeStructure = (tagName: string): XmlNode => { ...@@ -175,8 +250,24 @@ const createDefaultNodeStructure = (tagName: string): XmlNode => {
tagName: 'SELECTION', tagName: 'SELECTION',
attributes: { ...attributes }, attributes: { ...attributes },
children: [ children: [
{ id: crypto.randomUUID(), tagName: 'SELECT-ITEM', attributes: { VALUE: 'yes' }, children: [], textContent: '是 (Yes)', mixedContent: [], parentId: id }, {
{ id: crypto.randomUUID(), tagName: 'SELECT-ITEM', attributes: { VALUE: 'no' }, children: [], textContent: '否 (No)', mixedContent: [], parentId: id } id: crypto.randomUUID(),
tagName: 'SELECT-ITEM',
attributes: { VALUE: 'yes' },
children: [],
textContent: '是 (Yes)',
mixedContent: [],
parentId: id
},
{
id: crypto.randomUUID(),
tagName: 'SELECT-ITEM',
attributes: { VALUE: 'no' },
children: [],
textContent: '否 (No)',
mixedContent: [],
parentId: id
}
], ],
textContent: '', textContent: '',
mixedContent: [], mixedContent: [],
...@@ -204,36 +295,39 @@ export const useEditorStore = defineStore('editor', { ...@@ -204,36 +295,39 @@ export const useEditorStore = defineStore('editor', {
state: (): EditorState => ({ state: (): EditorState => ({
xmlTree: null, xmlTree: null,
selectedNodeId: null, selectedNodeId: null,
nodeMap: markRaw(new Map()),
undoStack: [], undoStack: [],
redoStack: [], redoStack: [],
lastUndoRedoTime: 0 lastUndoRedoTime: 0,
editorZoom: 100
}), }),
getters: { getters: {
nodeMap(state): Map<string, { node: XmlNode; parent: XmlNode | null }> { selectedNode(state): XmlNode | null {
if (!state.selectedNodeId) return null
return state.nodeMap.get(state.selectedNodeId)?.node ?? null
},
selectedNodeParent(state): XmlNode | null {
if (!state.selectedNodeId) return null
return state.nodeMap.get(state.selectedNodeId)?.parent ?? null
}
},
actions: {
rebuildNodeMap() {
const map = new Map<string, { node: XmlNode; parent: XmlNode | null }>() const map = new Map<string, { node: XmlNode; parent: XmlNode | null }>()
if (state.xmlTree) { if (this.xmlTree) {
const traverse = (node: XmlNode, parent: XmlNode | null) => { const traverse = (node: XmlNode, parent: XmlNode | null) => {
map.set(node.id, { node, parent }) map.set(node.id, { node, parent })
for (let i = 0; i < node.children.length; i++) { for (let i = 0; i < node.children.length; i++) {
traverse(node.children[i], node) traverse(node.children[i], node)
} }
} }
traverse(state.xmlTree, null) traverse(this.xmlTree, null)
}
return map
},
selectedNode(state): XmlNode | null {
if (!state.selectedNodeId) return null
return this.nodeMap.get(state.selectedNodeId)?.node ?? null
},
selectedNodeParent(state): XmlNode | null {
if (!state.selectedNodeId) return null
return this.nodeMap.get(state.selectedNodeId)?.parent ?? null
} }
this.nodeMap = markRaw(map)
}, },
actions: {
insertNode(tagName: string, insertBelow: boolean) { insertNode(tagName: string, insertBelow: boolean) {
if (!this.xmlTree || !this.selectedNodeId) { if (!this.xmlTree || !this.selectedNodeId) {
window.$message.warning('请先在树中选择一个目标节点!') window.$message.warning('请先在树中选择一个目标节点!')
...@@ -247,7 +341,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -247,7 +341,7 @@ export const useEditorStore = defineStore('editor', {
const setParentRecursive = (n: XmlNode, pid: string) => { const setParentRecursive = (n: XmlNode, pid: string) => {
n.parentId = pid n.parentId = pid
n.children.forEach(c => setParentRecursive(c, n.id)) n.children.forEach((c) => setParentRecursive(c, n.id))
} }
if (insertBelow) { if (insertBelow) {
...@@ -260,7 +354,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -260,7 +354,7 @@ export const useEditorStore = defineStore('editor', {
this.saveSnapshot() this.saveSnapshot()
setParentRecursive(newNode, parent.id) setParentRecursive(newNode, parent.id)
const index = parent.children.findIndex(c => c.id === this.selectedNodeId) const index = parent.children.findIndex((c) => c.id === this.selectedNodeId)
if (index !== -1) { if (index !== -1) {
parent.children.splice(index + 1, 0, newNode) parent.children.splice(index + 1, 0, newNode)
} else { } else {
...@@ -276,6 +370,90 @@ export const useEditorStore = defineStore('editor', { ...@@ -276,6 +370,90 @@ export const useEditorStore = defineStore('editor', {
this.selectedNodeId = newNode.id this.selectedNodeId = newNode.id
window.$message.success(`成功向节点内插入子节点 <${tagName}>`) window.$message.success(`成功向节点内插入子节点 <${tagName}>`)
} }
this.rebuildNodeMap()
},
insertXmlFragment(xmlString: string, mode: 'above' | 'below' | 'inside', targetNodeId?: string) {
const targetId = targetNodeId || this.selectedNodeId
if (!this.xmlTree || !targetId) {
throw new Error('请先选择一个目标节点!')
}
const item = this.nodeMap.get(targetId)
if (!item) {
throw new Error('当前的目标节点无效!')
}
const { node: targetNode, parent: parentNode } = item
// 1. 构建带有临时根节点的 XML 片段以进行完备的解析
const wrappedXml = `<root_fragment>${xmlString}</root_fragment>`
let fragmentTree: XmlNode
try {
fragmentTree = parseXmlToTree(wrappedXml)
} catch (err: any) {
throw new Error(`XML 格式或语法错误: ${err.message}`)
}
const newNodes = fragmentTree.children
if (newNodes.length === 0) {
throw new Error('解析不到任何有效的 XML 节点片段,请检查格式!')
}
// 2. 确定被插入的目标父节点
let destParentNode: XmlNode
if (mode === 'above' || mode === 'below') {
if (!parentNode) {
throw new Error('无法在根节点旁插入兄弟节点')
}
destParentNode = parentNode
} else {
destParentNode = targetNode
}
// 3. 严格校验 DTD 规则约束
const existingTags = destParentNode.children.map(c => c.tagName)
for (const node of newNodes) {
const tagName = node.tagName
const currentCount = existingTags.filter(t => t === tagName).length
if (!canAddChild(destParentNode.tagName, tagName, currentCount)) {
throw new Error(`DTD 校验失败: 节点 <${destParentNode.tagName}> 无法接受子元素 <${tagName}>`)
}
existingTags.push(tagName)
}
// 4. 递归修正插入子节点的 parentId 关系
const setParentRecursive = (n: XmlNode, pid: string) => {
n.parentId = pid
n.children.forEach(c => setParentRecursive(c, n.id))
}
this.saveSnapshot()
newNodes.forEach(node => setParentRecursive(node, destParentNode.id))
// 5. 将解析出来的新节点追加到指定位置,并重置选中焦点
if (mode === 'above') {
const index = destParentNode.children.findIndex(c => c.id === targetId)
if (index !== -1) {
destParentNode.children.splice(index, 0, ...newNodes)
} else {
destParentNode.children.unshift(...newNodes)
}
} else if (mode === 'below') {
const index = destParentNode.children.findIndex(c => c.id === targetId)
if (index !== -1) {
destParentNode.children.splice(index + 1, 0, ...newNodes)
} else {
destParentNode.children.push(...newNodes)
}
} else {
// inside
destParentNode.children.push(...newNodes)
}
this.selectedNodeId = newNodes[newNodes.length - 1].id
this.rebuildNodeMap()
return newNodes.length
}, },
setXmlTree(tree: XmlNode) { setXmlTree(tree: XmlNode) {
...@@ -284,10 +462,20 @@ export const useEditorStore = defineStore('editor', { ...@@ -284,10 +462,20 @@ export const useEditorStore = defineStore('editor', {
this.undoStack = [] this.undoStack = []
this.redoStack = [] this.redoStack = []
this.lastUndoRedoTime = 0 this.lastUndoRedoTime = 0
this.rebuildNodeMap()
}, },
setSelectedNodeId(id: string | null) { setSelectedNodeId(id: string | null) {
const oldId = this.selectedNodeId
this.selectedNodeId = id this.selectedNodeId = id
if (oldId && nodeSelectedRefs.has(oldId)) {
const r = nodeSelectedRefs.get(oldId)
if (r) r.value = false
}
if (id && nodeSelectedRefs.has(id)) {
const r = nodeSelectedRefs.get(id)
if (r) r.value = true
}
}, },
/** /**
...@@ -303,12 +491,16 @@ export const useEditorStore = defineStore('editor', { ...@@ -303,12 +491,16 @@ export const useEditorStore = defineStore('editor', {
saveSnapshot() { saveSnapshot() {
if (!this.xmlTree) return if (!this.xmlTree) return
const clone = JSON.parse(JSON.stringify(this.xmlTree)) const clone = JSON.parse(JSON.stringify(this.xmlTree))
const snapshot = {
tree: clone,
selectedNodeId: this.selectedNodeId
}
// 限制撤销栈大小为 50 // 限制撤销栈大小为 50
if (this.undoStack.length >= 50) { if (this.undoStack.length >= 50) {
this.undoStack.shift() this.undoStack.shift()
} }
this.undoStack.push(clone) this.undoStack.push(snapshot)
// 每次新操作后,清空重做栈 // 每次新操作后,清空重做栈
this.redoStack = [] this.redoStack = []
}, },
...@@ -317,13 +509,20 @@ export const useEditorStore = defineStore('editor', { ...@@ -317,13 +509,20 @@ export const useEditorStore = defineStore('editor', {
if (this.undoStack.length === 0 || !this.xmlTree) return if (this.undoStack.length === 0 || !this.xmlTree) return
const currentClone = JSON.parse(JSON.stringify(this.xmlTree)) const currentClone = JSON.parse(JSON.stringify(this.xmlTree))
this.redoStack.push(currentClone) const currentSnapshot = {
tree: currentClone,
selectedNodeId: this.selectedNodeId
}
this.redoStack.push(currentSnapshot)
const previousTree = this.undoStack.pop()! const previousSnapshot = this.undoStack.pop()!
this.xmlTree = previousTree this.xmlTree = previousSnapshot.tree
if (this.selectedNodeId && !this.nodeMap.has(this.selectedNodeId)) { this.rebuildNodeMap()
this.selectedNodeId = previousTree.id if (previousSnapshot.selectedNodeId && this.nodeMap.has(previousSnapshot.selectedNodeId)) {
this.setSelectedNodeId(previousSnapshot.selectedNodeId)
} else {
this.setSelectedNodeId(previousSnapshot.tree.id)
} }
this.lastUndoRedoTime = Date.now() this.lastUndoRedoTime = Date.now()
}, },
...@@ -335,13 +534,20 @@ export const useEditorStore = defineStore('editor', { ...@@ -335,13 +534,20 @@ export const useEditorStore = defineStore('editor', {
if (this.redoStack.length === 0 || !this.xmlTree) return if (this.redoStack.length === 0 || !this.xmlTree) return
const currentClone = JSON.parse(JSON.stringify(this.xmlTree)) const currentClone = JSON.parse(JSON.stringify(this.xmlTree))
this.undoStack.push(currentClone) const currentSnapshot = {
tree: currentClone,
selectedNodeId: this.selectedNodeId
}
this.undoStack.push(currentSnapshot)
const nextTree = this.redoStack.pop()! const nextSnapshot = this.redoStack.pop()!
this.xmlTree = nextTree this.xmlTree = nextSnapshot.tree
if (this.selectedNodeId && !this.nodeMap.has(this.selectedNodeId)) { this.rebuildNodeMap()
this.selectedNodeId = nextTree.id if (nextSnapshot.selectedNodeId && this.nodeMap.has(nextSnapshot.selectedNodeId)) {
this.setSelectedNodeId(nextSnapshot.selectedNodeId)
} else {
this.setSelectedNodeId(nextSnapshot.tree.id)
} }
this.lastUndoRedoTime = Date.now() this.lastUndoRedoTime = Date.now()
}, },
...@@ -355,6 +561,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -355,6 +561,7 @@ export const useEditorStore = defineStore('editor', {
this.saveSnapshot() this.saveSnapshot()
node.attributes = { ...attributes } node.attributes = { ...attributes }
this.rebuildNodeMap()
}, },
/** /**
...@@ -367,6 +574,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -367,6 +574,7 @@ export const useEditorStore = defineStore('editor', {
this.saveSnapshot() this.saveSnapshot()
node.textContent = text node.textContent = text
node.mixedContent = [] // 若纯文本修改,清空混合内容 node.mixedContent = [] // 若纯文本修改,清空混合内容
this.rebuildNodeMap()
}, },
/** /**
...@@ -380,6 +588,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -380,6 +588,7 @@ export const useEditorStore = defineStore('editor', {
node.mixedContent = mixedContent node.mixedContent = mixedContent
node.children = children node.children = children
node.textContent = '' // 清空纯文本内容 node.textContent = '' // 清空纯文本内容
this.rebuildNodeMap()
}, },
/** /**
...@@ -397,6 +606,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -397,6 +606,7 @@ export const useEditorStore = defineStore('editor', {
} else { } else {
node.children.push(newNode) node.children.push(newNode)
} }
this.rebuildNodeMap()
}, },
/** /**
...@@ -421,6 +631,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -421,6 +631,7 @@ export const useEditorStore = defineStore('editor', {
// 选中父节点 // 选中父节点
this.selectedNodeId = parent.id this.selectedNodeId = parent.id
} }
this.rebuildNodeMap()
}, },
/** /**
...@@ -452,6 +663,207 @@ export const useEditorStore = defineStore('editor', { ...@@ -452,6 +663,207 @@ export const useEditorStore = defineStore('editor', {
parent.mixedContent[mixedIndex1] = parent.mixedContent[mixedIndex2] parent.mixedContent[mixedIndex1] = parent.mixedContent[mixedIndex2]
parent.mixedContent[mixedIndex2] = tempMixed parent.mixedContent[mixedIndex2] = tempMixed
} }
this.rebuildNodeMap()
},
/**
* 在 XML 文档树中查找所有匹配文本
*/
findText(query: string, options: { matchCase: boolean; regExp: boolean }) {
if (!this.xmlTree || !query) return []
const results: {
nodeId: string
tagName: string
type: 'textContent' | 'mixedContent'
mixedIndex?: number
text: string
start: number
length: number
}[] = []
// 构建正则表达式或纯文本搜索匹配逻辑
let regex: RegExp | null = null
if (options.regExp) {
try {
regex = new RegExp(query, options.matchCase ? 'g' : 'gi')
} catch (e) {
// 正则不合法则返回空列表
return []
}
}
const searchString = (text: string, nodeId: string, tagName: string, type: 'textContent' | 'mixedContent', mixedIndex?: number) => {
if (!text) return
if (options.regExp && regex) {
regex.lastIndex = 0
let match
while ((match = regex.exec(text)) !== null) {
// 避免零宽匹配导致死循环
if (match.index === regex.lastIndex) {
regex.lastIndex++
}
results.push({
nodeId,
tagName,
type,
mixedIndex,
text,
start: match.index,
length: match[0].length
})
}
} else {
const searchStr = options.matchCase ? query : query.toLowerCase()
const targetStr = options.matchCase ? text : text.toLowerCase()
let pos = targetStr.indexOf(searchStr)
while (pos !== -1) {
results.push({
nodeId,
tagName,
type,
mixedIndex,
text,
start: pos,
length: query.length
})
pos = targetStr.indexOf(searchStr, pos + query.length)
}
}
}
const walk = (node: XmlNode) => {
// 1. 查找文本内容 (textContent)
if (node.textContent && (!node.mixedContent || node.mixedContent.length === 0)) {
searchString(node.textContent, node.id, node.tagName, 'textContent')
}
// 2. 查找混合内容中的文本片段 (mixedContent)
if (node.mixedContent && node.mixedContent.length > 0) {
node.mixedContent.forEach((item, idx) => {
if (item.type === 'text' && item.text) {
searchString(item.text, node.id, node.tagName, 'mixedContent', idx)
}
})
}
// 3. 递归子节点
if (node.children) {
node.children.forEach(walk)
}
}
walk(this.xmlTree)
return results
},
/**
* 替换指定的单个匹配项
*/
replaceMatch(match: any, replacement: string) {
const node = this.nodeMap.get(match.nodeId)?.node
if (!node) return false
this.saveSnapshot()
if (match.type === 'textContent') {
const text = node.textContent || ''
const prefix = text.substring(0, match.start)
const suffix = text.substring(match.start + match.length)
node.textContent = prefix + replacement + suffix
} else if (match.type === 'mixedContent' && typeof match.mixedIndex === 'number') {
const item = node.mixedContent[match.mixedIndex]
if (item && item.type === 'text') {
const text = item.text || ''
const prefix = text.substring(0, match.start)
const suffix = text.substring(match.start + match.length)
item.text = prefix + replacement + suffix
}
}
return true
},
/**
* 批量替换所有匹配项
*/
replaceAllMatches(query: string, replacement: string, options: { matchCase: boolean; regExp: boolean }) {
if (!this.xmlTree || !query) return 0
// 先查找出所有符合条件的匹配项
const matches = this.findText(query, options)
if (matches.length === 0) return 0
// 保存一次大快照
this.saveSnapshot()
// 按照从后往前的顺序对同一个节点内的文本进行替换,防止位置偏移
const nodeGroups: Record<string, typeof matches> = {}
matches.forEach(m => {
const key = `${m.nodeId}-${m.type}-${m.mixedIndex ?? 'none'}`
if (!nodeGroups[key]) {
nodeGroups[key] = []
}
nodeGroups[key].push(m)
})
let replaceCount = 0
// 针对每个文本单元,自后向前替换
Object.keys(nodeGroups).forEach(key => {
const group = nodeGroups[key]
// 按 start 降序排列
group.sort((a, b) => b.start - a.start)
const firstMatch = group[0]
const node = this.nodeMap.get(firstMatch.nodeId)?.node
if (!node) return
if (firstMatch.type === 'textContent') {
let text = node.textContent || ''
group.forEach(m => {
const prefix = text.substring(0, m.start)
const suffix = text.substring(m.start + m.length)
text = prefix + replacement + suffix
replaceCount++
})
node.textContent = text
} else if (firstMatch.type === 'mixedContent' && typeof firstMatch.mixedIndex === 'number') {
const item = node.mixedContent[firstMatch.mixedIndex]
if (item && item.type === 'text') {
let text = item.text || ''
group.forEach(m => {
const prefix = text.substring(0, m.start)
const suffix = text.substring(m.start + m.length)
text = prefix + replacement + suffix
replaceCount++
})
item.text = text
}
}
})
return replaceCount
},
/**
* 放大编辑器字号
*/
zoomIn() {
this.editorZoom = Math.min(200, this.editorZoom + 10)
},
/**
* 缩小编辑器字号
*/
zoomOut() {
this.editorZoom = Math.max(80, this.editorZoom - 10)
},
/**
* 重置字号大小
*/
resetZoom() {
this.editorZoom = 100
} }
} }
}) })
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
export interface Snapshot {
tree: XmlNode
selectedNodeId: string | null
}
export interface EditorState { export interface EditorState {
xmlTree: XmlNode | null xmlTree: XmlNode | null
selectedNodeId: string | null selectedNodeId: string | null
undoStack: XmlNode[] // 保存 XmlNode 历史快照 nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
redoStack: XmlNode[] // 保存 XmlNode 重做快照 undoStack: Snapshot[] // 保存 Snapshot 历史快照
redoStack: Snapshot[] // 保存 Snapshot 重做快照
lastUndoRedoTime: number // 回退重做动作的时间戳,用于触发视口重定位 lastUndoRedoTime: number // 回退重做动作的时间戳,用于触发视口重定位
editorZoom: number // 编辑器文字字号缩放比例 (80 - 200)
} }
...@@ -61,6 +61,8 @@ declare global { ...@@ -61,6 +61,8 @@ declare global {
$loading: { $loading: {
start: (text?: string) => void start: (text?: string) => void
finish: () => void finish: () => void
show: (text?: string) => void
hide: () => void
} }
$importModal: any $importModal: any
$exportModal: any $exportModal: any
......
import { service } from '@/api/index'
/** /**
* 通用列表数据请求工具 * 通用列表数据请求工具
......
...@@ -62,9 +62,18 @@ export function setupNaiveDiscreteApi() { ...@@ -62,9 +62,18 @@ export function setupNaiveDiscreteApi() {
appStore.loading = true appStore.loading = true
appStore.loadingText = text || '加载中...' appStore.loadingText = text || '加载中...'
}, },
show: (text?: string) => {
const appStore = useAppStore()
appStore.loading = true
appStore.loadingText = text || '加载中...'
},
finish: () => { finish: () => {
const appStore = useAppStore() const appStore = useAppStore()
appStore.loading = false appStore.loading = false
},
hide: () => {
const appStore = useAppStore()
appStore.loading = false
} }
} }
} }
......
import { h } from 'vue'
import { NText } from 'naive-ui' import { NText } from 'naive-ui'
import CommonButton from '@/components/CommonButton.vue' import CommonButton from '@/components/CommonButton.vue'
...@@ -50,24 +50,14 @@ export const renderAttachmentAction = (options: AttachmentRenderOptions) => { ...@@ -50,24 +50,14 @@ export const renderAttachmentAction = (options: AttachmentRenderOptions) => {
} }
export interface UploadActionOptions { export interface UploadActionOptions {
/** 上传接口名 (FunctionCode), 不传则默认为底座定义的兜底接口 */
api?: string
/** 业务分类值 */
category?: string
/** 业务 ID */
sourceId?: string | number
/** 弹窗标题 */ /** 弹窗标题 */
title?: string title?: string
/** 允许选择的文件类型,例如 'image/*' */ /** 允许选择的文件类型,例如 'image/*' */
accept?: string accept?: string
/** 成功后的回调 */ /** 成功后的回调 */
onSuccess?: (res?: any) => void onSuccess?: (res?: any, file?: File | null) => void
/** 分类字段名,默认 'fileCategory' */ /** 自定义上传 Promise 函数 */
categoryKey?: string uploadFunc?: (file: File) => Promise<any>
/** 业务 ID 字段名,默认 'sourceId' */
sourceIdKey?: string
/** 其它额外参数 */
extraData?: Record<string, any>
} }
/** /**
...@@ -75,32 +65,20 @@ export interface UploadActionOptions { ...@@ -75,32 +65,20 @@ export interface UploadActionOptions {
* @description 弹出统一的附件上传模块 * @description 弹出统一的附件上传模块
*/ */
export const openUploadModal = (options: UploadActionOptions) => { export const openUploadModal = (options: UploadActionOptions) => {
const { api, category, sourceId, title, accept, onSuccess, categoryKey = 'fileCategory', sourceIdKey = 'sourceId', extraData = {} } = options window.$uploadModal?.open(options)
const data: Record<string, any> = { ...extraData }
if (category) data[categoryKey] = category
if (sourceId) data[sourceIdKey] = String(sourceId)
window.$uploadModal?.open({
api: api || '/v1/plugins/ATTACHMENT_UPLOAD',
title: title || '上传附件',
accept,
data,
onSuccess
})
} }
export interface DownloadActionOptions { export interface DownloadActionOptions {
/** 下载接口地址,默认为 '/v1/plugins/ATTACHMENT_DOWN' */
api?: string
/** 请求参数 */
params?: any
/** 保存的文件名 */ /** 保存的文件名 */
fileName?: string fileName?: string
/** 弹窗标题 */ /** 弹窗标题 */
title?: string title?: string
/** 回调方法 */ /** 回调方法 */
callback?: (success: boolean) => void callback?: (success: boolean) => void
/** 本地下载兜底的 Blob 数据 */
localBlob?: Blob
/** 自定义下载 Promise 函数 */
downloadFunc?: () => Promise<any>
} }
/** /**
...@@ -126,10 +104,8 @@ export const getBaseOrigin = () => { ...@@ -126,10 +104,8 @@ export const getBaseOrigin = () => {
} }
export interface OpenPDFModalOptions { export interface OpenPDFModalOptions {
/** PDF 路径,支持:纯数字 ID、相对路径、绝对路径、或 Base64 数据 */ /** PDF 路径,支持:相对路径、绝对路径、或 Base64 数据 */
path: string path: string
/** 附件拉取接口,默认使用 ATTACHMENT_GET */
api?: string
/** 是否在打开预览的同时自动拉起打印 */ /** 是否在打开预览的同时自动拉起打印 */
print?: boolean print?: boolean
/** 是否为 Base64 编码的 PDF 数据 */ /** 是否为 Base64 编码的 PDF 数据 */
...@@ -138,13 +114,12 @@ export interface OpenPDFModalOptions { ...@@ -138,13 +114,12 @@ export interface OpenPDFModalOptions {
/** /**
* 打开统一的PDF预览弹窗 * 打开统一的PDF预览弹窗
* @param options.path - PDF 路径(数字 ID / 相对路径 / 绝对 URL / Base64) * @param options.path - PDF 路径(相对路径 / 绝对 URL / Base64)
* @param options.api - 拉取接口,默认 '/api/v1/plugins/ATTACHMENT_GET?down=Y&pkid='
* @param options.print - 是否拉起打印,默认 false * @param options.print - 是否拉起打印,默认 false
* @param options.isBase64 - 是否是 Base64 数据,默认 false * @param options.isBase64 - 是否是 Base64 数据,默认 false
*/ */
export const openPDFModal = (options: OpenPDFModalOptions) => { export const openPDFModal = (options: OpenPDFModalOptions) => {
const { path, api = '/api/v1/plugins/ATTACHMENT_GET?down=Y&pkid=', print = false, isBase64 = false } = options const { path, print = false, isBase64 = false } = options
if (!path) { if (!path) {
window.$message?.error('未提供文件路径') window.$message?.error('未提供文件路径')
...@@ -231,49 +206,8 @@ export const openPDFModal = (options: OpenPDFModalOptions) => { ...@@ -231,49 +206,8 @@ export const openPDFModal = (options: OpenPDFModalOptions) => {
// 确保以 / 开头(如果是相对路径且不以 / 开头) // 确保以 / 开头(如果是相对路径且不以 / 开头)
const normalizedPath = trimmedPath.startsWith('/') ? trimmedPath : '/' + trimmedPath const normalizedPath = trimmedPath.startsWith('/') ? trimmedPath : '/' + trimmedPath
const isPdfFormat = (str: string) => /^\/\d+\.pdf$/.test(str)
let url = normalizedPath
let extension = ''
let fileName = ''
let fullUrl = ''
const origin = getBaseOrigin() const origin = getBaseOrigin()
handleAction(`${origin}${normalizedPath}`)
if (isNaN(parseFloat(url))) {
extension = url.split('.').pop()?.toLowerCase() || ''
fileName = url.split('/').pop() || ''
fullUrl = `${origin}${url}`
if (isPdfFormat(url)) {
const match = url.match(/\d+/)
if (match) {
url = match[0]
}
}
}
// 再次判定是否是数字 ID (如纯数字或者是 /12345.pdf 格式转换后的数字)
// 或者如果显式传入了非默认的 api,说明用户指定了拉取接口,一律走指定的 api 拼接
const isCustomApi = api !== '/api/v1/plugins/ATTACHMENT_GET?down=Y&pkid='
if (!isNaN(parseFloat(url)) || isCustomApi) {
const base = api.startsWith('http://') || api.startsWith('https://') ? '' : origin
const paramValue = api.toLowerCase().includes('pkid') ? url : trimmedPath
const fileUrl = `${base}${api}${paramValue}`
handleAction(fileUrl)
} else {
if (extension === 'pdf') {
handleAction(fullUrl)
} else {
// 下载逻辑
openDownloadModal({
api: fullUrl,
fileName,
title: '文件下载'
})
}
}
} }
/** /**
......
...@@ -78,11 +78,13 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode ...@@ -78,11 +78,13 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
if (hasElementChildren && hasTextChildren) { if (hasElementChildren && hasTextChildren) {
// 混合内容节点(如 PARA, PARAC 中嵌有 REFBLOCK 等行内元素) // 混合内容节点(如 PARA, PARAC 中嵌有 REFBLOCK 等行内元素)
let textContentCollector = ''
for (const child of childNodes) { for (const child of childNodes) {
if (child.nodeType === Node.TEXT_NODE) { if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent || '' const text = child.textContent || ''
if (text) { if (text) {
mixedContent.push({ type: 'text', text }) mixedContent.push({ type: 'text', text })
textContentCollector += text
} }
} else if (child.nodeType === Node.ELEMENT_NODE) { } else if (child.nodeType === Node.ELEMENT_NODE) {
const childNode = domElementToXmlNode(child as Element, id) const childNode = domElementToXmlNode(child as Element, id)
...@@ -90,6 +92,7 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode ...@@ -90,6 +92,7 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
mixedContent.push({ type: 'element', nodeId: childNode.id }) mixedContent.push({ type: 'element', nodeId: childNode.id })
} }
} }
textContent = textContentCollector
} else if (hasElementChildren) { } else if (hasElementChildren) {
// 纯元素子节点 // 纯元素子节点
for (const child of childNodes) { for (const child of childNodes) {
...@@ -116,8 +119,9 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode ...@@ -116,8 +119,9 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
/** /**
* 将 XmlNode 树序列化回 XML 字符串 * 将 XmlNode 树序列化回 XML 字符串
*/ */
export function serializeTreeToXml(node: XmlNode, indent: number = 0): string { export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: boolean = false): string {
const pad = ' '.repeat(indent) const pad = compact ? '' : ' '.repeat(indent)
const newline = compact ? '' : '\n'
const attrs = Object.entries(node.attributes) const attrs = Object.entries(node.attributes)
.map(([k, v]) => `${k}="${escapeXmlAttr(v)}"`) .map(([k, v]) => `${k}="${escapeXmlAttr(v)}"`)
.join(' ') .join(' ')
...@@ -137,7 +141,7 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0): string { ...@@ -137,7 +141,7 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0): string {
} else if (item.type === 'element' && item.nodeId) { } else if (item.type === 'element' && item.nodeId) {
const child = node.children.find(c => c.id === item.nodeId) const child = node.children.find(c => c.id === item.nodeId)
if (child) { if (child) {
content += serializeTreeToXml(child, 0) content += serializeTreeToXml(child, 0, compact)
} }
} }
} }
...@@ -150,8 +154,8 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0): string { ...@@ -150,8 +154,8 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0): string {
} }
// 纯元素子节点 // 纯元素子节点
const childrenXml = node.children.map(c => serializeTreeToXml(c, indent + 1)).join('\n') const childrenXml = node.children.map(c => serializeTreeToXml(c, indent + 1, compact)).join(newline)
return `${pad}<${openTag}>\n${childrenXml}\n${pad}</${node.tagName}>` return `${pad}<${openTag}>${newline}${childrenXml}${newline}${pad}</${node.tagName}>`
} }
/** /**
......
...@@ -7,25 +7,14 @@ ...@@ -7,25 +7,14 @@
<n-form label-placement="left" label-width="120" size="small"> <n-form label-placement="left" label-width="120" size="small">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<n-form-item <n-form-item v-for="(def, name) in attributesDef" :key="name" :label="name.toString()" :required="def.requirement === '#REQUIRED'">
v-for="(def, name) in attributesDef" <CommonSelect
:key="name"
:label="name.toString()"
:required="def.requirement === '#REQUIRED'"
>
<n-select
v-if="def.enumValues && def.enumValues.length > 0" v-if="def.enumValues && def.enumValues.length > 0"
v-model:value="model[name]" v-model:value="model[name]"
:options="def.enumValues.map((v: any) => ({ label: v, value: v }))" :options="def.enumValues.map((v: any) => ({ label: v, value: v }))"
placeholder="请选择" @change="handleAttrChange"
@update:value="handleAttrChange"
/>
<n-input
v-else
v-model:value="model[name]"
placeholder="请输入属性值"
@input="handleAttrChange"
/> />
<n-input v-else v-model:value="model[name]" @input="handleAttrChange" />
</n-form-item> </n-form-item>
</div> </div>
</n-form> </n-form>
...@@ -55,13 +44,17 @@ const hasAttributes = computed(() => { ...@@ -55,13 +44,17 @@ const hasAttributes = computed(() => {
return Object.keys(attributesDef.value).length > 0 return Object.keys(attributesDef.value).length > 0
}) })
watch(() => props.node.id, () => { watch(
() => props.node.id,
() => {
const nextModel: Record<string, string> = {} const nextModel: Record<string, string> = {}
for (const name of Object.keys(attributesDef.value)) { for (const name of Object.keys(attributesDef.value)) {
nextModel[name] = props.node.attributes[name] || '' nextModel[name] = props.node.attributes[name] || ''
} }
model.value = nextModel model.value = nextModel
}, { immediate: true }) },
{ immediate: true }
)
function handleAttrChange() { function handleAttrChange() {
updateAttributes(model.value) updateAttributes(model.value)
......
export const LIST_ITEM_TAGS = [
'L1ITEM',
'L2ITEM',
'L3ITEM',
'L4ITEM',
'L5ITEM',
'L6ITEM',
'L7ITEM',
'UNLITEM',
'NUMLITEM'
]
export const HEADER_TAGS = [
'SMJC-HEADER',
'LMJC-HEADER',
'NRCJC-HEADER',
'TCJC-HEADER',
'QECJC-HEADER',
'EOTK-HEADER',
'DRJC-HEADER'
]
export const ROMAN_LOOKUP: Array<[string, number]> = [
['x', 10],
['ix', 9],
['v', 5],
['iv', 4],
['i', 1]
]
import type { XmlNode } from '@/types/xmlNode'
import { useEditorStore, nodeSelectedRefs } from '@/store/editor'
import { LIST_ITEM_TAGS, HEADER_TAGS, ROMAN_LOOKUP } from '../constants'
import { useI18n } from 'vue-i18n'
export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | null; isInline?: boolean; insideRefBlock?: boolean }) {
const editorStore = useEditorStore()
const { locale } = useI18n()
const isSelected = ref(editorStore.selectedNodeId === props.node.id)
onMounted(() => {
nodeSelectedRefs.set(props.node.id, isSelected)
})
onBeforeUnmount(() => {
nodeSelectedRefs.delete(props.node.id)
})
const parentAlert = inject('insideAlert', false)
const isInsideAlert = computed(() => parentAlert)
if (['WARNING', 'CAUTION', 'NOTE'].includes(props.node.tagName) || parentAlert) {
provide('insideAlert', true)
}
const parentChinese = inject('insideChinese', false)
const isChineseContext = computed(() => {
if (String(locale.value).toLowerCase().includes('zh')) {
return true
}
return parentChinese
})
if (props.node.tagName.endsWith('C') || parentChinese) {
provide('insideChinese', true)
}
// 判断是否是列表容器
const isContainer = computed(() => {
return (
!props.isInline &&
props.node.children &&
props.node.children.length > 0 &&
props.node.tagName !== 'TABLE' &&
props.node.tagName !== 'GRAPHIC' &&
props.node.tagName !== 'SELECTION' &&
props.node.tagName !== 'TOPIC' &&
props.node.tagName !== 'PRETOPIC' &&
props.node.tagName !== 'CBLST'
)
})
// 判断是否是列表项目项
const isListItem = computed(() => {
return LIST_ITEM_TAGS.includes(props.node.tagName)
})
// 判断是否是 Header 标签
const isHeaderTag = computed(() => {
return HEADER_TAGS.includes(props.node.tagName)
})
// 查找 header 的子元素值
const getHeaderValue = (tagName: string): string => {
const child = props.node.children.find((c) => c.tagName === tagName)
return child ? child.textContent || '' : ''
}
// 嵌套警告及中文上下文判定已改为 provide/inject 机制在组件实例化时透传,避免频繁向上遍历 nodeMap。
// 格式化 EFFRG
const formatEff = (eff: string | undefined): string => {
if (!eff) return 'ALL'
const cleaned = eff.replace(/\s+/g, '')
if (cleaned === '001999') return 'ALL'
if (cleaned.length === 6) {
return `${cleaned.substring(0, 3)}-${cleaned.substring(3)}`
}
return cleaned
}
// 罗马数字转换辅助
const romanize = (num: number): string => {
let roman = ''
let val = num
for (const [letter, limit] of ROMAN_LOOKUP) {
while (val >= limit) {
roman += letter
val -= limit
}
}
return roman
}
// 列表标号与数字格式化
const getListBullet = (node: XmlNode): string => {
if (node.tagName === 'UNLITEM') {
const parent = props.parent
const bullType = parent?.attributes?.BULLTYPE
if (bullType === 'BULLET') return '•'
if (bullType === 'NDASH') return '–'
if (bullType === 'MDASH') return '—'
if (bullType === 'DIAMOND') return '♦'
if (bullType === 'ASTERISK') return '*'
if (bullType === 'DELTA') return 'Δ'
if (bullType === 'SQUARE') return '♦'
if (bullType === 'NONE') return ''
return '•'
}
if (node.tagName === 'NUMLITEM') {
const parent = props.parent
if (!parent) return '1.'
const idx = parent.children.filter((c) => c.tagName === 'NUMLITEM').findIndex((c) => c.id === node.id)
return `${idx + 1}.`
}
if (node.tagName === 'L1ITEM') {
const parent = props.parent
if (!parent) return 'A.'
const idx = parent.children.filter((c) => c.tagName === 'L1ITEM').findIndex((c) => c.id === node.id)
return `${String.fromCharCode(65 + idx)}.`
}
if (node.tagName === 'L2ITEM') {
const parent = props.parent
if (!parent) return '(1)'
const idx = parent.children.filter((c) => c.tagName === 'L2ITEM').findIndex((c) => c.id === node.id)
return `(${idx + 1})`
}
if (node.tagName === 'L3ITEM') {
const parent = props.parent
if (!parent) return '(a)'
const idx = parent.children.filter((c) => c.tagName === 'L3ITEM').findIndex((c) => c.id === node.id)
return `(${String.fromCharCode(97 + idx)})`
}
if (node.tagName === 'L4ITEM') {
const parent = props.parent
if (!parent) return '(i)'
const idx = parent.children.filter((c) => c.tagName === 'L4ITEM').findIndex((c) => c.id === node.id)
return `(${romanize(idx + 1)})`
}
if (node.tagName === 'L5ITEM') {
const parent = props.parent
if (!parent) return 'a'
const idx = parent.children.filter((c) => c.tagName === 'L5ITEM').findIndex((c) => c.id === node.id)
return `${String.fromCharCode(97 + idx)}`
}
if (node.tagName === 'L6ITEM') {
const parent = props.parent
if (!parent) return '1.'
const idx = parent.children.filter((c) => c.tagName === 'L6ITEM').findIndex((c) => c.id === node.id)
return `${idx + 1}.`
}
if (node.tagName === 'L7ITEM') {
const parent = props.parent
if (!parent) return 'i'
const idx = parent.children.filter((c) => c.tagName === 'L7ITEM').findIndex((c) => c.id === node.id)
return `${romanize(idx + 1)}`
}
return '•'
}
// 获取 TOPIC / PRETOPIC 序号
const getTopicSeqNum = (node: XmlNode): string => {
const parent = props.parent
if (!parent) return ''
if (parent.tagName === 'CEP' || parent.tagName === 'TASK') {
const topicSiblings = parent.children.filter((c) => c.tagName === 'TOPIC' || c.tagName === 'PRETOPIC')
const idx = topicSiblings.findIndex((c) => c.id === node.id)
if (idx !== -1) {
return `${idx + 1}. `
}
}
return ''
}
// 提取 TOPIC 的 TITLE 节点
const getTopicTitleNodes = (n: XmlNode): XmlNode[] => {
return n.children.filter((c) => c.tagName === 'TITLE' || c.tagName === 'TITLEC')
}
// 提取 TOPIC 的内容节点
const getTopicContentNodes = (n: XmlNode): XmlNode[] => {
return n.children.filter((c) => c.tagName !== 'TITLE' && c.tagName !== 'TITLEC')
}
// 行内混合文本处理
const getChildNode = (childId: string): XmlNode | undefined => {
return props.node.children.find((c) => c.id === childId)
}
// 文本框值修改同步
const handleTextBlur = (e: FocusEvent) => {
const el = e.target as HTMLElement
const val = el.innerText || ''
if (val !== props.node.textContent) {
editorStore.saveSnapshot()
props.node.textContent = val
}
}
// 子节点内容修改同步 (主要是选项组里的 items)
const handleChildTextBlur = (childId: string, e: FocusEvent) => {
const el = e.target as HTMLElement
const val = el.innerText || ''
const child = props.node.children.find((c) => c.id === childId)
if (child && child.textContent !== val) {
editorStore.saveSnapshot()
child.textContent = val
}
}
// 混合文本片段修改同步
const handleMixedTextBlur = (index: number, e: FocusEvent) => {
const el = e.target as HTMLElement
const val = el.innerText || ''
if (props.node.mixedContent[index].text !== val) {
editorStore.saveSnapshot()
props.node.mixedContent[index].text = val
}
}
// 获取附图的标题
const getGraphicTitle = (): string => {
const titleNode = props.node.children.find((c) => c.tagName === 'TITLE')
return titleNode ? titleNode.textContent || '' : ''
}
const handleGraphicTitleBlur = (e: FocusEvent) => {
const el = e.target as HTMLElement
const val = el.innerText || ''
const titleNode = props.node.children.find((c) => c.tagName === 'TITLE')
if (titleNode && titleNode.textContent !== val) {
editorStore.saveSnapshot()
titleNode.textContent = val
}
}
// 工具和设备文本获取
const getTedText = (n: XmlNode): string => {
const name = n.children.find((c) => c.tagName === 'TOOLNAME')?.textContent || ''
const nbr = n.children.find((c) => c.tagName === 'TOOLNBR')?.textContent || ''
return nbr ? `${name} (${nbr})` : name
}
// 消耗品文本获取
const getConText = (n: XmlNode): string => {
const name = n.children.find((c) => c.tagName === 'CONNAME')?.textContent || ''
const nbr = n.children.find((c) => c.tagName === 'CONNBR')?.textContent || ''
return nbr ? `${name} (Material Ref. ${nbr})` : name
}
// 断路器列表辅助
const hasNonCbDataChildren = (subList: XmlNode): boolean => {
return subList.children.some((c) => c.tagName !== 'CBDATA')
}
const getNonCbDataChildren = (subList: XmlNode): XmlNode[] => {
return subList.children.filter((c) => c.tagName !== 'CBDATA')
}
const getCbValue = (cbData: XmlNode, tagName: string): string => {
const child = cbData.children.find((c) => c.tagName === tagName)
return child ? child.textContent || '' : ''
}
return {
editorStore,
isSelected,
isContainer,
isListItem,
isHeaderTag,
isInsideAlert,
isChineseContext,
formatEff,
getListBullet,
getTopicSeqNum,
getTopicTitleNodes,
getTopicContentNodes,
getChildNode,
handleTextBlur,
handleChildTextBlur,
handleMixedTextBlur,
getGraphicTitle,
handleGraphicTitleBlur,
getTedText,
getConText,
hasNonCbDataChildren,
getNonCbDataChildren,
getCbValue,
getHeaderValue
}
}
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
isSelected ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/5' : '', isSelected ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/5' : '',
!isInline && isContainer ? 'py-1 px-1' : '' !isInline && isContainer ? 'py-1 px-1' : ''
]" ]"
:style="node.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
@click.stop="editorStore.setSelectedNodeId(node.id)" @click.stop="editorStore.setSelectedNodeId(node.id)"
> >
<!-- 0. 各类 HEADER 节点特殊处理 (不展示) --> <!-- 0. 各类 HEADER 节点特殊处理 (不展示) -->
...@@ -18,46 +19,53 @@ ...@@ -18,46 +19,53 @@
<div <div
class="relative my-3 text-sm" class="relative my-3 text-sm"
:class="[ :class="[
node.tagName === 'WARNING' ? 'text-red-600 font-bold uppercase' : '', node.tagName === 'WARNING' ? 'font-bold uppercase' : '',
node.tagName === 'CAUTION' ? 'text-[#ff6a00] font-bold' : '', node.tagName === 'CAUTION' ? 'font-bold' : '',
node.tagName === 'NOTE' ? 'text-blue-600' : '' node.tagName === 'NOTE' ? '' : ''
]" ]"
:style="{ :style="{
paddingLeft: node.tagName === 'WARNING' ? '120px' : node.tagName === 'CAUTION' ? '140px' : '80px' paddingLeft: node.tagName === 'WARNING' ? '120px' : node.tagName === 'CAUTION' ? '140px' : '80px',
color: node.tagName === 'WARNING' ? 'red' : node.tagName === 'CAUTION' ? '#ff6a00' : 'blue'
}" }"
> >
<span <span
class="absolute left-0 top-0 font-bold underline select-none" class="absolute left-0 top-0 font-bold underline select-none"
:class="[ :style="{
node.tagName === 'WARNING' ? 'text-red-600' : '', color: node.tagName === 'WARNING' ? 'red' : node.tagName === 'CAUTION' ? '#ff6a00' : 'blue'
node.tagName === 'CAUTION' ? 'text-[#ff6a00]' : '', }"
node.tagName === 'NOTE' ? 'text-blue-600' : ''
]"
> >
{{ node.tagName === 'WARNING' ? '警告 WARNING:' : node.tagName === 'CAUTION' ? '警戒 CAUTION:' : '注意 NOTE:' }} {{ node.tagName === 'WARNING' ? '警告 WARNING:' : node.tagName === 'CAUTION' ? '警戒 CAUTION:' : '注意 NOTE:' }}
</span> </span>
<div class="space-y-1"> <div class="space-y-1">
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" /> <DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" />
</div> </div>
</div> </div>
</template> </template>
<!-- 2. EFFECT / CONEFFECT (适用性) 特殊处理 --> <!-- 2. EFFECT / CONEFFECT (适用性) 特殊处理 -->
<template v-else-if="node.tagName === 'EFFECT' || node.tagName === 'CONEFFECT'"> <template v-else-if="node.tagName === 'EFFECT' || node.tagName === 'CONEFFECT'">
<span v-if="isInline" class="text-danger font-bold text-xs select-none py-1 uppercase mx-1"> <span v-if="isInline" 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) }} ** {{ node.tagName === 'EFFECT' ? 'ON A/C' : 'CONF' }}: {{ formatEff(node.attributes.EFFRG || node.textContent) }}
</span> </span>
<div v-else class="text-danger font-bold text-xs select-none py-1 uppercase my-1 border-t border-b border-dashed border-danger/30 pl-1"> <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) }} ** {{ node.tagName === 'EFFECT' ? 'ON A/C' : 'CONF' }}: {{ formatEff(node.attributes.EFFRG || node.textContent) }}
</div> </div>
</template> </template>
<!-- 3. SBEFF / SBEFFC (服务通告适用性) 处理 --> <!-- 3. SBEFF / SBEFFC (服务通告适用性) 处理 -->
<template v-else-if="node.tagName === 'SBEFF' || node.tagName === 'SBEFFC'"> <template v-else-if="node.tagName === 'SBEFF' || node.tagName === 'SBEFFC'">
<span v-if="isInline" class="text-danger font-bold text-xs select-none py-1 uppercase mx-1"> <span v-if="isInline" 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) }} ** SB: {{ node.attributes.SBCOND }} SB {{ node.attributes.SBNBR }} for A/C {{ formatEff(node.attributes.EFFRG) }}
</span> </span>
<div v-else class="text-danger font-bold text-xs select-none py-1 uppercase my-1 border-t border-b border-dashed border-danger/30 pl-1"> <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) }} ** SB: {{ node.attributes.SBCOND }} SB {{ node.attributes.SBNBR }} for A/C {{ formatEff(node.attributes.EFFRG) }}
</div> </div>
</template> </template>
...@@ -103,6 +111,7 @@ ...@@ -103,6 +111,7 @@
<DocNodeRenderer <DocNodeRenderer
v-else-if="item.type === 'element' && item.nodeId && getChildNode(item.nodeId)" v-else-if="item.type === 'element' && item.nodeId && getChildNode(item.nodeId)"
:node="getChildNode(item.nodeId)!" :node="getChildNode(item.nodeId)!"
:parent="node"
is-inline is-inline
:inside-ref-block="insideRefBlock" :inside-ref-block="insideRefBlock"
/> />
...@@ -114,7 +123,14 @@ ...@@ -114,7 +123,14 @@
class="text-sm leading-relaxed py-1" class="text-sm leading-relaxed py-1"
:class="[isInsideAlert ? 'text-inherit' : 'text-color2']" :class="[isInsideAlert ? 'text-inherit' : 'text-color2']"
> >
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" is-inline :inside-ref-block="insideRefBlock" /> <DocNodeRenderer
v-for="child in node.children"
:key="child.id"
:node="child"
:parent="node"
is-inline
:inside-ref-block="insideRefBlock"
/>
</div> </div>
<div <div
v-else v-else
...@@ -128,9 +144,9 @@ ...@@ -128,9 +144,9 @@
<!-- 7. REFBLOCK 特殊处理 --> <!-- 7. REFBLOCK 特殊处理 -->
<template v-else-if="node.tagName === 'REFBLOCK'"> <template v-else-if="node.tagName === 'REFBLOCK'">
<span class="inline-ref-block font-medium"> <span class="inline-ref-block font-medium" style="color: blue">
<template v-if="node.children && node.children.length > 0"> <template v-if="node.children && node.children.length > 0">
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" is-inline :inside-ref-block="true" /> <DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" is-inline :inside-ref-block="true" />
</template> </template>
<template v-else> <template v-else>
<span <span
...@@ -143,10 +159,11 @@ ...@@ -143,10 +159,11 @@
</span> </span>
</template> </template>
<!-- 8. REFINT / REFEXT / GRPHCREF (引用链接) 特殊处理 --> <!-- 8. REFINT / REFEXT (引用链接) 特殊处理 -->
<template v-else-if="node.tagName === 'REFINT' || node.tagName === 'REFEXT' || node.tagName === 'GRPHCREF'"> <template v-else-if="node.tagName === 'REFINT' || node.tagName === 'REFEXT'">
<span <span
class="inline-ref font-mono font-medium text-primary hover:underline cursor-pointer select-all" class="inline-ref font-mono font-medium hover:underline cursor-pointer select-all"
style="color: blue"
@click.stop="editorStore.setSelectedNodeId(node.id)" @click.stop="editorStore.setSelectedNodeId(node.id)"
> >
<template v-if="!insideRefBlock"> <template v-if="!insideRefBlock">
...@@ -158,63 +175,95 @@ ...@@ -158,63 +175,95 @@
</span> </span>
</template> </template>
<!-- 9. EIN (功能号) 特殊处理 --> <!-- 8.5 GRPHCREF (图形交叉引用) 特殊处理 -->
<template v-else-if="node.tagName === 'EIN'"> <template v-else-if="node.tagName === 'GRPHCREF'">
<span <span
class="font-mono font-bold bg-fill-2 border border-divider px-1.5 py-0.5 rounded text-xs text-color1 mx-0.5 inline-flex items-center cursor-pointer hover:bg-fill-3" class="inline-ref font-mono font-medium hover:underline cursor-pointer select-all"
style="color: blue"
@click.stop="editorStore.setSelectedNodeId(node.id)" @click.stop="editorStore.setSelectedNodeId(node.id)"
> >
<template v-if="!insideRefBlock">
{{ isChineseContext ? '(参考: ' : '(Ref: '
}}{{
(node.mixedContent && node.mixedContent.length > 0
? node.mixedContent
.filter((m) => m.type === 'text')
.map((m) => m.text || '')
.join('')
.trim()
: node.textContent || ''
).trim() ||
node.attributes.REFID ||
node.attributes.STRUCTID ||
'—'
}}
<template v-if="node.attributes.SHEETNBR">[Sh.{{ node.attributes.SHEETNBR }}]</template>
{{ ')' }}
</template>
<template v-else>
{{
(node.mixedContent && node.mixedContent.length > 0
? node.mixedContent
.filter((m) => m.type === 'text')
.map((m) => m.text || '')
.join('')
.trim()
: node.textContent || ''
).trim() ||
node.attributes.REFID ||
node.attributes.STRUCTID ||
'—'
}}
<template v-if="node.attributes.SHEETNBR">[Sh.{{ node.attributes.SHEETNBR }}]</template>
</template>
</span>
</template>
<!-- 9. EIN (功能号) 特殊处理 -->
<template v-else-if="node.tagName === 'EIN'">
<span class="font-mono cursor-pointer underline" style="color: blue" @click.stop="editorStore.setSelectedNodeId(node.id)">
FIN {{ (node.textContent || '').replace(/-/g, '') }} FIN {{ (node.textContent || '').replace(/-/g, '') }}
</span> </span>
</template> </template>
<!-- 10. PAN (盖板/面板) 特殊处理 --> <!-- 10. PAN (盖板/面板) 特殊处理 -->
<template v-else-if="node.tagName === 'PAN'"> <template v-else-if="node.tagName === 'PAN'">
<span <span class="text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)">
class="underline font-bold text-color1 mx-0.5 cursor-pointer hover:text-primary"
@click.stop="editorStore.setSelectedNodeId(node.id)"
>
{{ node.textContent }} {{ node.textContent }}
</span> </span>
</template> </template>
<!-- 11. STDNAME (标准名称) 特殊处理 --> <!-- 11. STDNAME (标准名称) 特殊处理 -->
<template v-else-if="node.tagName === 'STDNAME'"> <template v-else-if="node.tagName === 'STDNAME'">
<span class="font-bold text-color1 mx-0.5 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)"> <span class="text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)">
{{ node.textContent }} {{ node.textContent }}
</span> </span>
</template> </template>
<!-- 12. ZONE (区域) 特殊处理 --> <!-- 12. ZONE (区域) 特殊处理 -->
<template v-else-if="node.tagName === 'ZONE'"> <template v-else-if="node.tagName === 'ZONE'">
<span <span class="font-mono text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)">
class="font-mono font-bold bg-warning/10 text-warning border border-warning/20 px-1 py-0.2 rounded text-xs mx-0.5 inline-flex items-center cursor-pointer hover:bg-warning/20"
@click.stop="editorStore.setSelectedNodeId(node.id)"
>
{{ node.textContent }} {{ node.textContent }}
</span> </span>
</template> </template>
<!-- 13. TED (工具/设备数据) 特殊处理 --> <!-- 13. TED (工具/设备数据) 特殊处理 -->
<template v-else-if="node.tagName === 'TED'"> <template v-else-if="node.tagName === 'TED'">
<span class="italic text-color1 mx-0.5 cursor-pointer hover:text-primary" @click.stop="editorStore.setSelectedNodeId(node.id)"> <span class="text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)">
{{ getTedText(node) }} {{ getTedText(node) }}
</span> </span>
</template> </template>
<!-- 14. CON (消耗品数据) 特殊处理 --> <!-- 14. CON (消耗品数据) 特殊处理 -->
<template v-else-if="node.tagName === 'CON'"> <template v-else-if="node.tagName === 'CON'">
<span class="italic text-color1 mx-0.5 cursor-pointer hover:text-primary" @click.stop="editorStore.setSelectedNodeId(node.id)"> <span class="text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)">
{{ getConText(node) }} {{ getConText(node) }}
</span> </span>
</template> </template>
<!-- 15. CB / CBNAME / CBLOC (断路器信息) 特殊处理 --> <!-- 15. CB / CBNAME / CBLOC (断路器信息) 特殊处理 -->
<template v-else-if="node.tagName === 'CB' || node.tagName === 'CBNAME' || node.tagName === 'CBLOC'"> <template v-else-if="node.tagName === 'CB' || node.tagName === 'CBNAME' || node.tagName === 'CBLOC'">
<span <span class="font-mono text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)">
class="font-mono font-bold text-color1 mx-0.5 px-1 bg-fill-2 rounded border border-divider cursor-pointer hover:bg-fill-3"
@click.stop="editorStore.setSelectedNodeId(node.id)"
>
{{ node.textContent }} {{ node.textContent }}
</span> </span>
</template> </template>
...@@ -243,7 +292,7 @@ ...@@ -243,7 +292,7 @@
<!-- 如果 CBSUBLST 下有除了 CBDATA 以外的子节点,比如说明文本,可以单独渲染一行 --> <!-- 如果 CBSUBLST 下有除了 CBDATA 以外的子节点,比如说明文本,可以单独渲染一行 -->
<tr v-if="hasNonCbDataChildren(subList)" class="bg-fill-1 text-color2"> <tr v-if="hasNonCbDataChildren(subList)" class="bg-fill-1 text-color2">
<td colspan="4" class="border border-divider px-2 py-1 font-semibold"> <td colspan="4" class="border border-divider px-2 py-1 font-semibold">
<DocNodeRenderer v-for="child in getNonCbDataChildren(subList)" :key="child.id" :node="child" /> <DocNodeRenderer v-for="child in getNonCbDataChildren(subList)" :key="child.id" :node="child" :parent="subList" />
</td> </td>
</tr> </tr>
<!-- 遍历 CBDATA 行 --> <!-- 遍历 CBDATA 行 -->
...@@ -255,6 +304,7 @@ ...@@ -255,6 +304,7 @@
v-for="eff in cbData.children.filter((c) => c.tagName === 'EFFECT')" v-for="eff in cbData.children.filter((c) => c.tagName === 'EFFECT')"
:key="eff.id" :key="eff.id"
:node="eff" :node="eff"
:parent="cbData"
is-inline is-inline
/> />
</td> </td>
...@@ -271,7 +321,7 @@ ...@@ -271,7 +321,7 @@
<td class="border border-divider px-2"> <td class="border border-divider px-2">
{{ getCbValue(cbData, 'CBNAME') }} {{ getCbValue(cbData, 'CBNAME') }}
</td> </td>
<td class="border border-divider px-2 text-center font-mono font-bold text-primary"> <td class="border border-divider px-2 text-center font-mono font-bold text-color1">
{{ getCbValue(cbData, 'CB').replace(/-/g, '') }} {{ getCbValue(cbData, 'CB').replace(/-/g, '') }}
</td> </td>
<td class="border border-divider px-2 text-center font-mono"> <td class="border border-divider px-2 text-center font-mono">
...@@ -287,14 +337,29 @@ ...@@ -287,14 +337,29 @@
<!-- 17. 列表项目 L1ITEM / L2ITEM / L3ITEM / L4ITEM / UNLITEM / NUMLITEM 处理 --> <!-- 17. 列表项目 L1ITEM / L2ITEM / L3ITEM / L4ITEM / UNLITEM / NUMLITEM 处理 -->
<template v-else-if="isListItem"> <template v-else-if="isListItem">
<div class="flex items-baseline space-x-2 my-1.5 pl-4"> <div>
<!-- 17.1 前置警告、提醒或适用性等提示节点(不带序号,占满整行宽度,避免序号挤占) -->
<div v-if="node.children && node.children.length > 0 && getSplitListChildren(node.children).alerts.length > 0" class="space-y-1 mb-2">
<DocNodeRenderer v-for="child in getSplitListChildren(node.children).alerts" :key="child.id" :node="child" :parent="node" />
</div>
<!-- 17.2 核心步骤节点(列表序号后移,标记在核心内容上) -->
<div
v-if="!node.children || node.children.length === 0 || getSplitListChildren(node.children).normals.length > 0"
class="flex items-baseline space-x-2 my-1.5 pl-4"
>
<span class="text-sm font-bold select-none shrink-0 w-6 text-right" :class="[isInsideAlert ? 'text-inherit' : 'text-color1']"> <span class="text-sm font-bold select-none shrink-0 w-6 text-right" :class="[isInsideAlert ? 'text-inherit' : 'text-color1']">
{{ getListBullet(node) }} {{ getListBullet(node) }}
</span> </span>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<template v-if="node.children && node.children.length > 0"> <template v-if="node.children && node.children.length > 0">
<div class="space-y-1"> <div class="space-y-1">
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" /> <DocNodeRenderer
v-for="child in getSplitListChildren(node.children).normals"
:key="child.id"
:node="child"
:parent="node"
/>
</div> </div>
</template> </template>
<template v-else> <template v-else>
...@@ -308,6 +373,7 @@ ...@@ -308,6 +373,7 @@
</template> </template>
</div> </div>
</div> </div>
</div>
</template> </template>
<!-- 18. CALS TABLE (表格) 可视化编辑器集成 --> <!-- 18. CALS TABLE (表格) 可视化编辑器集成 -->
...@@ -453,22 +519,29 @@ ...@@ -453,22 +519,29 @@
<div class="my-4"> <div class="my-4">
<!-- 标题部分 --> <!-- 标题部分 -->
<div class="font-bold text-base text-color1 border-b border-divider pb-1.5 mb-2 flex items-baseline"> <div class="font-bold text-base text-color1 border-b border-divider pb-1.5 mb-2 flex items-baseline">
<span class="mr-2 text-primary font-mono select-none">{{ getTopicSeqNum(node) }}</span> <span class="mr-2 text-color1 font-mono select-none">{{ getTopicSeqNum(node) }}</span>
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col">
<DocNodeRenderer <DocNodeRenderer v-for="titleNode in getTopicTitleNodes(node)" :key="titleNode.id" :node="titleNode" :parent="node" />
v-for="titleNode in getTopicTitleNodes(node)"
:key="titleNode.id"
:node="titleNode"
/>
</div> </div>
</div> </div>
<!-- 子节点部分 (排除 TITLE TITLEC) --> <!-- 子节点部分 (排除 TITLE TITLEC) -->
<div class="space-y-1 pl-4 border-l border-dashed border-divider/60"> <!-- 如果是顶级平铺 Block ( props.parent null),内容子节点已由虚拟滚动列表里的平级块单独负责,这里不重复渲染。但 PRETOPIC 仍需递归渲染其内容。 -->
<DocNodeRenderer v-for="child in getTopicContentNodes(node)" :key="child.id" :node="child" /> <div
v-if="node.tagName === 'PRETOPIC' || (parent !== null && parent !== undefined)"
class="space-y-1 pl-4 border-l border-dashed border-divider/60"
>
<DocNodeRenderer v-for="child in getTopicContentNodes(node)" :key="child.id" :node="child" :parent="node" />
</div> </div>
</div> </div>
</template> </template>
<!-- 24.5 SUBTASK 特殊处理 (加上左侧虚线框和段落缩进) -->
<template v-else-if="node.tagName === 'SUBTASK'">
<div class="space-y-1 pl-4 border-l border-dashed border-divider/60 my-2">
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" />
</div>
</template>
<!-- 25. ASSODATA 隐藏处理 (PDF 隐藏) --> <!-- 25. ASSODATA 隐藏处理 (PDF 隐藏) -->
<template v-else-if="node.tagName === 'ASSODATA'"> <template v-else-if="node.tagName === 'ASSODATA'">
<div <div
...@@ -482,7 +555,7 @@ ...@@ -482,7 +555,7 @@
<!-- 26. 其它所有容器节点(如 JOBCARD, CEP, TFMATR, SUBTASK, LIST1... --> <!-- 26. 其它所有容器节点(如 JOBCARD, CEP, TFMATR, SUBTASK, LIST1... -->
<template v-else-if="isContainer"> <template v-else-if="isContainer">
<div class="space-y-1"> <div class="space-y-1">
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" /> <DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" />
</div> </div>
</template> </template>
...@@ -490,13 +563,11 @@ ...@@ -490,13 +563,11 @@
<template v-else-if="node.tagName === 'SUPER' || node.tagName === 'SUPERSCRIPT' || node.tagName === 'SUB' || node.tagName === 'SUBSCRIPT'"> <template v-else-if="node.tagName === 'SUPER' || node.tagName === 'SUPERSCRIPT' || node.tagName === 'SUB' || node.tagName === 'SUBSCRIPT'">
<span <span
class="inline select-all text-[0.75em]" class="inline select-all text-[0.75em]"
:class="[ :class="[node.tagName === 'SUPER' || node.tagName === 'SUPERSCRIPT' ? 'align-super' : 'align-sub']"
(node.tagName === 'SUPER' || node.tagName === 'SUPERSCRIPT') ? 'align-super' : 'align-sub' style="text-indent: 0 !important"
]"
style="text-indent: 0 !important;"
> >
<template v-if="node.children && node.children.length > 0"> <template v-if="node.children && node.children.length > 0">
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" is-inline /> <DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" is-inline />
</template> </template>
<template v-else> <template v-else>
<span <span
...@@ -533,279 +604,64 @@ ...@@ -533,279 +604,64 @@
import DocNodeRenderer from './index.vue' import DocNodeRenderer from './index.vue'
import { ImageOutline, GridOutline } from '@vicons/ionicons5' import { ImageOutline, GridOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { useEditorStore } from '@/store/editor'
import TableEditor from '../TableEditor/index.vue' import TableEditor from '../TableEditor/index.vue'
import { useDocNodeRenderer } from './functionals'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
node: XmlNode node: XmlNode
parent?: XmlNode | null
isInline?: boolean isInline?: boolean
insideRefBlock?: boolean insideRefBlock?: boolean
}>(), }>(),
{ {
parent: null,
isInline: false, isInline: false,
insideRefBlock: false insideRefBlock: false
} }
) )
const editorStore = useEditorStore() const {
editorStore,
const isSelected = computed(() => editorStore.selectedNodeId === props.node.id) isSelected,
isContainer,
// 判断是否是列表容器 isListItem,
const isContainer = computed(() => { isHeaderTag,
return ( isInsideAlert,
!props.isInline && isChineseContext,
props.node.children && formatEff,
props.node.children.length > 0 && getListBullet,
props.node.tagName !== 'TABLE' && getTopicSeqNum,
props.node.tagName !== 'GRAPHIC' && getTopicTitleNodes,
props.node.tagName !== 'SELECTION' && getTopicContentNodes,
props.node.tagName !== 'TOPIC' && getChildNode,
props.node.tagName !== 'PRETOPIC' && handleTextBlur,
props.node.tagName !== 'CBLST' handleChildTextBlur,
) handleMixedTextBlur,
}) getGraphicTitle,
handleGraphicTitleBlur,
// 判断是否是列表项目项 getTedText,
const isListItem = computed(() => { getConText,
return ['L1ITEM', 'L2ITEM', 'L3ITEM', 'L4ITEM', 'L5ITEM', 'L6ITEM', 'L7ITEM', 'UNLITEM', 'NUMLITEM'].includes(props.node.tagName) hasNonCbDataChildren,
}) getNonCbDataChildren,
getCbValue
// 判断是否是 Header 标签 } = useDocNodeRenderer(props)
const isHeaderTag = computed(() => {
return ['SMJC-HEADER', 'LMJC-HEADER', 'NRCJC-HEADER', 'TCJC-HEADER', 'QECJC-HEADER', 'EOTK-HEADER', 'DRJC-HEADER'].includes(props.node.tagName) const getSplitListChildren = (children?: XmlNode[]) => {
}) if (!children) return { alerts: [], normals: [] }
const alertTags = new Set(['WARNING', 'CAUTION', 'NOTE', 'EFFECT', 'CONEFFECT', 'SBEFF', 'SBEFFC'])
// 查找 header 的子元素值 const alerts: XmlNode[] = []
const getHeaderValue = (tagName: string): string => { const normals: XmlNode[] = []
const child = props.node.children.find((c) => c.tagName === tagName) let foundNormal = false
return child ? child.textContent || '' : '' for (const child of children) {
} if (!foundNormal && alertTags.has(child.tagName)) {
alerts.push(child)
// 判断当前节点是否嵌套在 WARNING / CAUTION / NOTE 中 } else {
const isInsideAlert = computed(() => { foundNormal = true
let parent = editorStore.nodeMap.get(props.node.id)?.parent normals.push(child)
while (parent) {
if (['WARNING', 'CAUTION', 'NOTE'].includes(parent.tagName)) {
return true
} }
parent = editorStore.nodeMap.get(parent.id)?.parent
} }
return false return { alerts, normals }
})
// 判断是否在中文标签上下文中
const isChineseContext = computed(() => {
let parent = editorStore.nodeMap.get(props.node.id)?.parent
while (parent) {
if (parent.tagName.endsWith('C')) {
return true
}
parent = editorStore.nodeMap.get(parent.id)?.parent
}
return false
})
// 格式化 EFFRG
const formatEff = (eff: string | undefined): string => {
if (!eff) return 'ALL'
const cleaned = eff.replace(/\s+/g, '')
if (cleaned === '001999') return 'ALL'
if (cleaned.length === 6) {
return `${cleaned.substring(0, 3)}-${cleaned.substring(3)}`
}
return cleaned
}
// 罗马数字转换辅助
const romanize = (num: number): string => {
const lookup: Array<[string, number]> = [
['x', 10],
['ix', 9],
['v', 5],
['iv', 4],
['i', 1]
]
let roman = ''
let val = num
for (const [letter, limit] of lookup) {
while (val >= limit) {
roman += letter
val -= limit
}
}
return roman
}
// 列表标号与数字格式化
const getListBullet = (node: XmlNode): string => {
if (node.tagName === 'UNLITEM') {
const parent = editorStore.nodeMap.get(node.id)?.parent
const bullType = parent?.attributes?.BULLTYPE
if (bullType === 'BULLET') return '•'
if (bullType === 'NDASH') return '–'
if (bullType === 'MDASH') return '—'
if (bullType === 'DIAMOND') return '♦'
if (bullType === 'ASTERISK') return '*'
if (bullType === 'DELTA') return 'Δ'
if (bullType === 'SQUARE') return '♦'
if (bullType === 'NONE') return ''
return '•'
}
if (node.tagName === 'NUMLITEM') {
const parent = editorStore.nodeMap.get(node.id)?.parent
if (!parent) return '1.'
const idx = parent.children.filter((c) => c.tagName === 'NUMLITEM').findIndex((c) => c.id === node.id)
return `${idx + 1}.`
}
if (node.tagName === 'L1ITEM') {
const parent = editorStore.nodeMap.get(node.id)?.parent
if (!parent) return 'A.'
const idx = parent.children.filter((c) => c.tagName === 'L1ITEM').findIndex((c) => c.id === node.id)
return `${String.fromCharCode(65 + idx)}.`
}
if (node.tagName === 'L2ITEM') {
const parent = editorStore.nodeMap.get(node.id)?.parent
if (!parent) return '(1)'
const idx = parent.children.filter((c) => c.tagName === 'L2ITEM').findIndex((c) => c.id === node.id)
return `(${idx + 1})`
}
if (node.tagName === 'L3ITEM') {
const parent = editorStore.nodeMap.get(node.id)?.parent
if (!parent) return '(a)'
const idx = parent.children.filter((c) => c.tagName === 'L3ITEM').findIndex((c) => c.id === node.id)
return `(${String.fromCharCode(97 + idx)})`
}
if (node.tagName === 'L4ITEM') {
const parent = editorStore.nodeMap.get(node.id)?.parent
if (!parent) return '(i)'
const idx = parent.children.filter((c) => c.tagName === 'L4ITEM').findIndex((c) => c.id === node.id)
return `(${romanize(idx + 1)})`
}
if (node.tagName === 'L5ITEM') {
const parent = editorStore.nodeMap.get(node.id)?.parent
if (!parent) return 'a'
const idx = parent.children.filter((c) => c.tagName === 'L5ITEM').findIndex((c) => c.id === node.id)
return `${String.fromCharCode(97 + idx)}`
}
if (node.tagName === 'L6ITEM') {
const parent = editorStore.nodeMap.get(node.id)?.parent
if (!parent) return '1.'
const idx = parent.children.filter((c) => c.tagName === 'L6ITEM').findIndex((c) => c.id === node.id)
return `${idx + 1}.`
}
if (node.tagName === 'L7ITEM') {
const parent = editorStore.nodeMap.get(node.id)?.parent
if (!parent) return 'i'
const idx = parent.children.filter((c) => c.tagName === 'L7ITEM').findIndex((c) => c.id === node.id)
return `${romanize(idx + 1)}`
}
return '•'
}
// 获取 TOPIC / PRETOPIC 序号
const getTopicSeqNum = (node: XmlNode): string => {
const parent = editorStore.nodeMap.get(node.id)?.parent
if (!parent) return ''
if (parent.tagName === 'CEP' || parent.tagName === 'TASK') {
const topicSiblings = parent.children.filter((c) => c.tagName === 'TOPIC' || c.tagName === 'PRETOPIC')
const idx = topicSiblings.findIndex((c) => c.id === node.id)
if (idx !== -1) {
return `${idx + 1}. `
}
}
return ''
}
// 提取 TOPIC 的 TITLE 节点
const getTopicTitleNodes = (n: XmlNode): XmlNode[] => {
return n.children.filter((c) => c.tagName === 'TITLE' || c.tagName === 'TITLEC')
}
// 提取 TOPIC 的内容节点
const getTopicContentNodes = (n: XmlNode): XmlNode[] => {
return n.children.filter((c) => c.tagName !== 'TITLE' && c.tagName !== 'TITLEC')
}
// 行内混合文本处理
const getChildNode = (childId: string): XmlNode | undefined => {
return props.node.children.find((c) => c.id === childId)
}
// 文本框值修改同步
const handleTextBlur = (e: FocusEvent) => {
const el = e.target as HTMLElement
const val = el.innerText || ''
if (val !== props.node.textContent) {
editorStore.saveSnapshot()
props.node.textContent = val
}
}
// 子节点内容修改同步 (主要是选项组里的 items)
const handleChildTextBlur = (childId: string, e: FocusEvent) => {
const el = e.target as HTMLElement
const val = el.innerText || ''
const child = props.node.children.find((c) => c.id === childId)
if (child && child.textContent !== val) {
editorStore.saveSnapshot()
child.textContent = val
}
}
// 混合文本片段修改同步
const handleMixedTextBlur = (index: number, e: FocusEvent) => {
const el = e.target as HTMLElement
const val = el.innerText || ''
if (props.node.mixedContent[index].text !== val) {
editorStore.saveSnapshot()
props.node.mixedContent[index].text = val
}
}
// 获取附图的标题
const getGraphicTitle = (): string => {
const titleNode = props.node.children.find((c) => c.tagName === 'TITLE')
return titleNode ? titleNode.textContent || '' : ''
}
const handleGraphicTitleBlur = (e: FocusEvent) => {
const el = e.target as HTMLElement
const val = el.innerText || ''
let titleNode = props.node.children.find((c) => c.tagName === 'TITLE')
if (titleNode && titleNode.textContent !== val) {
editorStore.saveSnapshot()
titleNode.textContent = val
}
}
// 工具和设备文本获取
const getTedText = (n: XmlNode): string => {
const name = n.children.find((c) => c.tagName === 'TOOLNAME')?.textContent || ''
const nbr = n.children.find((c) => c.tagName === 'TOOLNBR')?.textContent || ''
return nbr ? `${name} (${nbr})` : name
}
// 消耗品文本获取
const getConText = (n: XmlNode): string => {
const name = n.children.find((c) => c.tagName === 'CONNAME')?.textContent || ''
const nbr = n.children.find((c) => c.tagName === 'CONNBR')?.textContent || ''
return nbr ? `${name} (Material Ref. ${nbr})` : name
}
// 断路器列表辅助
const hasNonCbDataChildren = (subList: XmlNode): boolean => {
return subList.children.some((c) => c.tagName !== 'CBDATA')
}
const getNonCbDataChildren = (subList: XmlNode): XmlNode[] => {
return subList.children.filter((c) => c.tagName !== 'CBDATA')
}
const getCbValue = (cbData: XmlNode, tagName: string): string => {
const child = cbData.children.find((c) => c.tagName === tagName)
return child ? child.textContent || '' : ''
} }
</script> </script>
......
import { useEditorStore } from '@/store/editor'
import { useMessage } from 'naive-ui'
export function useFindReplace(
props: { visible: boolean; syncEditorScroll: (nodeId: string, force: boolean) => void },
emit: (event: 'update:visible', value: boolean) => void
) {
const editorStore = useEditorStore()
const message = useMessage()
const isExpanded = ref(false)
const findQuery = ref('')
const replaceQuery = ref('')
const matchCase = ref(false)
const regExp = ref(false)
const matches = ref<any[]>([])
const currentMatchIndex = ref(-1)
const findInputRef = ref<any>(null)
const matchesListRef = ref<HTMLElement | null>(null)
const matchItemRefs = ref<any[]>([])
// 每次匹配列表变化时重置 ref 数组
watch(() => matches.value, () => {
matchItemRefs.value = []
})
const scrollToActiveMatch = () => {
nextTick(() => {
const activeEl = matchItemRefs.value[currentMatchIndex.value]
const containerEl = matchesListRef.value
if (activeEl && containerEl) {
const activeRect = activeEl.getBoundingClientRect()
const containerRect = containerEl.getBoundingClientRect()
// 若高亮项已超出可视区域的上边缘或下边缘,则将其滚动入屏
if (activeRect.top < containerRect.top || activeRect.bottom > containerRect.bottom) {
activeEl.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}
})
}
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
}
// 查找输入更新时,刷新搜索结果
const onFindInput = () => {
doSearch()
}
const toggleMatchCase = () => {
matchCase.value = !matchCase.value
doSearch()
}
const toggleRegExp = () => {
regExp.value = !regExp.value
doSearch()
}
const doSearch = () => {
if (!findQuery.value) {
matches.value = []
currentMatchIndex.value = -1
return
}
const results = editorStore.findText(findQuery.value, {
matchCase: matchCase.value,
regExp: regExp.value
})
matches.value = results
if (results.length > 0) {
// 如果旧的索引有效,保持接近 the 索引,否则从 0 开始
if (currentMatchIndex.value < 0 || currentMatchIndex.value >= results.length) {
currentMatchIndex.value = 0
}
// 自动聚焦到首个匹配点
focusMatch(currentMatchIndex.value)
} else {
currentMatchIndex.value = -1
}
}
// 聚焦到指定索引的匹配项,更新编辑器视图
const focusMatch = (index: number) => {
if (index < 0 || index >= matches.value.length) return
currentMatchIndex.value = index
const match = matches.value[index]
// 选中该节点并调用父级导出的平滑滚动方法
editorStore.setSelectedNodeId(match.nodeId)
props.syncEditorScroll(match.nodeId, true)
// 自动将列表中的高亮项滚动入屏
scrollToActiveMatch()
}
const selectMatch = (idx: number) => {
focusMatch(idx)
}
const findNext = () => {
if (matches.value.length === 0) return
const nextIdx = (currentMatchIndex.value + 1) % matches.value.length
focusMatch(nextIdx)
}
const findPrev = () => {
if (matches.value.length === 0) return
const prevIdx = (currentMatchIndex.value - 1 + matches.value.length) % matches.value.length
focusMatch(prevIdx)
}
// 替换当前匹配项
const replaceCurrent = () => {
if (matches.value.length === 0 || currentMatchIndex.value === -1) return
const match = matches.value[currentMatchIndex.value]
const success = editorStore.replaceMatch(match, replaceQuery.value)
if (success) {
message.success('替换成功')
// 重新查找以保持精准的位置和剩余结果统计
const prevIndex = currentMatchIndex.value
doSearch()
// 如果仍然有匹配,定位到下一个或上一个
if (matches.value.length > 0) {
const nextIdx = Math.min(prevIndex, matches.value.length - 1)
focusMatch(nextIdx)
}
} else {
message.error('替换失败')
}
}
// 批量替换所有匹配项
const replaceAll = () => {
if (matches.value.length === 0) return
const count = editorStore.replaceAllMatches(findQuery.value, replaceQuery.value, {
matchCase: matchCase.value,
regExp: regExp.value
})
if (count > 0) {
message.success(`成功替换了 ${count} 处匹配`)
matches.value = []
currentMatchIndex.value = -1
findQuery.value = ''
} else {
message.info('未找到可替换的匹配项')
}
}
// 生成轻量的上下文文本以便预览,根据是否展开动态调整上下文展示长度
const getMatchContext = (match: any): string => {
const text = match.text
const start = match.start
const len = match.length
const padding = isExpanded.value ? 35 : 15
const startOffset = Math.max(0, start - padding)
const endOffset = Math.min(text.length, start + len + padding)
let context = text.substring(startOffset, endOffset)
if (startOffset > 0) context = '...' + context
if (endOffset < text.length) context = context + '...'
return context
}
const closePanel = () => {
emit('update:visible', false)
}
// 自动对焦查找框
watch(
() => props.visible,
(val) => {
if (val) {
nextTick(() => {
findInputRef.value?.focus()
doSearch()
})
} else {
matches.value = []
currentMatchIndex.value = -1
}
}
)
return {
isExpanded,
findQuery,
replaceQuery,
matchCase,
regExp,
matches,
currentMatchIndex,
findInputRef,
matchesListRef,
matchItemRefs,
toggleExpand,
onFindInput,
toggleMatchCase,
toggleRegExp,
findNext,
findPrev,
selectMatch,
replaceCurrent,
replaceAll,
getMatchContext,
closePanel
}
}
<template>
<transition name="slide-fade">
<div
v-if="visible"
class="find-replace-panel absolute top-4 right-4 z-50 bg-card/90 backdrop-blur-md border border-divider shadow-2xl rounded-xl p-4 flex flex-col space-y-3 select-none transition-all duration-300 ease-in-out"
:style="{ width: isExpanded ? '560px' : '320px' }"
:class="{ 'is-expanded': isExpanded }"
>
<!-- 头部标题与关闭按钮 -->
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-color1 tracking-wide flex items-center space-x-1">
<n-icon class="text-primary text-sm">
<search-outline />
</n-icon>
<span>查找与替换</span>
</span>
<div class="flex items-center space-x-1">
<!-- 放大展开/收起按钮 (通过 CodeOutline 表示 <>) -->
<CommonButton size="tiny" quaternary circle @click="toggleExpand" :title="isExpanded ? '折叠面板' : '放大面板'">
<template #icon>
<n-icon><code-outline /></n-icon>
</template>
</CommonButton>
<!-- 关闭按钮 -->
<CommonButton size="tiny" quaternary circle @click="closePanel">
<template #icon>
<n-icon><close-outline /></n-icon>
</template>
</CommonButton>
</div>
</div>
<!-- 查找行 -->
<div class="flex flex-col space-y-1">
<div class="flex items-center space-x-1">
<n-input
ref="findInputRef"
v-model:value="findQuery"
placeholder="查找内容..."
size="small"
class="flex-1"
@input="onFindInput"
@keydown.enter="findNext"
>
<template #suffix>
<!-- 选项开关 -->
<div class="flex items-center space-x-1 text-color3">
<span
class="option-btn text-[10px] px-1 py-0.5 rounded cursor-pointer transition-colors"
:class="{ 'active bg-primary/20 text-primary font-bold': matchCase }"
title="区分大小写"
@click="toggleMatchCase"
>
Aa
</span>
<span
class="option-btn text-[10px] px-1 py-0.5 rounded cursor-pointer transition-colors"
:class="{ 'active bg-primary/20 text-primary font-bold': regExp }"
title="正则表达式"
@click="toggleRegExp"
>
.*
</span>
</div>
</template>
</n-input>
</div>
</div>
<!-- 替换行 -->
<div class="flex flex-col space-y-1">
<n-input
v-model:value="replaceQuery"
placeholder="替换为..."
size="small"
@keydown.enter="replaceCurrent"
/>
</div>
<!-- 匹配结果及控制区 -->
<div class="flex items-center justify-between text-[11px] text-color2">
<div class="flex items-center space-x-1">
<span v-if="matches.length > 0" class="font-mono text-primary font-bold">
{{ currentMatchIndex + 1 }} / {{ matches.length }}
</span>
<span v-else class="text-color3">无匹配结果</span>
</div>
<div class="flex items-center space-x-1">
<CommonButton size="tiny" quaternary :disabled="matches.length === 0" @click="findPrev">
<template #icon>
<n-icon><chevron-up-outline /></n-icon>
</template>
</CommonButton>
<CommonButton size="tiny" quaternary :disabled="matches.length === 0" @click="findNext">
<template #icon>
<n-icon><chevron-down-outline /></n-icon>
</template>
</CommonButton>
</div>
</div>
<!-- 操作按钮组 -->
<div class="grid grid-cols-2 gap-2 pt-1">
<CommonButton
size="small"
secondary
:disabled="matches.length === 0"
@click="replaceCurrent"
class="justify-center"
>
替换当前
</CommonButton>
<CommonButton
size="small"
type="primary"
:disabled="matches.length === 0"
@click="replaceAll"
class="justify-center font-semibold"
>
全部替换
</CommonButton>
</div>
<!-- 匹配文本摘要预览(轻量化列表展示,极大提升体验) -->
<div
v-if="matches.length > 0"
ref="matchesListRef"
class="overflow-y-auto border border-divider/50 rounded-lg p-1 bg-fill-2 text-[10px] space-y-1 scrollbar-thin transition-all duration-300 ease-in-out"
:style="{ maxHeight: isExpanded ? '320px' : '96px' }"
>
<div
v-for="(match, idx) in matches"
:key="idx"
:ref="el => { if (el) matchItemRefs[idx] = el }"
class="match-item p-1 rounded cursor-pointer transition-all truncate hover:bg-primary/10"
:class="{ 'bg-primary/20 active-match border-l-2 border-primary': idx === currentMatchIndex }"
@click="selectMatch(idx)"
>
<span class="bg-primary/10 px-1 rounded mr-1 font-bold" :class="idx === currentMatchIndex ? 'text-primary' : 'text-color3'">{{ match.tagName }}</span>
<span>{{ getMatchContext(match) }}</span>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { SearchOutline, CloseOutline, ChevronUpOutline, ChevronDownOutline, CodeOutline } from '@vicons/ionicons5'
import { useFindReplace } from './functionals'
const props = defineProps<{
visible: boolean
syncEditorScroll: (nodeId: string, force: boolean) => void
}>()
const emit = defineEmits(['update:visible'])
const {
isExpanded,
findQuery,
replaceQuery,
matchCase,
regExp,
matches,
currentMatchIndex,
findInputRef,
matchesListRef,
matchItemRefs,
toggleExpand,
onFindInput,
toggleMatchCase,
toggleRegExp,
findNext,
findPrev,
selectMatch,
replaceCurrent,
replaceAll,
getMatchContext,
closePanel
} = useFindReplace(props, emit)
</script>
<style scoped>
.find-replace-panel {
border-color: var(--n-border-color);
}
.option-btn:hover {
background-color: rgba(24, 160, 88, 0.1);
}
.match-item {
transition: all 0.15s ease;
color: var(--n-text-color);
}
.match-item.active-match {
color: var(--n-primary-color) !important;
}
.scrollbar-thin::-webkit-scrollbar {
width: 3px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 1.5px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: var(--n-primary-color);
}
/* 进出过渡动画 */
.slide-fade-enter-active {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide-fade-leave-active {
transition: all 0.2s cubic-bezier(0.7, 0, 0.84, 0);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(-8px) scale(0.98);
opacity: 0;
}
/* 当面板放大时,微调内部的字号、高度等样式,达到等比例放大弹框尺寸和内容的效果 */
.find-replace-panel.is-expanded {
font-size: 13px !important;
}
.find-replace-panel.is-expanded :deep(.n-input) {
--n-font-size: 13px !important;
--n-height: 34px !important;
}
.find-replace-panel.is-expanded :deep(.n-button) {
--n-font-size: 13px !important;
--n-height: 32px !important;
}
.find-replace-panel.is-expanded .match-item {
font-size: 12px !important;
padding: 6px 8px !important;
}
.find-replace-panel.is-expanded .option-btn {
font-size: 11px !important;
padding: 2px 5px !important;
}
</style>
...@@ -6,8 +6,8 @@ export const ALL_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3', 'UNLIST'] ...@@ -6,8 +6,8 @@ export const ALL_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3', 'UNLIST']
// 表格节点标签定义 // 表格节点标签定义
export const CALS_TABLE_TAGS = ['TABLE', 'TGROUP'] export const CALS_TABLE_TAGS = ['TABLE', 'TGROUP']
// 容器节点:直接穿透,不独立成块,而是遍历其子节点 // 容器节点:直接穿透,不独立成块,而是遍历其子节点,以实现细粒度原子级虚拟滚动分块
export const TRANSPARENT_TAGS = new Set(['JOBCARD', 'CEP', 'TASK']) export const TRANSPARENT_TAGS = new Set(['JOBCARD', 'CEP', 'TASK', 'TFMATR'])
/** /**
* 虚拟滚动:各类节点的默认估计高度(px) * 虚拟滚动:各类节点的默认估计高度(px)
......
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { import { TRANSPARENT_TAGS, getEstimatedHeight, type EditorBlock, type BlockPosition } from '../constants'
TRANSPARENT_TAGS,
ESTIMATED_HEIGHT,
getEstimatedHeight,
type EditorBlock,
type BlockPosition
} from '../constants'
/** /**
* EditorPanel 组件核心逻辑 Hook * EditorPanel 组件核心逻辑 Hook
...@@ -47,6 +40,11 @@ export function useEditorPanel() { ...@@ -47,6 +40,11 @@ export function useEditorPanel() {
const walk = (n: XmlNode) => { const walk = (n: XmlNode) => {
if (TRANSPARENT_TAGS.has(n.tagName)) { if (TRANSPARENT_TAGS.has(n.tagName)) {
n.children.forEach(walk) n.children.forEach(walk)
} else if (n.tagName === 'TOPIC') {
// 特殊拆分:TOPIC 节点自身仅作为标题 Block,其内容子节点(SUBTASK 等)则作为平级 Block 扁平化,以实现细粒度虚拟化
blocks.push({ id: n.id, tagName: 'TOPIC', rawNode: n })
const contentNodes = n.children.filter((c) => c.tagName !== 'TITLE' && c.tagName !== 'TITLEC')
contentNodes.forEach(walk)
} else { } else {
blocks.push({ id: n.id, tagName: n.tagName, rawNode: n }) blocks.push({ id: n.id, tagName: n.tagName, rawNode: n })
} }
...@@ -66,9 +64,12 @@ export function useEditorPanel() { ...@@ -66,9 +64,12 @@ export function useEditorPanel() {
const heightsMap = ref<Record<string, number>>({}) const heightsMap = ref<Record<string, number>>({})
// 切换 XML 文件时,重置高度缓存 // 切换 XML 文件时,重置高度缓存
watch(() => editorStore.xmlTree?.id, () => { watch(
() => editorStore.xmlTree?.id,
() => {
heightsMap.value = {} heightsMap.value = {}
}) }
)
const positions = computed<BlockPosition[]>(() => { const positions = computed<BlockPosition[]>(() => {
const list: BlockPosition[] = [] const list: BlockPosition[] = []
...@@ -84,7 +85,7 @@ export function useEditorPanel() { ...@@ -84,7 +85,7 @@ export function useEditorPanel() {
const totalHeight = computed(() => { const totalHeight = computed(() => {
const pos = positions.value const pos = positions.value
return pos.length === 0 ? 0 : pos[pos.length - 1].bottom return pos.length === 0 ? 0 : pos[pos.length - 1].bottom + 350 // 额外增加 350px 底部留白,方便最底部节点操作与表格新增等动作
}) })
const viewportRef = ref<HTMLElement | null>(null) const viewportRef = ref<HTMLElement | null>(null)
...@@ -108,7 +109,8 @@ export function useEditorPanel() { ...@@ -108,7 +109,8 @@ export function useEditorPanel() {
const startIndex = computed(() => { const startIndex = computed(() => {
const pos = positions.value const pos = positions.value
if (pos.length === 0) return 0 if (pos.length === 0) return 0
let low = 0, high = pos.length - 1 let low = 0,
high = pos.length - 1
while (low <= high) { while (low <= high) {
const mid = Math.floor((low + high) / 2) const mid = Math.floor((low + high) / 2)
if (pos[mid].bottom > scrollTop.value) high = mid - 1 if (pos[mid].bottom > scrollTop.value) high = mid - 1
...@@ -122,7 +124,8 @@ export function useEditorPanel() { ...@@ -122,7 +124,8 @@ export function useEditorPanel() {
const pos = positions.value const pos = positions.value
if (pos.length === 0) return 0 if (pos.length === 0) return 0
const visibleBottom = scrollTop.value + viewportHeight.value const visibleBottom = scrollTop.value + viewportHeight.value
let low = 0, high = pos.length - 1 let low = 0,
high = pos.length - 1
while (low <= high) { while (low <= high) {
const mid = Math.floor((low + high) / 2) const mid = Math.floor((low + high) / 2)
if (pos[mid].top >= visibleBottom) high = mid - 1 if (pos[mid].top >= visibleBottom) high = mid - 1
...@@ -131,9 +134,7 @@ export function useEditorPanel() { ...@@ -131,9 +134,7 @@ export function useEditorPanel() {
return Math.min(pos.length, low + BUFFER) return Math.min(pos.length, low + BUFFER)
}) })
const visibleBlocks = computed(() => const visibleBlocks = computed(() => blocksList.value.slice(startIndex.value, endIndex.value))
blocksList.value.slice(startIndex.value, endIndex.value)
)
const startOffset = computed(() => { const startOffset = computed(() => {
const pos = positions.value const pos = positions.value
...@@ -202,38 +203,62 @@ export function useEditorPanel() { ...@@ -202,38 +203,62 @@ export function useEditorPanel() {
} }
} }
const isAncestorOrSelf = (blockId: string, childId: string): boolean => { /**
let curr = editorStore.nodeMap.get(childId) * 从目标节点向上遍历祖先链,找到最近(最深)的包含块索引。
*
* 原来用 findIndex+isRelatedBlock:TOPIC 是 SUBTASK 的祖先,也是 CONNBR 的祖先,
* 所以 findIndex 命中的是排在前面的 TOPIC 块,导致虚拟列表跳到整个章节顶部,
* 而非包含目标节点的具体 SUBTASK/PRETOPIC 块。
*
* 新方案:从目标节点(CONNBR)沿 parent 链向上,第一个命中 blocksList 的节点即为
* 最近的渲染块(如 SUBTASK)。后续路径搜索会自动回退到 CON/PARAC 等可见父元素。
*/
const findNearestBlockIdx = (targetId: string): number => {
const blockIdxMap = new Map(blocksList.value.map((b, idx) => [b.id, idx]))
if (blockIdxMap.has(targetId)) return blockIdxMap.get(targetId)!
let curr = editorStore.nodeMap.get(targetId)
while (curr) { while (curr) {
if (curr.node.id === blockId) return true if (blockIdxMap.has(curr.node.id)) return blockIdxMap.get(curr.node.id)!
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
} }
return false return -1
} }
const syncEditorScroll = (newId: string | null, force = false) => { const syncEditorScroll = (newId: string | null, force = false) => {
if (!newId || !editorStore.xmlTree) return if (!newId || !editorStore.xmlTree) return
const blockIdx = blocksList.value.findIndex(b => isAncestorOrSelf(b.id, newId)) // 找到包含目标节点的最近块(如 SUBTASK),而非远祖先的 TOPIC 块
const blockIdx = findNearestBlockIdx(newId)
if (blockIdx === -1) return if (blockIdx === -1) return
nextTick(() => { nextTick(() => {
if (!viewportRef.value) return if (!viewportRef.value) return
// 1. 先从路径中由下到上找最近的已挂载 DOM 元素 // 1. 先从路径中由下到上找最近的已挂载 DOM 元素,需限制在所属文档块已挂载的前提下
const blockId = blocksList.value[blockIdx]?.id
const blockEl =
blockId && viewportRef.value ? (viewportRef.value.querySelector(`[data-node-id="${blockId}"]`) as HTMLElement | null) : null
let el: HTMLElement | null = null let el: HTMLElement | null = null
let foundNodeId: string | null = null
const path: XmlNode[] = [] const path: XmlNode[] = []
let curr = editorStore.nodeMap.get(newId) let curr = editorStore.nodeMap.get(newId)
while (curr) { while (curr) {
path.unshift(curr.node) path.unshift(curr.node)
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
} }
if (path.length > 0) { if (blockEl && path.length > 0) {
for (let i = path.length - 1; i >= 0; i--) { for (let i = path.length - 1; i >= 0; i--) {
el = viewportRef.value.querySelector( if (blockEl.dataset.nodeId === path[i].id) {
`[data-node-id="${path[i].id}"]` el = blockEl
) as HTMLElement | null foundNodeId = path[i].id
if (el) break break
}
el = blockEl.querySelector(`[data-node-id="${path[i].id}"]`) as HTMLElement | null
if (el) {
foundNodeId = path[i].id
break
}
} }
} }
...@@ -244,7 +269,7 @@ export function useEditorPanel() { ...@@ -244,7 +269,7 @@ export function useEditorPanel() {
if (!force && rect.top >= containerRect.top + 20 && rect.bottom <= containerRect.bottom - 20) { if (!force && rect.top >= containerRect.top + 20 && rect.bottom <= containerRect.bottom - 20) {
return return
} }
el.scrollIntoView({ behavior: 'smooth', block: 'center' }) el.scrollIntoView({ behavior: 'auto', block: 'center' })
return return
} }
...@@ -262,21 +287,31 @@ export function useEditorPanel() { ...@@ -262,21 +287,31 @@ export function useEditorPanel() {
setTimeout(() => { setTimeout(() => {
if (!viewportRef.value) return if (!viewportRef.value) return
let targetEl: HTMLElement | null = null let targetEl: HTMLElement | null = null
let subFoundId: string | null = null
if (path && path.length > 0) { if (path && path.length > 0) {
for (let i = path.length - 1; i >= 0; i--) { for (let i = path.length - 1; i >= 0; i--) {
targetEl = viewportRef.value.querySelector( targetEl = viewportRef.value.querySelector(`[data-node-id="${path[i].id}"]`) as HTMLElement | null
`[data-node-id="${path[i].id}"]` if (targetEl) {
) as HTMLElement | null subFoundId = path[i].id
if (targetEl) break break
}
} }
} }
targetEl?.scrollIntoView({ behavior: 'smooth', block: 'center' }) if (targetEl) {
targetEl.scrollIntoView({ behavior: 'auto', block: 'center' })
}
}, 100) }, 100)
}) })
} }
watch(() => editorStore.selectedNodeId, (newId) => syncEditorScroll(newId, false)) watch(
watch(() => editorStore.lastUndoRedoTime, () => syncEditorScroll(editorStore.selectedNodeId, true)) () => editorStore.selectedNodeId,
(newId) => syncEditorScroll(newId, false)
)
watch(
() => editorStore.lastUndoRedoTime,
() => syncEditorScroll(editorStore.selectedNodeId, true)
)
return { return {
selectedNode, selectedNode,
...@@ -287,6 +322,7 @@ export function useEditorPanel() { ...@@ -287,6 +322,7 @@ export function useEditorPanel() {
startOffset, startOffset,
visibleBlocks, visibleBlocks,
handleScroll, handleScroll,
setBlockRef setBlockRef,
syncEditorScroll
} }
} }
<template> <template>
<div class="flex-1 flex flex-col min-h-0 bg-transparent"> <div class="flex-1 flex flex-col min-h-0 bg-transparent relative">
<template v-if="editorStore.xmlTree"> <template v-if="editorStore.xmlTree">
<!-- 顶部面包屑与属性面板控制 --> <!-- 顶部面包屑与属性面板控制 -->
<div <div class="px-4 py-2 border-b border-divider bg-card flex items-center justify-between text-xs shrink-0">
class="px-4 py-2 border-b border-divider bg-fill-2 flex items-center justify-between text-xs shrink-0" <div class="flex items-center space-x-2 flex-1 min-w-0 mr-4 overflow-x-auto whitespace-nowrap scrollbar-thin">
> <span class="text-color3 select-none shrink-0">当前路径:</span>
<div class="flex items-center space-x-2">
<span class="text-color3 select-none">当前路径:</span>
<n-breadcrumb v-if="nodePath.length > 0"> <n-breadcrumb v-if="nodePath.length > 0">
<n-breadcrumb-item <n-breadcrumb-item
v-for="n in nodePath" v-for="n in nodePath"
...@@ -20,13 +18,18 @@ ...@@ -20,13 +18,18 @@
<span v-else class="text-color3 italic">未选择节点</span> <span v-else class="text-color3 italic">未选择节点</span>
</div> </div>
<div class="flex items-center space-x-2">
<!-- 查找替换按钮 -->
<CommonButton size="tiny" secondary @click="toggleFindReplace">
<template #icon>
<n-icon>
<search-outline />
</n-icon>
</template>
查找替换 (Ctrl+F)
</CommonButton>
<!-- 修改属性按钮(触发弹框) --> <!-- 修改属性按钮(触发弹框) -->
<CommonButton <CommonButton size="tiny" secondary :disabled="!selectedNode" @click="handleEditSelectedNode">
size="tiny"
secondary
:disabled="!selectedNode"
@click="handleEditSelectedNode"
>
<template #icon> <template #icon>
<n-icon> <n-icon>
<settings-outline /> <settings-outline />
...@@ -35,31 +38,28 @@ ...@@ -35,31 +38,28 @@
修改属性 修改属性
</CommonButton> </CommonButton>
</div> </div>
</div>
<!-- 文档编辑区(虚拟滚动容器) --> <!-- 文档编辑区(虚拟滚动容器) -->
<div <div ref="viewportRef" class="flex-1 overflow-y-auto min-h-0 leading-relaxed relative" @scroll="handleScroll">
ref="viewportRef"
class="flex-1 overflow-y-auto min-h-0 leading-relaxed relative"
@scroll="handleScroll"
>
<!-- 占位撑高,模拟全量内容总高度 --> <!-- 占位撑高,模拟全量内容总高度 -->
<div :style="{ height: totalHeight + 'px', position: 'relative' }"> <div :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 仅渲染可视区块,通过 translateY 定位 --> <!-- 仅渲染可视区块,通过 translateY 定位 -->
<div <div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0">
:style="{ transform: `translateY(${startOffset}px)` }"
class="absolute top-0 left-0 right-0"
>
<div <div
v-for="block in visibleBlocks" v-for="block in visibleBlocks"
:key="block.id" :key="block.id"
:ref="(el: any) => setBlockRef(el, block.id)" :ref="(el: any) => setBlockRef(el, block.id)"
class="w-full px-6 py-2 border-b border-divider/30" class="w-full px-6 py-2 border-b border-divider/30"
> >
<DocNodeRenderer :node="block.rawNode" /> <DocNodeRenderer :node="block.rawNode" :parent="null" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 查找与替换浮动面板 -->
<FindReplacePanel v-model:visible="findReplaceVisible" :sync-editor-scroll="syncEditorScroll" />
</template> </template>
<template v-else> <template v-else>
<div class="flex-1 flex flex-col items-center justify-center text-color3"> <div class="flex-1 flex flex-col items-center justify-center text-color3">
...@@ -70,26 +70,39 @@ ...@@ -70,26 +70,39 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { DocumentTextOutline, SettingsOutline } from '@vicons/ionicons5' import { SettingsOutline, SearchOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import DocNodeRenderer from '../DocNodeRenderer/index.vue' import DocNodeRenderer from '../DocNodeRenderer/index.vue'
import FindReplacePanel from './components/FindReplacePanel/index.vue'
import { useEditorPanel } from './functionals' import { useEditorPanel } from './functionals'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals' import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals'
const themeVars = useThemeVars()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const { const { selectedNode, nodePath, editorTitle, viewportRef, totalHeight, startOffset, visibleBlocks, handleScroll, setBlockRef, syncEditorScroll } =
selectedNode, useEditorPanel()
nodePath,
editorTitle, const findReplaceVisible = ref(false)
viewportRef,
totalHeight, const toggleFindReplace = () => {
startOffset, findReplaceVisible.value = !findReplaceVisible.value
visibleBlocks, }
handleScroll,
setBlockRef const handleKeydown = (e: KeyboardEvent) => {
} = useEditorPanel() const ctrl = e.ctrlKey || e.metaKey
if (ctrl && e.key.toLowerCase() === 'f') {
e.preventDefault()
findReplaceVisible.value = true
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
const handleEditSelectedNode = () => { const handleEditSelectedNode = () => {
if (!selectedNode.value) return if (!selectedNode.value) return
...@@ -99,3 +112,26 @@ const handleEditSelectedNode = () => { ...@@ -99,3 +112,26 @@ const handleEditSelectedNode = () => {
addNodeVisible.value = true addNodeVisible.value = true
} }
</script> </script>
<style scoped>
:deep(.n-breadcrumb) {
display: flex !important;
flex-wrap: nowrap !important;
}
:deep(.n-breadcrumb-item) {
flex-shrink: 0 !important;
}
.scrollbar-thin::-webkit-scrollbar {
height: 4px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: #e0e0e0;
border-radius: 2px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #18a058;
}
</style>
import { useEditorStore } from '@/store/editor'
export function useInsertFragmentModal() {
const editorStore = useEditorStore()
const visible = ref(false)
const xmlContent = ref('')
const isSaving = ref(false)
const insertModeSetting = ref<'above' | 'below' | 'inside'>('below')
const targetNodeIdSetting = ref<string | undefined>(undefined)
const open = async (mode: boolean | 'above' | 'below' | 'inside', targetId?: string) => {
if (typeof mode === 'boolean') {
insertModeSetting.value = mode ? 'below' : 'inside'
} else {
insertModeSetting.value = mode
}
targetNodeIdSetting.value = targetId
xmlContent.value = ''
try {
if (navigator.clipboard && navigator.clipboard.readText) {
const text = await navigator.clipboard.readText()
if (text && text.trim()) {
xmlContent.value = text.trim()
}
}
} catch (err) {
console.warn('读取剪贴板失败:', err)
}
visible.value = true
}
const handleConfirm = async () => {
if (!xmlContent.value.trim()) {
window.$message?.warning('请输入有效的 XML 片段内容!')
return
}
isSaving.value = true
try {
const count = editorStore.insertXmlFragment(
xmlContent.value.trim(),
insertModeSetting.value,
targetNodeIdSetting.value
)
window.$message?.success(`成功插入 ${count} 个 XML 节点`)
visible.value = false
} catch (err: any) {
window.$message?.error(err.message || '插入失败')
} finally {
isSaving.value = false
}
}
return {
visible,
xmlContent,
isSaving,
open,
handleConfirm
}
}
<template>
<CommonModal
v-model="visible"
title="插入 XML 片段"
:width="600"
:loading="isSaving"
confirm-text="插入"
@confirm="handleConfirm"
>
<div class="flex flex-col space-y-3 p-1">
<div class="text-xs text-color3">
请输入从其他地方复制的 XML 节点片段,系统将自动解析节点结构,并根据当前选中节点及 DTD 架构规则进行兼容性校验:
</div>
<n-input
v-model:value="xmlContent"
type="textarea"
rows="10"
placeholder="例如:<PARAC>测试记录行</PARAC>"
/>
</div>
</CommonModal>
</template>
<script setup lang="ts">
import { useInsertFragmentModal } from './functionals'
const {
visible,
xmlContent,
isSaving,
open,
handleConfirm
} = useInsertFragmentModal()
defineExpose({
open
})
</script>
import { ImageOutline, GridOutline, DocumentTextOutline, CreateOutline } from '@vicons/ionicons5'
/** /**
* EditorToolbar 组件级静态常量 * EditorToolbar 组件级静态常量
*/ */
export const TOOLBAR_TITLE = 'XML 编辑工具栏' export const TOOLBAR_TITLE = 'XML 编辑工具栏'
export const GREEN_BUTTONS: any[] = [
// { label: '插入图片', tag: 'GRAPHIC', icon: ImageOutline },
// { label: '插入表格', tag: 'TABLE', icon: GridOutline },
// { label: '插入模板', tag: 'PRETOPIC', icon: DocumentTextOutline },
// { label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline }
]
import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app/index'
/** /**
* EditorToolbar 组件级业务逻辑 Hook * EditorToolbar 组件级业务逻辑 Hook
*/ */
export function useEditorToolbar() { export function useEditorToolbar(emit: any) {
// 暂无专用逻辑,事件通过 emit 委托给父级页面 const editorStore = useEditorStore()
return {} const appStore = useAppStore()
const insertBelow = ref(true)
const fileInputRef = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
const canUndo = computed(() => editorStore.undoStack.length > 0)
const canRedo = computed(() => editorStore.redoStack.length > 0)
const handleInsert = (tag: string) => {
editorStore.insertNode(tag, insertBelow.value)
}
const handleTranslate = (type: 'batch' | 'extract' | 'search') => {
const labelMap = { batch: '批量翻译', extract: '提取翻译', search: '搜索翻译' }
window.$message.info(`已触发 ${labelMap[type]} 功能,自动匹配双语对照。`)
}
const triggerUpload = () => {
openUploadModal({
accept: '.xml',
title: '导入 XML',
onSuccess: async (res, file) => {
let text = ''
if (file) {
text = await file.text()
} else if (res && typeof res === 'string') {
text = res
}
if (!text) {
window.$message.error('未获取到导入文件内容')
return
}
isUploading.value = true
window.$loading?.show('解析中…')
try {
const tree = await parseXmlToTreeAsync(text)
editorStore.setXmlTree(tree)
window.$message.success('XML 导入成功!')
} catch (err: any) {
window.$message.error('XML 解析失败: ' + err.message)
} finally {
isUploading.value = false
window.$loading?.hide()
}
}
})
}
return {
editorStore,
appStore,
insertBelow,
fileInputRef,
isUploading,
canUndo,
canRedo,
handleInsert,
handleTranslate,
triggerUpload,
handleFileUpload: () => {} // 保留空函数以防组件 template 尚未完全更新时报错
}
} }
<template> <template>
<div class="editor-toolbar flex flex-wrap items-center justify-between px-4 py-2 border-b border-divider bg-fill-2 gap-3 select-none"> <div
<!-- 左侧:内容插入操作区 --> class="editor-toolbar flex flex-nowrap items-center px-4 py-2 border-b border-divider bg-card select-none w-full overflow-x-auto overflow-y-hidden h-[54px]"
<div class="flex flex-wrap items-center gap-2"> >
<!-- 左侧:内容插入操作区 (强制不收缩) -->
<div class="flex items-center gap-2 flex-shrink-0">
<!-- 插入位置设置 --> <!-- 插入位置设置 -->
<div class="flex items-center gap-1.5 bg-fill-3 px-2.5 py-1 rounded-md border border-divider text-xs"> <div class="flex items-center gap-1.5 bg-fill-3 px-2.5 py-1 rounded-md border border-divider text-xs flex-shrink-0">
<span class="font-medium text-color3">插入</span> <span class="font-medium text-color3">插入</span>
<span class="font-semibold" :class="insertBelow ? 'text-primary' : 'text-color2'"> <span class="font-semibold text-color2 w-8 text-center" :class="insertBelow ? 'text-primary' : 'text-color2'">
{{ insertBelow ? '下方' : '上方' }} {{ insertBelow ? '下方' : '内部' }}
</span> </span>
<n-switch v-model:value="insertBelow" size="small" /> <n-switch v-model:value="insertBelow" size="small" />
</div> </div>
<n-divider vertical class="!mx-0" /> <n-divider vertical class="!mx-0 flex-shrink-0" />
<!-- 插入元素按钮组 --> <!-- 插入元素按钮组 (这里同为 flex-shrink-0) -->
<div class="flex flex-wrap items-center gap-1"> <div class="flex items-center gap-1 flex-shrink-0">
<CommonButton <CommonButton
v-for="btn in greenButtons" v-for="btn in GREEN_BUTTONS"
:key="btn.tag" :key="btn.tag"
type="primary" secondary
size="small" size="small"
class="insert-btn" class="insert-btn flex-shrink-0"
@click="handleInsert(btn.tag)" @click="handleInsert(btn.tag)"
> >
<template #icon> <template #icon>
...@@ -28,13 +30,26 @@ ...@@ -28,13 +30,26 @@
</template> </template>
{{ btn.label.replace('插入', '') }} {{ btn.label.replace('插入', '') }}
</CommonButton> </CommonButton>
<!-- 新增:插入 XML 片段按钮 -->
<CommonButton
secondary
size="small"
class="insert-btn flex-shrink-0"
@click="insertFragmentModalRef?.open(insertBelow)"
>
<template #icon>
<n-icon><code-working-outline /></n-icon>
</template>
XML片段
</CommonButton>
</div> </div>
</div> </div>
<!-- 右侧:全局工具与管理操作区 --> <!-- 右侧:全局工具与管理操作区 (固定宽度,ml-auto 靠右,强制不收缩) -->
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex items-center gap-1.5 flex-shrink-0 ml-auto">
<!-- 翻译辅助组 --> <!-- 翻译辅助组 -->
<div class="flex items-center gap-1 bg-fill-3 px-1 py-0.5 rounded-md border border-divider"> <div class="flex items-center gap-1 bg-fill-3 px-1 py-0.5 rounded-md border border-divider flex-shrink-0">
<CommonButton size="small" quaternary class="util-btn" @click="handleTranslate('batch')"> <CommonButton size="small" quaternary class="util-btn" @click="handleTranslate('batch')">
<template #icon> <template #icon>
<n-icon><language-outline /></n-icon> <n-icon><language-outline /></n-icon>
...@@ -55,37 +70,35 @@ ...@@ -55,37 +70,35 @@
</CommonButton> </CommonButton>
</div> </div>
<n-divider vertical class="!mx-0" /> <n-divider vertical class="!mx-0 flex-shrink-0" />
<!-- XML文件管理 --> <CommonButton size="small" secondary class="xml-btn flex-shrink-0" :disabled="isUploading" :loading="isUploading" @click="triggerUpload">
<CommonButton size="small" secondary class="xml-btn" :disabled="isUploading" :loading="isUploading" @click="triggerUpload">
<template #icon> <template #icon>
<n-icon><cloud-upload-outline /></n-icon> <n-icon><cloud-upload-outline /></n-icon>
</template> </template>
{{ isUploading ? '解析中…' : '导入 XML' }} {{ isUploading ? '解析中…' : '导入 XML' }}
</CommonButton> </CommonButton>
<input type="file" ref="fileInputRef" style="display: none" accept=".xml" @change="handleFileUpload" />
<CommonButton size="small" secondary class="xml-btn" @click="emit('export')"> <CommonButton size="small" secondary class="xml-btn flex-shrink-0" @click="emit('export')">
<template #icon> <template #icon>
<n-icon><cloud-download-outline /></n-icon> <n-icon><cloud-download-outline /></n-icon>
</template> </template>
导出 XML 导出 XML
</CommonButton> </CommonButton>
<CommonButton size="small" type="primary" class="preview-btn" @click="emit('preview')"> <CommonButton size="small" type="primary" class="preview-btn flex-shrink-0" @click="emit('preview')">
<template #icon> <template #icon>
<n-icon><eye-outline /></n-icon> <n-icon><eye-outline /></n-icon>
</template> </template>
预览工卡 预览工卡
</CommonButton> </CommonButton>
<n-divider vertical class="!mx-0" /> <n-divider vertical class="!mx-0 flex-shrink-0" />
<!-- 撤销 / 重做 --> <!-- 撤销 / 重做 -->
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<CommonButton size="small" quaternary :disabled="!canUndo" @click="editorStore.undo()"> <CommonButton size="small" quaternary :disabled="!canUndo" @click="editorStore.undo()" class="flex-shrink-0">
<template #icon> <template #icon>
<n-icon><arrow-undo-outline /></n-icon> <n-icon><arrow-undo-outline /></n-icon>
</template> </template>
...@@ -97,7 +110,7 @@ ...@@ -97,7 +110,7 @@
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<CommonButton size="small" quaternary :disabled="!canRedo" @click="editorStore.redo()"> <CommonButton size="small" quaternary :disabled="!canRedo" @click="editorStore.redo()" class="flex-shrink-0">
<template #icon> <template #icon>
<n-icon><arrow-redo-outline /></n-icon> <n-icon><arrow-redo-outline /></n-icon>
</template> </template>
...@@ -107,12 +120,12 @@ ...@@ -107,12 +120,12 @@
重做 (Ctrl+Y) 重做 (Ctrl+Y)
</n-tooltip> </n-tooltip>
<n-divider vertical class="!mx-0" /> <n-divider vertical class="!mx-0 flex-shrink-0" />
<!-- 主题切换 --> <!-- 主题切换 -->
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<CommonButton size="small" quaternary circle @click="appStore.isDark = !appStore.isDark"> <CommonButton size="small" quaternary circle @click="appStore.isDark = !appStore.isDark" class="flex-shrink-0">
<template #icon> <template #icon>
<n-icon> <n-icon>
<sunny-outline v-if="appStore.isDark" /> <sunny-outline v-if="appStore.isDark" />
...@@ -125,21 +138,18 @@ ...@@ -125,21 +138,18 @@
</n-tooltip> </n-tooltip>
<!-- 偏好设置 --> <!-- 偏好设置 -->
<div class="flex-shrink-0 flex items-center">
<SettingsDrawer /> <SettingsDrawer />
</div> </div>
</div> </div>
<!-- 插入 XML 片段弹窗 -->
<InsertFragmentModal ref="insertFragmentModalRef" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import {
ImageOutline,
GridOutline,
DocumentTextOutline,
ListOutline,
CalendarOutline,
CalculatorOutline,
CheckboxOutline,
CreateOutline,
LanguageOutline, LanguageOutline,
DownloadOutline, DownloadOutline,
SearchOutline, SearchOutline,
...@@ -149,71 +159,45 @@ import { ...@@ -149,71 +159,45 @@ import {
ArrowUndoOutline, ArrowUndoOutline,
ArrowRedoOutline, ArrowRedoOutline,
SunnyOutline, SunnyOutline,
MoonOutline MoonOutline,
CodeWorkingOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorToolbar } from './functionals'
import { useAppStore } from '@/store/app/index' import { GREEN_BUTTONS } from './constants'
import SettingsDrawer from '@/layouts/components/SettingsDrawer.vue' import SettingsDrawer from '@/layouts/components/SettingsDrawer.vue'
import { parseXmlToTreeAsync } from '@/utils/xmlParser' import InsertFragmentModal from './components/InsertFragmentModal/index.vue'
const emit = defineEmits(['save', 'validate', 'export', 'preview']) const emit = defineEmits(['save', 'validate', 'export', 'preview'])
const editorStore = useEditorStore()
const appStore = useAppStore()
const insertBelow = ref(true)
const fileInputRef = ref<HTMLInputElement | null>(null)
const canUndo = computed(() => editorStore.undoStack.length > 0)
const canRedo = computed(() => editorStore.redoStack.length > 0)
const greenButtons = [
{ label: '插入图片', tag: 'GRAPHIC', icon: ImageOutline },
{ label: '插入表格', tag: 'TABLE', icon: GridOutline },
{ label: '插入模板', tag: 'PRETOPIC', icon: DocumentTextOutline },
{ label: '插入记录项', tag: 'RECORD-LINE', icon: ListOutline },
{ label: '插入校验日期', tag: 'DATE', icon: CalendarOutline },
{ label: '插入单位记录项', tag: 'UNIT-RECORD', icon: CalculatorOutline },
{ label: '插入选项组', tag: 'SELECTION', icon: CheckboxOutline },
{ label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline }
]
const handleInsert = (tag: string) => editorStore.insertNode(tag, insertBelow.value) const { editorStore, appStore, insertBelow, fileInputRef, isUploading, canUndo, canRedo, handleInsert, handleTranslate, triggerUpload } =
useEditorToolbar(emit)
const handleTranslate = (type: 'batch' | 'extract' | 'search') => { const insertFragmentModalRef = ref<any>(null)
const labelMap = { batch: '批量翻译', extract: '提取翻译', search: '搜索翻译' }
window.$message.info(`已触发 ${labelMap[type]} 功能,自动匹配双语对照。`)
}
const triggerUpload = () => fileInputRef.value?.click()
const isUploading = ref(false)
const handleFileUpload = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
target.value = ''
const text = await file.text()
isUploading.value = true
window.$message.info(`正在解析 ${file.name}${(file.size / 1024).toFixed(0)} KB),请稍候…`)
try {
const tree = await parseXmlToTreeAsync(text)
editorStore.setXmlTree(tree)
window.$message.success(`${file.name} 解析成功!`)
} catch (err: any) {
window.$message.error('XML 解析失败: ' + err.message)
} finally {
isUploading.value = false
}
}
</script> </script>
<style scoped> <style scoped>
/* 消除工具栏容器本身的 focus outline */ /* 消除工具栏容器本身的 focus outline */
.editor-toolbar { .editor-toolbar {
outline: none; outline: none;
/* 允许横向滚动 */
scrollbar-width: thin;
scrollbar-color: var(--n-scrollbar-color, rgba(0, 0, 0, 0.2)) transparent;
}
/* 整个工具栏滚动条自定义:极其纤细,显眼且好看 */
.editor-toolbar::-webkit-scrollbar {
height: 5px;
}
.editor-toolbar::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--divider-color) 40%, transparent);
border-radius: 2.5px;
}
.editor-toolbar::-webkit-scrollbar-thumb {
background: var(--n-scrollbar-color, rgba(0, 0, 0, 0.2));
border-radius: 2.5px;
}
.editor-toolbar::-webkit-scrollbar-thumb:hover {
background: var(--n-scrollbar-color-hover, rgba(0, 0, 0, 0.4));
} }
/* 插入按钮:深色文字 + 更紧凑的圆角 */ /* 插入按钮:深色文字 + 更紧凑的圆角 */
......
...@@ -14,11 +14,11 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -14,11 +14,11 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
// 表单数据 // 表单数据
const form = reactive<{ const form = reactive<{
tagName: string tagName: string | null
attrs: Record<string, string> attrs: Record<string, string | null>
textContent: string textContent: string
}>({ }>({
tagName: '', tagName: null,
attrs: {}, attrs: {},
textContent: '' textContent: ''
}) })
...@@ -85,15 +85,15 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -85,15 +85,15 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
form.tagName = node.tagName form.tagName = node.tagName
form.textContent = node.textContent || '' form.textContent = node.textContent || ''
const defs = getElementAttributes(node.tagName) const defs = getElementAttributes(node.tagName)
const attrs: Record<string, string> = {} const attrs: Record<string, string | null> = {}
for (const name of Object.keys(defs)) { for (const name of Object.keys(defs)) {
attrs[name] = node.attributes[name] || '' attrs[name] = node.attributes[name] !== undefined ? node.attributes[name] : null
} }
form.attrs = attrs form.attrs = attrs
} }
} }
} else { } else {
form.tagName = '' form.tagName = null
form.attrs = {} form.attrs = {}
form.textContent = '' form.textContent = ''
} }
...@@ -117,9 +117,32 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -117,9 +117,32 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
store.saveSnapshot() store.saveSnapshot()
const cleanAttrs: Record<string, string> = {}
for (const [k, v] of Object.entries(form.attrs)) {
if (v !== null && v !== undefined && v !== '') {
cleanAttrs[k] = v
}
}
if (addNodeMode.value === 'edit') { if (addNodeMode.value === 'edit') {
targetNode.attributes = { ...form.attrs } targetNode.attributes = cleanAttrs
targetNode.textContent = form.textContent targetNode.textContent = form.textContent
// 同步更新混合内容子节点的文本
if (isMixedContentElement(targetNode.tagName)) {
targetNode.mixedContent = targetNode.mixedContent || []
const firstTextIndex = targetNode.mixedContent.findIndex((item) => item.type === 'text')
if (firstTextIndex !== -1) {
if (form.textContent) {
targetNode.mixedContent[firstTextIndex].text = form.textContent
} else {
targetNode.mixedContent.splice(firstTextIndex, 1)
}
} else if (form.textContent) {
targetNode.mixedContent.unshift({ type: 'text', text: form.textContent })
}
}
// 如果当前编辑的正是选中的节点,强制更新 store 的选中节点引用以重新渲染视图 // 如果当前编辑的正是选中的节点,强制更新 store 的选中节点引用以重新渲染视图
if (store.selectedNodeId === targetNode.id) { if (store.selectedNodeId === targetNode.id) {
store.setSelectedNodeId(targetNode.id) store.setSelectedNodeId(targetNode.id)
...@@ -130,8 +153,8 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -130,8 +153,8 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const newId = crypto.randomUUID() const newId = crypto.randomUUID()
const newNode: XmlNode = { const newNode: XmlNode = {
id: newId, id: newId,
tagName: form.tagName, tagName: form.tagName!,
attributes: { ...form.attrs }, attributes: cleanAttrs,
children: [], children: [],
textContent: form.textContent, textContent: form.textContent,
mixedContent: [], mixedContent: [],
...@@ -150,6 +173,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -150,6 +173,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
parent.children.splice(insertIdx, 0, newNode) parent.children.splice(insertIdx, 0, newNode)
} }
store.rebuildNodeMap()
store.setSelectedNodeId(newId) store.setSelectedNodeId(newId)
window.$message?.success(`成功插入节点 <${form.tagName}>`) window.$message?.success(`成功插入节点 <${form.tagName}>`)
addNodeVisible.value = false addNodeVisible.value = false
......
<template> <template>
<CommonModal <CommonModal v-model="addNodeVisible" :title="modalTitle" :width="520" :loading="saving" @confirm="handleConfirm">
v-model="addNodeVisible"
:title="modalTitle"
:width="520"
:loading="saving"
@confirm="handleConfirm"
>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top" require-mark-placement="right-hanging"> <n-form ref="formRef" :model="form" :rules="rules" label-placement="top" require-mark-placement="right-hanging">
<!-- 标签选择 --> <!-- 标签选择 -->
<n-form-item label="标签" path="tagName"> <n-form-item label="标签" path="tagName">
<n-select <CommonSelect v-model:value="form.tagName" :options="tagOptions" :disabled="addNodeMode === 'edit'" @change="onTagChange" />
v-model:value="form.tagName"
:options="tagOptions"
placeholder="请选择"
filterable
:disabled="addNodeMode === 'edit'"
@update:value="onTagChange"
/>
</n-form-item> </n-form-item>
<!-- 动态属性表单 --> <!-- 动态属性表单 -->
...@@ -24,25 +11,15 @@ ...@@ -24,25 +11,15 @@
<n-divider class="!my-2"> <n-divider class="!my-2">
<span class="text-xs text-color3">属性配置</span> <span class="text-xs text-color3">属性配置</span>
</n-divider> </n-divider>
<n-form-item <n-form-item v-for="attr in attributeDefs" :key="attr.name" :label="attr.name" :path="`attrs.${attr.name}`">
v-for="attr in attributeDefs"
:key="attr.name"
:label="attr.name"
:path="`attrs.${attr.name}`"
>
<!-- 枚举类型:select --> <!-- 枚举类型:select -->
<n-select <CommonSelect
v-if="attr.enumValues && attr.enumValues.length > 0" v-if="attr.enumValues && attr.enumValues.length > 0"
v-model:value="form.attrs[attr.name]" v-model:value="form.attrs[attr.name]"
:options="attr.enumValues.map(v => ({ label: v, value: v }))" :options="attr.enumValues.map((v) => ({ label: v, value: v }))"
placeholder="请选择"
/> />
<!-- 普通文本 --> <!-- 普通文本 -->
<n-input <n-input v-else v-model:value="form.attrs[attr.name]" />
v-else
v-model:value="form.attrs[attr.name]"
placeholder="请输入"
/>
<!-- 属性说明 --> <!-- 属性说明 -->
<template v-if="attr.typeDefinition" #feedback> <template v-if="attr.typeDefinition" #feedback>
<span class="text-[10px] text-color3">{{ attr.typeDefinition }}</span> <span class="text-[10px] text-color3">{{ attr.typeDefinition }}</span>
...@@ -55,12 +32,7 @@ ...@@ -55,12 +32,7 @@
<span class="text-xs text-color3">文本内容 (PCDATA)</span> <span class="text-xs text-color3">文本内容 (PCDATA)</span>
</n-divider> </n-divider>
<n-form-item label="内容" path="textContent"> <n-form-item label="内容" path="textContent">
<n-input <n-input v-model:value="form.textContent" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" />
v-model:value="form.textContent"
type="textarea"
placeholder="请输入文本内容"
:autosize="{ minRows: 2, maxRows: 6 }"
/>
</n-form-item> </n-form-item>
</template> </template>
</n-form> </n-form>
...@@ -74,15 +46,5 @@ import { useAddNodeModal } from './functionals' ...@@ -74,15 +46,5 @@ import { useAddNodeModal } from './functionals'
const formRef = ref<FormInst | null>(null) const formRef = ref<FormInst | null>(null)
const { const { form, rules, saving, modalTitle, tagOptions, attributeDefs, showTextContentField, onTagChange, handleConfirm } = useAddNodeModal(formRef)
form,
rules,
saving,
modalTitle,
tagOptions,
attributeDefs,
showTextContentField,
onTagChange,
handleConfirm
} = useAddNodeModal(formRef)
</script> </script>
import { checkRuleData } from '../../../functionals' import { checkRuleData } from '../../../functionals'
// ─── occurrence 符号 ───────────────────────────────────────────────────────────
const OCC: Record<string, string> = {
zeroOrMore: '*',
oneOrMore: '+',
optional: '?',
once: ''
}
// ─── 将节点渲染为纯文本(用于估算宽度 / 简单节点内联显示)─────────────────────
const toInline = (node: any): string => {
if (!node) return ''
const occ = OCC[node.occurrence] ?? ''
if (node.type === 'elementRef') return `${node.name}${occ}`
if (node.type === 'pcdata') return `#PCDATA`
if (!node.children?.length) return ''
const sep = node.type === 'sequence' ? ', ' : ' | '
const body = node.children.map(toInline).join(sep)
return `(${body})${occ}`
}
// ─── 判断一个节点能否内联(不超过 maxLen 个字符)────────────────────────────────
const canInline = (node: any, maxLen = 60): boolean => toInline(node).length <= maxLen
// ─── 生成带 HTML 语法高亮的格式化文本 ───────────────────────────────────────────
const h = {
elem: (s: string) => `<span class="text-primary font-semibold">${s}</span>`,
occ: (s: string) => (s ? `<span class="text-warning font-bold">${s}</span>` : ''),
punc: (s: string) => `<span class="text-color3">${s}</span>`,
pipe: () => `<span class="text-color3"> | </span>`,
comma: () => `<span class="text-color3">, </span>`,
pcdata: () => `<span class="text-danger italic font-semibold">#PCDATA</span><span class="text-color3 text-[11px] ml-1">(文本)</span>`
}
const formatNodeHtml = (node: any, indent: number): string => {
if (!node) return ''
const pad = ' '.repeat(indent)
const occ = OCC[node.occurrence] ?? ''
// ── elementRef ──────────────────────────────────────────────────
if (node.type === 'elementRef') {
return `${pad}${h.elem(node.name)}${h.occ(occ)}`
}
// ── pcdata (#PCDATA = 文本内容) ─────────────────────────────────
if (node.type === 'pcdata') {
return `${pad}${h.pcdata()}`
}
if (!node.children?.length) return ''
// ── sequence ────────────────────────────────────────────────────
if (node.type === 'sequence') {
// 足够短 → 单行
if (canInline(node)) {
const body = node.children.map((c: any) => toInlineHtml(c)).join(h.comma())
return `${pad}${h.punc('(')}${body}${h.punc(')')}${h.occ(occ)}`
}
// 多行:每个子节点一行,末尾加逗号(最后一个不加)
const childLines = node.children.map((c: any, i: number) => {
const line = formatNodeHtml(c, indent + 1)
const isLast = i === node.children.length - 1
return isLast ? line : line + h.comma()
})
const needWrap = indent > 0 || occ !== ''
if (needWrap) {
return [`${pad}${h.punc('(')}`, ...childLines, `${pad}${h.punc(')')}${h.occ(occ)}`].join('\n')
}
return childLines.join('\n')
}
// ── choice / mixed ──────────────────────────────────────────────
if (node.type === 'choice' || node.type === 'mixed') {
// 足够短 → 单行
if (canInline(node)) {
const body = node.children.map((c: any) => toInlineHtml(c)).join(h.pipe())
return `${pad}${h.punc('(')}${body}${h.punc(')')}${h.occ(occ)}`
}
// 多行:第一个子节点无 | 前缀,后续每个以 | 开头
const finalLines = node.children.map((c: any, i: number) => {
const childHtml = formatNodeHtml(c, 0).trimStart() // 不带缩进生成内容
const innerPad = ' '.repeat(indent + 1)
const prefix = i === 0 ? `${innerPad} ` : `${innerPad}${h.punc('|')} `
// 多行子节点:只给第一行加前缀,其余行额外缩进对齐
const subLines = childHtml.split('\n')
const firstLine = `${prefix}${subLines[0]}`
if (subLines.length === 1) return firstLine
const extraPad = ' '.repeat(indent + 2)
const rest = subLines.slice(1).map((l: string) => `${extraPad}${l.trimStart()}`)
return [firstLine, ...rest].join('\n')
})
return [`${pad}${h.punc('(')}`, ...finalLines, `${pad}${h.punc(')')}${h.occ(occ)}`].join('\n')
}
return ''
}
// 内联渲染(返回 HTML,无前置空格)
const toInlineHtml = (node: any): string => {
if (!node) return ''
const occ = OCC[node.occurrence] ?? ''
if (node.type === 'elementRef') return `${h.elem(node.name)}${h.occ(occ)}`
if (node.type === 'pcdata') return h.pcdata()
if (!node.children?.length) return ''
const sep = node.type === 'sequence' ? h.comma() : h.pipe()
const body = node.children.map(toInlineHtml).join(sep)
return `${h.punc('(')}${body}${h.punc(')')}${h.occ(occ)}`
}
/** /**
* CheckRuleModal 逻辑 Hook * CheckRuleModal 逻辑 Hook
*/ */
export function useCheckRuleModal() { export function useCheckRuleModal() {
// 语法高亮:关键字 + 标签名(使用全局主题样式,禁止硬编码 Hex) const formattedRule = computed(() => {
const highlightedRule = computed(() => { if (!checkRuleData.value.parsed) {
// 回退到基于正则的简单高亮
const text = checkRuleData.value.humanReadable || checkRuleData.value.rawModel const text = checkRuleData.value.humanReadable || checkRuleData.value.rawModel
return text return text
.replace(/\|/g, '<span class="text-primary font-bold">|</span>') .replace(/\|/g, '<span class="text-color3 font-bold">|</span>')
.replace(/[?*+]/g, '<span class="text-warning">$&</span>') .replace(/[?*+]/g, '<span class="text-warning">$&</span>')
.replace(/[()]/g, '<span class="text-color3">$&</span>') .replace(/[()]/g, '<span class="text-color3">$&</span>')
.replace(/([A-Z][A-Z0-9\-]*)/g, '<span class="text-success font-semibold">$1</span>') .replace(/([A-Z][A-Z0-9\-]*)/g, '<span class="text-primary font-semibold">$1</span>')
.replace(/#PCDATA/g, '<span class="text-danger">#PCDATA</span>') .replace(/#PCDATA/g, '<span class="text-danger font-semibold">#PCDATA</span>')
}
return formatNodeHtml(checkRuleData.value.parsed, 0)
}) })
return { return {
highlightedRule formattedRule
} }
} }
<template> <template>
<CommonModal v-model="checkRuleVisible" :title="`查看节点规则:${checkRuleData.nodeName}`" :width="640" :show-confirm="false" cancel-text="关闭"> <CommonModal v-model="checkRuleVisible" :title="`查看节点规则:${checkRuleData.nodeName}`" :width="800" :show-confirm="false" cancel-text="关闭">
<!-- 原始内容模型 --> <!-- 原始内容模型单行摘要(类似于 n-alert 样式的简短栏) -->
<div class="px-3 py-2 rounded bg-fill-2 border border-divider font-mono text-xs text-color2 leading-relaxed break-all"> <div class="px-4 py-3 rounded-lg bg-fill-2 border border-divider font-mono text-xs text-color2 leading-relaxed break-all select-all">
{{ checkRuleData.rawModel }} {{ checkRuleData.rawModel }}
</div> </div>
<!-- 人类可读的格式化规则 --> <!-- 精致语法树排版与彩色高亮代码框 -->
<div class="rounded overflow-hidden border border-divider"> <div class="rounded-lg overflow-hidden border border-divider bg-fill-4">
<div class="bg-fill-3 p-4 font-mono text-xs text-color1 leading-loose whitespace-pre"> <div class="p-4 font-mono text-xs text-color1 leading-loose whitespace-pre overflow-x-auto max-h-[450px] scrollbar-thin">
<span v-html="highlightedRule"></span> <span v-html="formattedRule"></span>
</div> </div>
</div> </div>
</CommonModal> </CommonModal>
...@@ -18,5 +18,5 @@ ...@@ -18,5 +18,5 @@
import { checkRuleVisible, checkRuleData } from '../../functionals' import { checkRuleVisible, checkRuleData } from '../../functionals'
import { useCheckRuleModal } from './functionals' import { useCheckRuleModal } from './functionals'
const { highlightedRule } = useCheckRuleModal() const { formattedRule } = useCheckRuleModal()
</script> </script>
<template>
<CommonModal v-model="viewXmlVisible" :title="viewXmlTitle" :width="900" :show-confirm="false" cancel-text="关闭">
<div class="flex flex-col space-y-4">
<!-- XML 渲染面板,带优雅网格背景与代码字体 -->
<div
class="rounded-xl overflow-hidden border border-divider shadow-lg relative bg-fill-4"
style="background-image: radial-gradient(circle, rgba(0,0,0,0.02) 1px, transparent 1px); background-size: 16px 16px;"
>
<!-- 磨砂玻璃质感的顶部装饰条 -->
<div class="h-8 bg-fill-3 border-b border-divider flex items-center px-4 space-x-1.5 select-none shrink-0">
<div class="w-3 h-3 rounded-full bg-red-500/80"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500/80"></div>
<div class="w-3 h-3 rounded-full bg-green-500/80"></div>
</div>
<div class="p-6 font-mono text-sm leading-relaxed overflow-x-auto max-h-[500px] scrollbar-thin select-all">
<pre class="text-color1"><code class="xml-content-pre">{{ viewXmlContent }}</code></pre>
</div>
</div>
</div>
<template #footer-extra>
<CommonButton type="primary" @click="handleCopyXml">
<template #icon>
<n-icon><clipboard-outline /></n-icon>
</template>
一键复制
</CommonButton>
</template>
</CommonModal>
</template>
<script setup lang="ts">
import { ClipboardOutline } from '@vicons/ionicons5'
import { viewXmlVisible, viewXmlTitle, viewXmlContent } from '../../functionals'
const handleCopyXml = async () => {
try {
await navigator.clipboard.writeText(viewXmlContent.value)
window.$message?.success('XML 片段已成功复制到剪贴板')
} catch (err) {
window.$message?.error('复制失败,请手动选择复制')
}
}
</script>
<style scoped>
.xml-content-pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
white-space: pre-wrap;
word-break: break-all;
}
</style>
import type { XmlNode } from '@/types/xmlNode'
// 列表/文档型叶子节点标签判定 // 列表/文档型叶子节点标签判定
export const DOCUMENT_LIKE_TAGS = ['PARA', 'PARAC', 'TITLE', 'TITLEC', 'WARNING', 'CAUTION', 'NOTE'] export const DOCUMENT_LIKE_TAGS = ['PARA', 'PARAC', 'TITLE', 'TITLEC', 'WARNING', 'CAUTION', 'NOTE']
...@@ -6,6 +8,7 @@ export interface CheckRuleData { ...@@ -6,6 +8,7 @@ export interface CheckRuleData {
nodeName: string nodeName: string
rawModel: string rawModel: string
humanReadable: string humanReadable: string
parsed?: any
} }
// 插入节点模式类型 // 插入节点模式类型
...@@ -19,3 +22,14 @@ export interface AttrDef { ...@@ -19,3 +22,14 @@ export interface AttrDef {
required: boolean required: boolean
defaultValue: string | null defaultValue: string | null
} }
// 树节点扁平化数据接口定义
export interface FlatNode {
id: string
tagName: string
subtitle: string
depth: number
hasChildren: boolean
isExpanded: boolean
rawNode: XmlNode
}
...@@ -10,12 +10,16 @@ import { ...@@ -10,12 +10,16 @@ import {
EyeOutline, EyeOutline,
ClipboardOutline, ClipboardOutline,
SaveOutline, SaveOutline,
BuildOutline BuildOutline,
FolderOpenOutline,
DocumentTextOutline,
CodeWorkingOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { getAllowedChildren, getInsertableChildren, canDeleteChild, getElementRule } from '@/utils/dtdManager' import { serializeTreeToXml } from '@/utils/xmlParser'
import type { CheckRuleData, InsertMode } from '../constants' import type { CheckRuleData, InsertMode, FlatNode } from '../constants'
import { DOCUMENT_LIKE_TAGS } from '../constants'
// ══════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════
// 全局共享状态:查看规则弹窗 // 全局共享状态:查看规则弹窗
...@@ -28,6 +32,13 @@ export const checkRuleData = ref<CheckRuleData>({ ...@@ -28,6 +32,13 @@ export const checkRuleData = ref<CheckRuleData>({
}) })
// ══════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════
// 全局共享状态:查看XML片段弹窗
// ══════════════════════════════════════════════════════════
export const viewXmlVisible = ref(false)
export const viewXmlTitle = ref('')
export const viewXmlContent = ref('')
// ══════════════════════════════════════════════════════════
// 全局共享状态:添加子节点 / 插入兄弟节点弹窗 // 全局共享状态:添加子节点 / 插入兄弟节点弹窗
// ══════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════
export const addNodeVisible = ref(false) export const addNodeVisible = ref(false)
...@@ -46,19 +57,428 @@ const icon = (component: any) => () => h(NIcon, null, { default: () => h(compone ...@@ -46,19 +57,428 @@ const icon = (component: any) => () => h(NIcon, null, { default: () => h(compone
/** /**
* NodeTree 组件核心逻辑 Hook * NodeTree 组件核心逻辑 Hook
*/ */
export function useNodeTree() { export function useNodeTree(
const store = useEditorStore() props: { expandedKeys: string[] },
emit: (event: 'update:expandedKeys', keys: string[]) => void,
onOpenInsertFragment?: (mode: 'above' | 'below' | 'inside', nodeId: string) => void
) {
const editorStore = useEditorStore()
const pattern = ref('')
const isTreeSelecting = ref(false)
const ITEM_HEIGHT = 32
const viewportRef = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(400)
// 下拉菜单状态
const showDropdown = ref(false)
const dropdownX = ref(0)
const dropdownY = ref(0)
const contextNodeId = ref<string | null>(null)
// 内部管理的展开状态 Set
const expandedKeys = ref<Set<string>>(new Set())
// 将外部传入的展开状态同步到 Set
watch(
() => props.expandedKeys,
(keys) => {
expandedKeys.value = new Set(keys)
},
{ deep: true }
)
// 辅助:获取所有有子节点的节点 ID
const collectAllExpandableKeys = (node: XmlNode): string[] => {
const keys: string[] = []
const walk = (n: XmlNode) => {
if (n.children && n.children.length > 0) {
keys.push(n.id)
n.children.forEach(walk)
}
}
walk(node)
return keys
}
// 默认全展开(仅加载新文档或文档 ID 发生改变时执行)
watch(
() => editorStore.xmlTree,
(newVal, oldVal) => {
if (newVal) {
if (!oldVal || newVal.id !== oldVal.id) {
const keys = collectAllExpandableKeys(newVal)
expandedKeys.value = new Set(keys)
emit('update:expandedKeys', keys)
}
}
},
{ immediate: true }
)
// 监听滚动
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
scrollTop.value = target.scrollTop
}
// 监听 resize 或初始化高度
onMounted(() => {
if (viewportRef.value) {
viewportHeight.value = viewportRef.value.clientHeight
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
viewportHeight.value = entry.contentRect.height
}
})
observer.observe(viewportRef.value)
}
})
// 搜索匹配检查
const matchNode = (node: XmlNode, search: string): boolean => {
if (!search) return true
const term = search.toLowerCase()
if (node.tagName.toLowerCase().includes(term)) return true
if (node.attributes.ID && node.attributes.ID.toLowerCase().includes(term)) return true
if (node.attributes.EFFECT && node.attributes.EFFECT.toLowerCase().includes(term)) return true
if (node.attributes.EFFRG && node.attributes.EFFRG.toLowerCase().includes(term)) return true
if (node.attributes.SBCOND && node.attributes.SBCOND.toLowerCase().includes(term)) return true
if (node.attributes.SBNBR && node.attributes.SBNBR.toLowerCase().includes(term)) return true
if (node.textContent && node.textContent.toLowerCase().includes(term)) return true
return node.children.some((child) => matchNode(child, search))
}
// 递归获取节点的文本预览,支持混合内容与行内子节点拼接
const getNodeTextPreview = (node: XmlNode): string => {
const STRUCTURAL_TAGS = new Set([
'JOBCARD',
'CEP',
'TFMATR',
'PRETOPIC',
'TOPIC',
'SUBTASK',
'LIST1',
'L1ITEM',
'LIST2',
'L2ITEM',
'LIST3',
'L3ITEM',
'LIST4',
'L4ITEM',
'LIST5',
'L5ITEM',
'UNLIST',
'UNLITEM',
'NUMLIST',
'NUMLITEM',
'TABLE',
'TGROUP',
'TBODY',
'THEAD',
'ROW',
'ENTRY',
'WARNING',
'CAUTION',
'NOTE',
'CBSUBLST'
])
if (STRUCTURAL_TAGS.has(node.tagName)) {
return ''
}
const getFullText = (n: XmlNode): string => {
if (n.textContent && n.textContent.trim()) {
return n.textContent.trim()
}
if (n.mixedContent && n.mixedContent.length > 0) {
return n.mixedContent
.map((item) => {
if (item.type === 'text') {
return item.text || ''
} else if (item.type === 'element' && item.nodeId) {
const child = n.children.find((c) => c.id === item.nodeId)
return child ? getFullText(child) : ''
}
return ''
})
.join('')
}
return ''
}
const text = getFullText(node).trim()
return text.length > 60 ? text.substring(0, 60) + '...' : text
}
// 递归构建扁平列表
const buildFlatList = (node: XmlNode, depth = 0, search = ''): FlatNode[] => {
if (search && !matchNode(node, search)) {
return []
}
const list: FlatNode[] = []
const hasChildren = node.children && node.children.length > 0
const isExpanded = expandedKeys.value.has(node.id)
let subtitle = ''
const formatEff = (eff: string | undefined): string => {
if (!eff) return 'ALL'
const cleaned = eff.replace(/\s+/g, '')
if (cleaned === '001999') return 'ALL'
if (cleaned.length === 6) {
return `${cleaned.substring(0, 3)}-${cleaned.substring(3)}`
}
return cleaned
}
if (node.tagName === 'EFFECT') {
subtitle = `ON A/C: ${formatEff(node.attributes.EFFRG || node.textContent)}`
} else if (node.tagName === 'CONEFFECT') {
subtitle = `CONF: ${formatEff(node.attributes.EFFRG || node.textContent)}`
} else if (node.tagName === 'SBEFF' || node.tagName === 'SBEFFC') {
subtitle = `SB: ${node.attributes.SBCOND || ''} SB ${node.attributes.SBNBR || ''} for A/C ${formatEff(node.attributes.EFFRG)}`
} else if (node.tagName === 'CBLST') {
const action = node.attributes.ACTION === 'verif-close' ? '确认关闭' : node.attributes.ACTION === 'open' ? '断开' : '操作'
subtitle = `行动: ${action}`
} else if (node.tagName === 'UNIT-RECORD') {
const text = node.textContent ? node.textContent.trim() : ''
subtitle = `${text} (单位: ${node.attributes.UNIT || 'mm'})`
} else if (node.tagName === 'SIGNOFF') {
subtitle = `Tag: ${node.attributes.TAG || '签字'}`
} else if (node.attributes.ID) {
subtitle = node.attributes.ID
} else if (node.attributes.EFFRG) {
subtitle = `A/C: ${formatEff(node.attributes.EFFRG)}`
} else if (node.attributes.EFFECT) {
subtitle = node.attributes.EFFECT
} else {
subtitle = getNodeTextPreview(node)
}
list.push({
id: node.id,
tagName: node.tagName,
subtitle,
depth,
hasChildren,
isExpanded,
rawNode: node
})
// 如果处于搜索状态,强制展开展示搜索结果;否则根据 isExpanded 展开
if (hasChildren && (isExpanded || search)) {
for (const child of node.children) {
list.push(...buildFlatList(child, depth + 1, search))
}
}
return list
}
// 扁平列表数据
const flatList = computed(() => {
if (!editorStore.xmlTree) return []
return buildFlatList(editorStore.xmlTree, 0, pattern.value)
})
const totalHeight = computed(() => {
return flatList.value.length * ITEM_HEIGHT + 200 // 增加 200px 底部留白,方便最下方节点操作与右键菜单弹出
})
const startIndex = computed(() => {
return Math.max(0, Math.floor(scrollTop.value / ITEM_HEIGHT) - 5)
})
const endIndex = computed(() => {
return Math.min(flatList.value.length, Math.ceil((scrollTop.value + viewportHeight.value) / ITEM_HEIGHT) + 5)
})
const visibleItems = computed(() => {
return flatList.value.slice(startIndex.value, endIndex.value)
})
const startOffset = computed(() => {
const pos = flatList.value
return pos.length === 0 || startIndex.value >= pos.length ? 0 : startIndex.value * ITEM_HEIGHT
})
// === 计算连接虚线 ===
const verticalLines = computed(() => {
const lines: Array<{ key: string; left: number; top: number; height: number }> = []
const nodes = flatList.value
if (nodes.length === 0) return lines
nodes.forEach((node, index) => {
// 如果节点有子节点且处于展开状态,绘制向下连接其子节点的虚线
if (node.hasChildren && node.isExpanded) {
const childLevel = node.depth + 1
let firstChildIndex = -1
let lastChildIndex = -1
for (let i = index + 1; i < nodes.length; i++) {
if (nodes[i].depth < childLevel) {
break
}
if (nodes[i].depth === childLevel) {
if (firstChildIndex === -1) {
firstChildIndex = i
}
lastChildIndex = i
}
}
if (firstChildIndex !== -1 && lastChildIndex !== -1) {
const lineLeft = childLevel * 20 + 8
const lineTop = (index + 0.5) * ITEM_HEIGHT
const lineHeight = (lastChildIndex - index) * ITEM_HEIGHT
lines.push({
key: `${node.id}-vline`,
left: lineLeft,
top: lineTop,
height: lineHeight
})
}
}
})
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
})
})
// 展开/折叠逻辑
const toggleExpand = (id: string) => {
if (expandedKeys.value.has(id)) {
expandedKeys.value.delete(id)
} else {
expandedKeys.value.add(id)
}
expandedKeys.value = new Set(expandedKeys.value)
emit('update:expandedKeys', Array.from(expandedKeys.value))
}
// 选中逻辑
const handleSelect = (id: string) => {
isTreeSelecting.value = true
editorStore.setSelectedNodeId(id)
setTimeout(() => {
isTreeSelecting.value = false
}, 100)
}
// 获取节点图标
const getNodeIcon = (item: FlatNode) => {
if (item.hasChildren) {
return FolderOpenOutline
} else if (DOCUMENT_LIKE_TAGS.includes(item.tagName)) {
return DocumentTextOutline
}
return CodeWorkingOutline
}
// 搜索高亮逻辑
const highlightText = (text: string, keyword: string): string => {
if (!keyword || !text) return text
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${escaped})`, 'gi')
return text.replace(regex, '<mark class="highlight-mark">$1</mark>')
}
// 树展开与定位同步辅助函数
const syncTreeSelection = (newId: string | null) => {
if (!newId) return
// 自动展开其所有祖先节点
const path: XmlNode[] = []
let curr = editorStore.nodeMap.get(newId)
while (curr) {
path.unshift(curr.node)
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
}
if (path.length > 0) {
let changed = false
for (let i = 0; i < path.length - 1; i++) {
const ancestorId = path[i].id
if (!expandedKeys.value.has(ancestorId)) {
expandedKeys.value.add(ancestorId)
changed = true
}
}
if (changed) {
expandedKeys.value = new Set(expandedKeys.value)
emit('update:expandedKeys', Array.from(expandedKeys.value))
}
}
// 自动滚动定位到视口中央
nextTick(() => {
if (isTreeSelecting.value) {
return
}
const idx = flatList.value.findIndex((item) => item.id === newId)
if (idx !== -1 && viewportRef.value) {
const itemTop = idx * ITEM_HEIGHT
const vHeight = viewportRef.value.clientHeight
viewportRef.value.scrollTo({
top: Math.max(0, itemTop - vHeight / 2),
behavior: 'auto'
})
}
})
}
// 监听选中的节点,进行树组件的自动展开与滚动定位
watch(() => editorStore.selectedNodeId, syncTreeSelection)
// 监听回退/重做以同步树的展开与定位
watch(
() => editorStore.lastUndoRedoTime,
() => {
syncTreeSelection(editorStore.selectedNodeId)
}
)
// 监听数据树重解析同步更新,自动同步展开并定位当前选中
watch(
() => editorStore.xmlTree,
() => {
nextTick(() => {
syncTreeSelection(editorStore.selectedNodeId)
})
}
)
// ── 生成右键下拉菜单选项 ────────────────────────────── // ── 生成右键下拉菜单选项 ──────────────────────────────
const getDropdownOptions = (nodeId: string): DropdownOption[] => { const getDropdownOptions = (nodeId: string): DropdownOption[] => {
const tree = store.xmlTree const tree = editorStore.xmlTree
if (!tree) return [] if (!tree) return []
const item = store.nodeMap.get(nodeId) const item = editorStore.nodeMap.get(nodeId)
if (!item) return [] if (!item) return []
const { node, parent } = item const { node, parent } = item
const options: DropdownOption[] = [] const options: DropdownOption[] = []
// 查看XML
options.push({
label: '查看XML',
key: 'viewXml',
icon: icon(CodeWorkingOutline)
})
// 查看规则 // 查看规则
options.push({ options.push({
label: '查看规则', label: '查看规则',
...@@ -95,12 +515,25 @@ export function useNodeTree() { ...@@ -95,12 +515,25 @@ export function useNodeTree() {
}) })
} }
// 粘贴XML片段
const pasteFragmentChildren: DropdownOption[] = [
{ label: '粘贴到上方', key: 'pasteFragmentAbove', icon: icon(ClipboardOutline), disabled: !parent },
{ label: '粘贴到下方', key: 'pasteFragmentBelow', icon: icon(ClipboardOutline), disabled: !parent },
{ label: '粘贴到内部', key: 'pasteFragmentInside', icon: icon(ClipboardOutline) }
]
options.push({
label: '粘贴XML片段',
key: 'pasteXmlFragment',
icon: icon(ClipboardOutline),
children: pasteFragmentChildren
})
// 删除节点(受 DTD 约束) // 删除节点(受 DTD 约束)
if (parent) { if (parent) {
const childCount = parent.children.filter((c) => c.tagName === node.tagName).length const childCount = parent.children.filter((c) => c.tagName === node.tagName).length
const deletable = canDeleteChild(parent.tagName, node.tagName, childCount) const deletable = canDeleteChild(parent.tagName, node.tagName, childCount)
options.push({ options.push({
label: deletable ? '删除节点' : '删除节点 (受 DTD 约束)', label: deletable ? '删除节点' : '删除节点',
key: 'deleteNode', key: 'deleteNode',
icon: icon(TrashOutline), icon: icon(TrashOutline),
disabled: !deletable disabled: !deletable
...@@ -161,20 +594,30 @@ export function useNodeTree() { ...@@ -161,20 +594,30 @@ export function useNodeTree() {
// ── 处理菜单动作 ────────────────────────────────────── // ── 处理菜单动作 ──────────────────────────────────────
const handleDropdownAction = async (key: string, nodeId: string): Promise<void> => { const handleDropdownAction = async (key: string, nodeId: string): Promise<void> => {
const tree = store.xmlTree const tree = editorStore.xmlTree
if (!tree) return if (!tree) return
const item = store.nodeMap.get(nodeId) const item = editorStore.nodeMap.get(nodeId)
if (!item) return if (!item) return
const { node, parent } = item const { node, parent } = item
switch (key) { switch (key) {
// ── 查看XML ──
case 'viewXml': {
const xmlFragment = serializeTreeToXml(node)
viewXmlTitle.value = `查看节点 [${node.tagName}] 的 XML 片段`
viewXmlContent.value = xmlFragment
viewXmlVisible.value = true
break
}
// ── 查看规则 ── // ── 查看规则 ──
case 'checkRule': { case 'checkRule': {
const rule = getElementRule(node.tagName) const rule = getElementRule(node.tagName)
checkRuleData.value = { checkRuleData.value = {
nodeName: node.tagName, nodeName: node.tagName,
rawModel: rule?.contentModel.raw || '(#PCDATA)', rawModel: rule?.contentModel.raw || '(#PCDATA)',
humanReadable: rule?.contentModel.humanReadable || '' humanReadable: rule?.contentModel.humanReadable || '',
parsed: rule?.contentModel.parsed || null
} }
checkRuleVisible.value = true checkRuleVisible.value = true
break break
...@@ -201,9 +644,10 @@ export function useNodeTree() { ...@@ -201,9 +644,10 @@ export function useNodeTree() {
if (!copyNodeCache.value || !parent) break if (!copyNodeCache.value || !parent) break
const cloned = deepCloneWithNewIds(copyNodeCache.value, parent.id) const cloned = deepCloneWithNewIds(copyNodeCache.value, parent.id)
const idx = parent.children.findIndex((c) => c.id === nodeId) const idx = parent.children.findIndex((c) => c.id === nodeId)
store.saveSnapshot() editorStore.saveSnapshot()
parent.children.splice(idx, 0, cloned) parent.children.splice(idx, 0, cloned)
store.setSelectedNodeId(cloned.id) editorStore.rebuildNodeMap()
editorStore.setSelectedNodeId(cloned.id)
window.$message?.success('粘贴成功') window.$message?.success('粘贴成功')
break break
} }
...@@ -213,9 +657,10 @@ export function useNodeTree() { ...@@ -213,9 +657,10 @@ export function useNodeTree() {
if (!copyNodeCache.value || !parent) break if (!copyNodeCache.value || !parent) break
const cloned = deepCloneWithNewIds(copyNodeCache.value, parent.id) const cloned = deepCloneWithNewIds(copyNodeCache.value, parent.id)
const idx = parent.children.findIndex((c) => c.id === nodeId) const idx = parent.children.findIndex((c) => c.id === nodeId)
store.saveSnapshot() editorStore.saveSnapshot()
parent.children.splice(idx + 1, 0, cloned) parent.children.splice(idx + 1, 0, cloned)
store.setSelectedNodeId(cloned.id) editorStore.rebuildNodeMap()
editorStore.setSelectedNodeId(cloned.id)
window.$message?.success('粘贴成功') window.$message?.success('粘贴成功')
break break
} }
...@@ -224,13 +669,38 @@ export function useNodeTree() { ...@@ -224,13 +669,38 @@ export function useNodeTree() {
case 'pasteInside': { case 'pasteInside': {
if (!copyNodeCache.value) break if (!copyNodeCache.value) break
const cloned = deepCloneWithNewIds(copyNodeCache.value, nodeId) const cloned = deepCloneWithNewIds(copyNodeCache.value, nodeId)
store.saveSnapshot() editorStore.saveSnapshot()
node.children.push(cloned) node.children.push(cloned)
store.setSelectedNodeId(cloned.id) editorStore.rebuildNodeMap()
editorStore.setSelectedNodeId(cloned.id)
window.$message?.success('粘贴成功') window.$message?.success('粘贴成功')
break break
} }
// ── 粘贴XML片段到上方 ──
case 'pasteFragmentAbove': {
if (onOpenInsertFragment) {
onOpenInsertFragment('above', nodeId)
}
break
}
// ── 粘贴XML片段到下方 ──
case 'pasteFragmentBelow': {
if (onOpenInsertFragment) {
onOpenInsertFragment('below', nodeId)
}
break
}
// ── 粘贴XML片段到内部 ──
case 'pasteFragmentInside': {
if (onOpenInsertFragment) {
onOpenInsertFragment('inside', nodeId)
}
break
}
// ── 删除节点 ── // ── 删除节点 ──
case 'deleteNode': { case 'deleteNode': {
if (nodeId === tree.id) { if (nodeId === tree.id) {
...@@ -242,7 +712,7 @@ export function useNodeTree() { ...@@ -242,7 +712,7 @@ export function useNodeTree() {
title: '确认删除', title: '确认删除',
content: `确定要删除节点 <${node.tagName}> 吗?该操作将连带删除其所有子节点,且不可撤销!` content: `确定要删除节点 <${node.tagName}> 吗?该操作将连带删除其所有子节点,且不可撤销!`
}) })
store.deleteSelectedNode() editorStore.deleteSelectedNode()
window.$message?.success('删除成功') window.$message?.success('删除成功')
} catch { } catch {
// 取消 // 取消
...@@ -312,8 +782,90 @@ export function useNodeTree() { ...@@ -312,8 +782,90 @@ export function useNodeTree() {
return cloned return cloned
} }
// 右键下拉菜单数据
const dropdownOptions = computed<DropdownOption[]>(() => {
if (!contextNodeId.value) return []
return getDropdownOptions(contextNodeId.value)
})
const handleContextMenu = (e: MouseEvent, item: FlatNode) => {
showDropdown.value = false
contextNodeId.value = item.id
nextTick(() => {
dropdownX.value = e.clientX
dropdownY.value = e.clientY
showDropdown.value = true
})
}
const handleDropdownSelect = async (key: string) => {
showDropdown.value = false
if (!contextNodeId.value) return
await handleDropdownAction(key, contextNodeId.value)
// 如果添加了节点,确保父级节点展开状态
if (key.startsWith('add-child-')) {
if (!expandedKeys.value.has(contextNodeId.value)) {
expandedKeys.value.add(contextNodeId.value)
expandedKeys.value = new Set(expandedKeys.value)
emit('update:expandedKeys', Array.from(expandedKeys.value))
}
}
}
const hasCustomColor = (item: FlatNode): boolean => {
const tag = item.tagName.toUpperCase()
return ['WARNING', 'EFFECT', 'CONEFFECT', 'SBEFF', 'SBEFFC', 'CAUTION', 'NOTE', 'REFBLOCK', 'REFINT', 'REFEXT', 'GRPHCREF', 'EIN'].includes(
tag
)
}
const getNodeStyle = (item: FlatNode) => {
if (editorStore.selectedNodeId === item.id) {
return {}
}
const tag = item.tagName.toUpperCase()
if (['WARNING', 'EFFECT', 'CONEFFECT', 'SBEFF', 'SBEFFC'].includes(tag)) {
return { color: 'red' }
}
if (['CAUTION'].includes(tag)) {
return { color: '#ff6a00' }
}
if (['NOTE', 'REFBLOCK', 'REFINT', 'REFEXT', 'GRPHCREF', 'EIN'].includes(tag)) {
return { color: 'blue' }
}
return {}
}
return { return {
getDropdownOptions, pattern,
handleDropdownAction isTreeSelecting,
ITEM_HEIGHT,
viewportRef,
scrollTop,
viewportHeight,
showDropdown,
dropdownX,
dropdownY,
contextNodeId,
expandedKeys,
flatList,
totalHeight,
startIndex,
endIndex,
visibleItems,
startOffset,
visibleVerticalLines,
dropdownOptions,
toggleExpand,
handleSelect,
getNodeIcon,
highlightText,
handleContextMenu,
handleDropdownSelect,
handleScroll,
hasCustomColor,
getNodeStyle
} }
} }
...@@ -10,11 +10,7 @@ ...@@ -10,11 +10,7 @@
</div> </div>
<!-- 虚拟滚动树组件容器 --> <!-- 虚拟滚动树组件容器 -->
<div <div ref="viewportRef" class="flex-1 overflow-y-auto p-2 relative select-none virtual-tree-container" @scroll="handleScroll">
ref="viewportRef"
class="flex-1 overflow-y-auto p-2 relative select-none virtual-tree-container"
@scroll="handleScroll"
>
<div v-if="flatList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }"> <div v-if="flatList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 背景连接线层 - 绘制连续的垂直虚线 --> <!-- 背景连接线层 - 绘制连续的垂直虚线 -->
<div class="tree-lines-layer"> <div class="tree-lines-layer">
...@@ -31,10 +27,7 @@ ...@@ -31,10 +27,7 @@
</div> </div>
<!-- 可见列表节点 --> <!-- 可见列表节点 -->
<div <div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0 space-y-0.5">
:style="{ transform: `translateY(${startOffset}px)` }"
class="absolute top-0 left-0 right-0 space-y-0.5"
>
<div <div
v-for="item in visibleItems" v-for="item in visibleItems"
:key="item.id" :key="item.id"
...@@ -42,12 +35,17 @@ ...@@ -42,12 +35,17 @@
:class="[ :class="[
editorStore.selectedNodeId === item.id editorStore.selectedNodeId === item.id
? 'bg-primary text-white tree-node-selected' ? 'bg-primary text-white tree-node-selected'
: hasCustomColor(item)
? 'hover:bg-fill-3'
: 'hover:bg-fill-3 text-color2' : 'hover:bg-fill-3 text-color2'
]" ]"
:style="{ :style="[
paddingLeft: (item.depth * 20 + 8) + 'px', {
paddingLeft: item.depth * 20 + 8 + 'px',
'--tree-level': item.depth '--tree-level': item.depth
}" },
getNodeStyle(item)
]"
@click="handleSelect(item.id)" @click="handleSelect(item.id)"
@contextmenu.prevent="(e) => handleContextMenu(e, item)" @contextmenu.prevent="(e) => handleContextMenu(e, item)"
> >
...@@ -55,9 +53,7 @@ ...@@ -55,9 +53,7 @@
<div <div
v-if="item.hasChildren" v-if="item.hasChildren"
class="w-4 h-4 flex items-center justify-center mr-1 text-color3 hover:text-color1 cursor-pointer transition-colors z-10" class="w-4 h-4 flex items-center justify-center mr-1 text-color3 hover:text-color1 cursor-pointer transition-colors z-10"
:class="[ :class="[editorStore.selectedNodeId === item.id ? 'text-white/80 hover:text-white' : 'text-primary']"
editorStore.selectedNodeId === item.id ? 'text-white/80 hover:text-white' : 'text-primary'
]"
@click.stop="toggleExpand(item.id)" @click.stop="toggleExpand(item.id)"
> >
<!-- 展开状态:减号 --> <!-- 展开状态:减号 -->
...@@ -74,24 +70,26 @@ ...@@ -74,24 +70,26 @@
<div v-else class="w-4 h-4 mr-1"></div> <div v-else class="w-4 h-4 mr-1"></div>
<!-- Icon --> <!-- Icon -->
<div class="mr-1.5 flex items-center justify-center shrink-0 z-10" :class="[editorStore.selectedNodeId === item.id ? 'text-white' : 'text-primary']"> <div
class="mr-1.5 flex items-center justify-center shrink-0 z-10"
:class="[editorStore.selectedNodeId === item.id ? 'text-white' : '']"
>
<n-icon size="16"> <n-icon size="16">
<component :is="getNodeIcon(item)" /> <component :is="getNodeIcon(item)" />
</n-icon> </n-icon>
</div> </div>
<!-- Label & Subtitle --> <!-- Label & Subtitle -->
<div class="flex-1 min-w-0 flex items-center space-x-1 z-10"> <div class="flex-1 min-w-0 flex items-center space-x-2 z-10">
<!-- 节点名称高亮 --> <!-- 节点名称高亮 -->
<span <span class="font-bold text-sm flex-shrink-0" v-html="highlightText(item.tagName, pattern)"></span>
class="font-bold text-sm truncate"
v-html="highlightText(item.tagName, pattern)"
></span>
<!-- 子标题高亮 --> <!-- 子标题高亮 -->
<span <span
v-if="item.subtitle" v-if="item.subtitle"
class="text-xs italic truncate" class="text-xs truncate"
:class="[editorStore.selectedNodeId === item.id ? 'text-white/70' : 'text-color3']" :class="[
editorStore.selectedNodeId === item.id ? 'text-white/70' : hasCustomColor(item) ? 'opacity-80' : 'text-color3'
]"
v-html="highlightText(item.subtitle, pattern)" v-html="highlightText(item.subtitle, pattern)"
></span> ></span>
</div> </div>
...@@ -118,26 +116,25 @@ ...@@ -118,26 +116,25 @@
<!-- 查看规则弹窗 --> <!-- 查看规则弹窗 -->
<CheckRuleModal /> <CheckRuleModal />
<!-- 查看XML片段弹窗 -->
<ViewXmlModal />
<!-- 添加/插入节点弹窗 --> <!-- 添加/插入节点弹窗 -->
<AddNodeModal /> <AddNodeModal />
<!-- 插入 XML 片段弹窗 -->
<InsertFragmentModal ref="insertFragmentModalRef" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownOption } from 'naive-ui' import { SearchOutline } from '@vicons/ionicons5'
import { NIcon } from 'naive-ui'
import {
SearchOutline,
CodeWorkingOutline,
DocumentTextOutline,
FolderOpenOutline
} from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { useNodeTree } from './functionals' import { useNodeTree } from './functionals'
import { DOCUMENT_LIKE_TAGS } from './constants'
import type { XmlNode } from '@/types/xmlNode'
import CheckRuleModal from './components/CheckRuleModal/index.vue' import CheckRuleModal from './components/CheckRuleModal/index.vue'
import AddNodeModal from './components/AddNodeModal/index.vue' import AddNodeModal from './components/AddNodeModal/index.vue'
import ViewXmlModal from './components/ViewXmlModal/index.vue'
import InsertFragmentModal from '@/views/editor/components/EditorToolbar/components/InsertFragmentModal/index.vue'
const props = defineProps<{ const props = defineProps<{
expandedKeys: string[] expandedKeys: string[]
...@@ -146,330 +143,32 @@ const props = defineProps<{ ...@@ -146,330 +143,32 @@ const props = defineProps<{
const emit = defineEmits(['update:expandedKeys']) const emit = defineEmits(['update:expandedKeys'])
const editorStore = useEditorStore() const editorStore = useEditorStore()
const { getDropdownOptions, handleDropdownAction } = useNodeTree() const insertFragmentModalRef = ref<any>(null)
const pattern = ref('') const {
const ITEM_HEIGHT = 32 pattern,
viewportRef,
const viewportRef = ref<HTMLElement | null>(null) flatList,
const scrollTop = ref(0) totalHeight,
const viewportHeight = ref(400) visibleVerticalLines,
startOffset,
// 下拉菜单状态 visibleItems,
const showDropdown = ref(false) showDropdown,
const dropdownX = ref(0) dropdownOptions,
const dropdownY = ref(0) dropdownX,
const contextNodeId = ref<string | null>(null) dropdownY,
handleScroll,
// 内部管理的展开状态 Set handleSelect,
const expandedKeys = ref<Set<string>>(new Set()) handleContextMenu,
toggleExpand,
// 将外部传入的展开状态同步到 Set getNodeIcon,
watch(() => props.expandedKeys, (keys) => { highlightText,
expandedKeys.value = new Set(keys) handleDropdownSelect,
}, { deep: true }) hasCustomColor,
getNodeStyle
// 辅助:获取所有有子节点的节点 ID } = useNodeTree(props, emit, (mode, nodeId) => {
const collectAllExpandableKeys = (node: XmlNode): string[] => { insertFragmentModalRef.value?.open(mode, nodeId)
const keys: string[] = []
const walk = (n: XmlNode) => {
if (n.children && n.children.length > 0) {
keys.push(n.id)
n.children.forEach(walk)
}
}
walk(node)
return keys
}
// 默认全展开(仅加载新文档或文档 ID 发生改变时执行)
watch(() => editorStore.xmlTree, (newVal, oldVal) => {
if (newVal) {
if (!oldVal || newVal.id !== oldVal.id) {
const keys = collectAllExpandableKeys(newVal)
expandedKeys.value = new Set(keys)
emit('update:expandedKeys', keys)
}
}
}, { immediate: true })
// 监听滚动
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
scrollTop.value = target.scrollTop
}
// 监听 resize 或初始化高度
onMounted(() => {
if (viewportRef.value) {
viewportHeight.value = viewportRef.value.clientHeight
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
viewportHeight.value = entry.contentRect.height
}
})
observer.observe(viewportRef.value)
}
})
// 搜索匹配检查
const matchNode = (node: XmlNode, search: string): boolean => {
if (!search) return true
const term = search.toLowerCase()
if (node.tagName.toLowerCase().includes(term)) return true
if (node.attributes.ID && node.attributes.ID.toLowerCase().includes(term)) return true
if (node.attributes.EFFECT && node.attributes.EFFECT.toLowerCase().includes(term)) return true
if (node.textContent && node.textContent.toLowerCase().includes(term)) return true
return node.children.some(child => matchNode(child, search))
}
interface FlatNode {
id: string
tagName: string
subtitle: string
depth: number
hasChildren: boolean
isExpanded: boolean
rawNode: XmlNode
}
// 递归构建扁平列表
const buildFlatList = (node: XmlNode, depth = 0, search = ''): FlatNode[] => {
if (search && !matchNode(node, search)) {
return []
}
const list: FlatNode[] = []
const hasChildren = node.children && node.children.length > 0
const isExpanded = expandedKeys.value.has(node.id)
let subtitle = ''
if (node.attributes.ID) {
subtitle = ` : ${node.attributes.ID}`
} else if (node.attributes.EFFECT) {
subtitle = ` : ${node.attributes.EFFECT}`
} else if (node.textContent && node.textContent.trim().length > 0) {
const clean = node.textContent.trim()
subtitle = ` : ${clean.length > 25 ? clean.substring(0, 25) + '...' : clean}`
}
list.push({
id: node.id,
tagName: node.tagName,
subtitle,
depth,
hasChildren,
isExpanded,
rawNode: node
})
// 如果处于搜索状态,强制展开展示搜索结果;否则根据 isExpanded 展开
if (hasChildren && (isExpanded || search)) {
for (const child of node.children) {
list.push(...buildFlatList(child, depth + 1, search))
}
}
return list
}
// 扁平列表数据
const flatList = computed(() => {
if (!editorStore.xmlTree) return []
return buildFlatList(editorStore.xmlTree, 0, pattern.value)
})
const totalHeight = computed(() => {
return flatList.value.length * ITEM_HEIGHT
}) })
const startIndex = computed(() => {
return Math.max(0, Math.floor(scrollTop.value / ITEM_HEIGHT) - 5)
})
const endIndex = computed(() => {
return Math.min(
flatList.value.length,
Math.ceil((scrollTop.value + viewportHeight.value) / ITEM_HEIGHT) + 5
)
})
const visibleItems = computed(() => {
return flatList.value.slice(startIndex.value, endIndex.value)
})
const startOffset = computed(() => {
return startIndex.value * ITEM_HEIGHT
})
// === 计算连接虚线 ===
const verticalLines = computed(() => {
const lines: Array<{ key: string; left: number; top: number; height: number }> = []
const nodes = flatList.value
if (nodes.length === 0) return lines
nodes.forEach((node, index) => {
// 如果节点有子节点且处于展开状态,绘制向下连接其子节点的虚线
if (node.hasChildren && node.isExpanded) {
const childLevel = node.depth + 1
let firstChildIndex = -1
let lastChildIndex = -1
for (let i = index + 1; i < nodes.length; i++) {
if (nodes[i].depth < childLevel) {
break
}
if (nodes[i].depth === childLevel) {
if (firstChildIndex === -1) {
firstChildIndex = i
}
lastChildIndex = i
}
}
if (firstChildIndex !== -1 && lastChildIndex !== -1) {
const lineLeft = childLevel * 20 + 8
const lineTop = (index + 0.5) * ITEM_HEIGHT
const lineHeight = (lastChildIndex - index) * ITEM_HEIGHT
lines.push({
key: `${node.id}-vline`,
left: lineLeft,
top: lineTop,
height: lineHeight
})
}
}
})
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
})
})
// 展开/折叠逻辑
const toggleExpand = (id: string) => {
if (expandedKeys.value.has(id)) {
expandedKeys.value.delete(id)
} else {
expandedKeys.value.add(id)
}
expandedKeys.value = new Set(expandedKeys.value)
emit('update:expandedKeys', Array.from(expandedKeys.value))
}
// 选中逻辑
const handleSelect = (id: string) => {
editorStore.setSelectedNodeId(id)
}
// 获取节点图标
const getNodeIcon = (item: FlatNode) => {
if (item.hasChildren) {
return FolderOpenOutline
} else if (DOCUMENT_LIKE_TAGS.includes(item.tagName)) {
return DocumentTextOutline
}
return CodeWorkingOutline
}
// 搜索高亮逻辑
const highlightText = (text: string, keyword: string): string => {
if (!keyword || !text) return text
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${escaped})`, 'gi')
return text.replace(regex, '<mark class="highlight-mark">$1</mark>')
}
// 树展开与定位同步辅助函数
const syncTreeSelection = (newId: string | null) => {
if (!newId) return
// 自动展开其所有祖先节点
const path: XmlNode[] = []
let curr = editorStore.nodeMap.get(newId)
while (curr) {
path.unshift(curr.node)
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
}
if (path.length > 0) {
let changed = false
for (let i = 0; i < path.length - 1; i++) {
const ancestorId = path[i].id
if (!expandedKeys.value.has(ancestorId)) {
expandedKeys.value.add(ancestorId)
changed = true
}
}
if (changed) {
expandedKeys.value = new Set(expandedKeys.value)
emit('update:expandedKeys', Array.from(expandedKeys.value))
}
}
// 自动滚动定位到视口中央
nextTick(() => {
const idx = flatList.value.findIndex(item => item.id === newId)
if (idx !== -1 && viewportRef.value) {
const itemTop = idx * ITEM_HEIGHT
const vHeight = viewportRef.value.clientHeight
viewportRef.value.scrollTo({
top: Math.max(0, itemTop - vHeight / 2),
behavior: 'smooth'
})
}
})
}
// 监听选中的节点,进行树组件的自动展开与滚动定位
watch(() => editorStore.selectedNodeId, syncTreeSelection)
// 监听回退/重做以同步树的展开与定位
watch(() => editorStore.lastUndoRedoTime, () => {
syncTreeSelection(editorStore.selectedNodeId)
})
// 右键下拉菜单数据
const dropdownOptions = computed<DropdownOption[]>(() => {
if (!contextNodeId.value) return []
return getDropdownOptions(contextNodeId.value)
})
function handleContextMenu(e: MouseEvent, item: FlatNode) {
showDropdown.value = false
contextNodeId.value = item.id
nextTick(() => {
dropdownX.value = e.clientX
dropdownY.value = e.clientY
showDropdown.value = true
})
}
// 菜单选择处理,委托给 Hook
async function handleDropdownSelect(key: string) {
showDropdown.value = false
if (!contextNodeId.value) return
await handleDropdownAction(key, contextNodeId.value)
// 如果添加了节点,确保父级节点展开状态
if (key.startsWith('add-child-')) {
if (!expandedKeys.value.has(contextNodeId.value)) {
expandedKeys.value.add(contextNodeId.value)
expandedKeys.value = new Set(expandedKeys.value)
emit('update:expandedKeys', Array.from(expandedKeys.value))
}
}
}
</script> </script>
<style scoped> <style scoped>
...@@ -501,7 +200,9 @@ async function handleDropdownSelect(key: string) { ...@@ -501,7 +200,9 @@ async function handleDropdownSelect(key: string) {
.tree-node-content { .tree-node-content {
position: relative; position: relative;
z-index: 1; z-index: 1;
transition: background-color 0.15s ease, color 0.15s ease; transition:
background-color 0.15s ease,
color 0.15s ease;
} }
/* 水平连接虚线 */ /* 水平连接虚线 */
...@@ -517,14 +218,14 @@ async function handleDropdownSelect(key: string) { ...@@ -517,14 +218,14 @@ async function handleDropdownSelect(key: string) {
} }
/* 根级节点不显示水平虚线 */ /* 根级节点不显示水平虚线 */
.tree-node-content[style*="--tree-level: 0"]::after { .tree-node-content[style*='--tree-level: 0']::after {
display: none; display: none;
} }
/* 搜索高亮标记样式 */ /* 搜索高亮标记样式 */
:deep(.highlight-mark) { :deep(.highlight-mark) {
background-color: var(--primary-color-hover, rgba(24, 160, 88, 0.2)); background-color: var(--primary-color-hover);
color: var(--primary-color, #18a058); color: var(--primary-color);
padding: 0 2px; padding: 0 2px;
border-radius: 2px; border-radius: 2px;
font-weight: 600; font-weight: 600;
......
export interface SplitterProps {
width: number
collapsed: boolean
collapseThreshold?: number
defaultWidth?: number
maxRatio?: number
}
export function useSplitter(
props: SplitterProps,
emit: {
(e: 'update:width', val: number): void
(e: 'update:collapsed', val: boolean): void
}
) {
const collapseThreshold = props.collapseThreshold ?? 150
const defaultWidth = props.defaultWidth ?? 560
const maxRatio = props.maxRatio ?? 0.6
const dividerRef = ref<HTMLElement | null>(null)
const isDragging = ref(false)
let containerEl: HTMLElement | null = null
let startX = 0
let startWidth = 0
let hasDragged = false
onMounted(() => {
if (dividerRef.value) {
containerEl = dividerRef.value.parentElement
}
})
const startDrag = (e: MouseEvent) => {
isDragging.value = true
hasDragged = false
startX = e.clientX
startWidth = props.collapsed ? 0 : props.width
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onDrag)
window.addEventListener('mouseup', stopDrag)
}
const onDrag = (e: MouseEvent) => {
if (!isDragging.value || !containerEl) return
const delta = e.clientX - startX
if (Math.abs(delta) > 3) hasDragged = true
const raw = startWidth + delta
const containerWidth = containerEl.clientWidth
const maxWidth = containerWidth * maxRatio
if (raw < collapseThreshold) {
emit('update:width', Math.max(0, raw))
emit('update:collapsed', raw < collapseThreshold / 2)
} else {
emit('update:collapsed', false)
emit('update:width', Math.min(maxWidth, raw))
}
}
const stopDrag = () => {
isDragging.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
if (!props.collapsed && props.width < collapseThreshold) {
emit('update:collapsed', true)
}
}
const handleDividerClick = () => {
if (hasDragged) return
if (props.collapsed) {
emit('update:collapsed', false)
emit('update:width', defaultWidth)
}
}
onUnmounted(() => {
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
})
return {
dividerRef,
isDragging,
startDrag,
handleDividerClick
}
}
<template>
<div
ref="dividerRef"
class="split-divider group"
:class="{ 'is-dragging': isDragging, 'is-collapsed': collapsed }"
@mousedown.prevent="startDrag"
@click="handleDividerClick"
>
<!-- 折叠状态:展开箭头 -->
<div v-if="collapsed" class="split-expand-btn">
<svg class="w-3 h-3" viewBox="0 0 12 12" fill="currentColor">
<path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- 展开状态:可视指示线 + 手柄圆点 -->
<template v-else>
<div class="split-divider-line" />
<div class="split-divider-handle">
<div class="handle-dot" />
<div class="handle-dot" />
<div class="handle-dot" />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { useSplitter } from './functionals'
const props = withDefaults(defineProps<{
width: number
collapsed: boolean
collapseThreshold?: number
defaultWidth?: number
maxRatio?: number
}>(), {
collapseThreshold: 150,
defaultWidth: 560,
maxRatio: 0.6
})
const emit = defineEmits<{
(e: 'update:width', val: number): void
(e: 'update:collapsed', val: boolean): void
}>()
const {
dividerRef,
isDragging,
startDrag,
handleDividerClick
} = useSplitter(props, emit)
</script>
<style scoped>
/* ── 分割条容器 ─────────────────────────────────────────────────────────────── */
.split-divider {
position: relative;
width: 10px;
flex-shrink: 0;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: background-color 0.2s;
}
.split-divider:hover,
.split-divider.is-dragging {
background-color: color-mix(in srgb, var(--primary-color) 10%, transparent);
}
/* ── 可视线 ───────────────────────────────────────────────────────────────── */
.split-divider-line {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
background-color: var(--divider-color, rgba(0, 0, 0, 0.08));
transition: background-color 0.2s, width 0.2s;
}
.split-divider:hover .split-divider-line,
.split-divider.is-dragging .split-divider-line {
background-color: var(--primary-color);
width: 2px;
}
/* ── 手柄圆点 ────────────────────────────────────────────────────────────── */
.split-divider-handle {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 3px;
padding: 4px 3px;
border-radius: 8px;
background: var(--fill-color-2, rgba(0, 0, 0, 0.04));
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
opacity: 0;
transform: scaleY(0.8);
transition: opacity 0.2s, transform 0.2s, background 0.2s;
}
.split-divider:hover .split-divider-handle,
.split-divider.is-dragging .split-divider-handle {
opacity: 1;
transform: scaleY(1);
background: var(--primary-color);
border-color: var(--primary-color);
}
.handle-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--divider-color, rgba(0, 0, 0, 0.2));
transition: background-color 0.2s;
}
.split-divider:hover .handle-dot,
.split-divider.is-dragging .handle-dot {
background-color: white;
}
/* ── 折叠状态 ─────────────────────────────────────────────────────────────── */
.split-divider.is-collapsed {
width: 16px;
cursor: pointer;
background-color: var(--fill-color-3, rgba(0, 0, 0, 0.06));
border-right: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
}
.split-divider.is-collapsed:hover {
background-color: color-mix(in srgb, var(--primary-color) 12%, transparent);
}
.split-expand-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--text-color-3, rgba(0, 0, 0, 0.38));
transition: color 0.2s;
}
.split-divider.is-collapsed:hover .split-expand-btn {
color: var(--primary-color);
}
</style>
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
export interface CellParagraphModel { export interface CellParagraphModel {
id: string; id: string
tagName: string; tagName: string
text: string; text: string
} }
export interface TableCellModel { export interface TableCellModel {
id: string; id: string
attributes: Record<string, string>; attributes: Record<string, string>
paragraphs: CellParagraphModel[]; paragraphs: CellParagraphModel[]
/** 原始 ENTRY XmlNode,当 ENTRY 含有 UNLIST 等复杂结构时通过 DocNodeRenderer 渲染 */
rawNode?: XmlNode
/** 是否包含需要通过 DocNodeRenderer 渲染的复杂子节点(非纯 PARA/PARAC) */
hasComplexChildren?: boolean
colspan?: number
rowspan?: number
colIdx?: number
rowIdx?: number
shouldRender?: boolean
isDummy?: boolean
} }
export interface TableRowModel { export interface TableRowModel {
id: string; id: string
cells: TableCellModel[]; cells: TableCellModel[]
} }
export interface TableStructureModel { export interface TableStructureModel {
cols: number; cols: number
colSpecs: XmlNode[]; colSpecs: XmlNode[]
theadRows: TableRowModel[]; theadRows: TableRowModel[]
tbodyRows: TableRowModel[]; tbodyRows: TableRowModel[]
tgroupId: string; tgroupId: string
theadId: string; theadId: string
tbodyId: string; tbodyId: string
} }
// 表格组件相关的提示文本定义 // 表格组件相关的提示文本定义
......
...@@ -6,10 +6,10 @@ const getDeepText = (node: XmlNode): string => { ...@@ -6,10 +6,10 @@ const getDeepText = (node: XmlNode): string => {
if (node.textContent) return node.textContent if (node.textContent) return node.textContent
if (node.mixedContent && node.mixedContent.length > 0) { if (node.mixedContent && node.mixedContent.length > 0) {
return node.mixedContent return node.mixedContent
.map(item => { .map((item) => {
if (item.type === 'text') return item.text || '' if (item.type === 'text') return item.text || ''
if (item.type === 'element' && item.nodeId) { if (item.type === 'element' && item.nodeId) {
const child = node.children.find(c => c.id === item.nodeId) const child = node.children.find((c) => c.id === item.nodeId)
return child ? getDeepText(child) : '' return child ? getDeepText(child) : ''
} }
return '' return ''
...@@ -22,23 +22,6 @@ const getDeepText = (node: XmlNode): string => { ...@@ -22,23 +22,6 @@ const getDeepText = (node: XmlNode): string => {
return '' return ''
} }
const getCellText = (cellNode: XmlNode): string => {
if (cellNode.textContent) return cellNode.textContent
const parac = cellNode.children.find(c => c.tagName === 'PARAC')
if (parac) {
return getDeepText(parac)
}
const para = cellNode.children.find(c => c.tagName === 'PARA')
if (para) {
return getDeepText(para)
}
if (cellNode.children.length > 0) {
return getDeepText(cellNode.children[0])
}
return ''
}
const setDeepText = (node: XmlNode, text: string): void => { const setDeepText = (node: XmlNode, text: string): void => {
if (node.children && node.children.length > 0) { if (node.children && node.children.length > 0) {
for (const child of node.children) { for (const child of node.children) {
...@@ -57,21 +40,47 @@ const setDeepText = (node: XmlNode, text: string): void => { ...@@ -57,21 +40,47 @@ const setDeepText = (node: XmlNode, text: string): void => {
/** /**
* CALS 表格编辑器 (TableEditor) 组件专用 Hook 逻辑 * CALS 表格编辑器 (TableEditor) 组件专用 Hook 逻辑
*/ */
export function useTableEditor() { export function useTableEditor(props: { node: XmlNode }) {
const store = useEditorStore() const store = useEditorStore()
const structure = ref<TableStructureModel>({
cols: 0,
colSpecs: [],
theadRows: [],
tbodyRows: [],
tgroupId: '',
theadId: '',
tbodyId: ''
})
const selectedCellIds = ref<string[]>([])
/** /**
* 辅助查找当前节点树中的 TGROUP 节点 * 辅助查找当前节点树中的 TGROUP 节点
*/ */
const findTgroup = (node: XmlNode): XmlNode | null => { const findTgroup = (node: XmlNode): XmlNode | null => {
if (node.tagName === 'TGROUP') return node if (node.tagName === 'TGROUP') return node
if (node.tagName === 'TABLE') { if (node.tagName === 'TABLE') {
const tgroup = node.children.find(c => c.tagName === 'TGROUP') const tgroup = node.children.find((c) => c.tagName === 'TGROUP')
return tgroup || null return tgroup || null
} }
return null return null
} }
/**
* 递归查找 XML 节点
*/
const findXmlNodeById = (node: XmlNode, id: string): XmlNode | null => {
if (node.id === id) return node
if (node.children) {
for (const child of node.children) {
const found = findXmlNodeById(child, id)
if (found) return found
}
}
return null
}
const parseTable = (node: XmlNode): TableStructureModel => { const parseTable = (node: XmlNode): TableStructureModel => {
const tgroup = findTgroup(node) const tgroup = findTgroup(node)
if (!tgroup) { if (!tgroup) {
...@@ -79,68 +88,192 @@ export function useTableEditor() { ...@@ -79,68 +88,192 @@ export function useTableEditor() {
} }
const cols = parseInt(tgroup.attributes.COLS || '0', 10) || 1 const cols = parseInt(tgroup.attributes.COLS || '0', 10) || 1
const colSpecs = tgroup.children.filter(c => c.tagName === 'COLSPEC') const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
const thead = tgroup.children.find(c => c.tagName === 'THEAD') const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find(c => c.tagName === 'TBODY') const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
const parseRows = (sectionNode?: XmlNode): TableRowModel[] => { const parseRowsLayout = (sectionNode?: XmlNode): TableRowModel[] => {
if (!sectionNode) return [] if (!sectionNode) return []
return sectionNode.children
.filter(c => c.tagName === 'ROW') const rowsList = sectionNode.children.filter((c) => c.tagName === 'ROW')
.map(rowNode => { const numRows = rowsList.length
const cells: TableCellModel[] = rowNode.children
.filter(c => c.tagName === 'ENTRY') // 建立 colSpecs 映射,快速根据 colName 找 index
.map(cellNode => { const colSpecMap = new Map<string, number>()
let paragraphs = cellNode.children colSpecs.forEach((spec, index) => {
.filter(c => c.tagName === 'PARAC' || c.tagName === 'PARA') const name = (spec.attributes.COLNAME || spec.attributes.colname || '').toLowerCase()
.map(pNode => ({ if (name) colSpecMap.set(name, index)
})
const spanSpecMap = new Map<string, { namest: string; nameend: string }>()
tgroup.children
.filter((c) => c.tagName === 'SPANSPEC')
.forEach((spec) => {
const name = (spec.attributes.SPANNAME || spec.attributes.spanname || '').toLowerCase()
const namest = (spec.attributes.NAMEST || spec.attributes.namest || '').toLowerCase()
const nameend = (spec.attributes.NAMEEND || spec.attributes.nameend || '').toLowerCase()
if (name && namest && nameend) {
spanSpecMap.set(name, { namest, nameend })
}
})
// 初始化 occupied 二维占用矩阵 [numRows][cols] 为 null
const occupied: (TableCellModel | null)[][] = Array.from({ length: numRows }, () => Array(cols).fill(null))
const resultRows: TableRowModel[] = rowsList.map((rowNode) => ({
id: rowNode.id,
cells: []
}))
rowsList.forEach((rowNode, rIdx) => {
const entryNodes = rowNode.children.filter((c) => c.tagName === 'ENTRY')
let nodeIdx = 0
for (let cIdx = 0; cIdx < cols; cIdx++) {
// 如果该格子已经被上面的 rowspan 占用了,直接跳过
if (occupied[rIdx][cIdx] !== null) continue
// 取得当前物理 entry
const entryNode = entryNodes[nodeIdx]
if (!entryNode) {
// 如果 entry 没了,我们补一个 dummy 占位,或者空的真实的 entry
const cellId = crypto.randomUUID()
const newCell: TableCellModel = {
id: cellId,
attributes: {},
paragraphs: [{ id: cellId, tagName: 'ENTRY', text: '' }],
colspan: 1,
rowspan: 1,
colIdx: cIdx,
rowIdx: rIdx,
shouldRender: true,
isDummy: false
}
occupied[rIdx][cIdx] = newCell
resultRows[rIdx].cells.push(newCell)
continue
}
// 计算该 entry 的 colspan 和 rowspan
let cellColspan = 1
const namest = (entryNode.attributes.NAMEST || entryNode.attributes.namest || '').toLowerCase()
const nameend = (entryNode.attributes.NAMEEND || entryNode.attributes.nameend || '').toLowerCase()
const spanname = (entryNode.attributes.SPANNAME || entryNode.attributes.spanname || '').toLowerCase()
let startCol = cIdx
let endCol = cIdx
if (namest && nameend) {
const sIdx = colSpecMap.get(namest)
const eIdx = colSpecMap.get(nameend)
if (sIdx !== undefined && eIdx !== undefined) {
startCol = Math.min(sIdx, eIdx)
endCol = Math.max(sIdx, eIdx)
}
} else if (spanname && spanSpecMap.has(spanname)) {
const spec = spanSpecMap.get(spanname)!
const sIdx = colSpecMap.get(spec.namest)
const eIdx = colSpecMap.get(spec.nameend)
if (sIdx !== undefined && eIdx !== undefined) {
startCol = Math.min(sIdx, eIdx)
endCol = Math.max(sIdx, eIdx)
}
}
// 修正由于前面的跨列导致的起点不对
if (startCol < cIdx) {
const diff = cIdx - startCol
startCol = cIdx
endCol = endCol + diff
}
if (endCol >= cols) endCol = cols - 1
cellColspan = endCol - startCol + 1
// 获取 rowspan (MOREROWS 是 0-indexed 的额外行数)
const morerowsAttr = entryNode.attributes.MOREROWS || entryNode.attributes.morerows || '0'
const cellRowspan = (parseInt(morerowsAttr, 10) || 0) + 1
// 检测是否含有 UNLIST / NUMLIST 等复杂列表结构(非纯 PARA/PARAC)
const COMPLEX_ENTRY_TAGS = new Set(['UNLIST', 'NUMLIST', 'LIST1', 'L1', 'WARNING', 'CAUTION', 'NOTE', 'GRAPHIC', 'TABLE'])
const hasComplexChildren = entryNode.children.some((c) => COMPLEX_ENTRY_TAGS.has(c.tagName))
// 解析文字
let paragraphs = entryNode.children
.filter((c) => c.tagName === 'PARAC' || c.tagName === 'PARA')
.map((pNode) => ({
id: pNode.id, id: pNode.id,
tagName: pNode.tagName, tagName: pNode.tagName,
text: getDeepText(pNode) text: getDeepText(pNode)
})) }))
if (paragraphs.length === 0 && !hasComplexChildren) {
if (paragraphs.length === 0) {
paragraphs.push({ paragraphs.push({
id: cellNode.id, id: entryNode.id,
tagName: 'ENTRY', tagName: 'ENTRY',
text: cellNode.textContent || '' text: entryNode.textContent || ''
}) })
} }
return { const actualCell: TableCellModel = {
id: cellNode.id, id: entryNode.id,
attributes: { ...cellNode.attributes }, attributes: { ...entryNode.attributes },
paragraphs paragraphs,
rawNode: entryNode,
hasComplexChildren,
colspan: cellColspan,
rowspan: cellRowspan,
colIdx: startCol,
rowIdx: rIdx,
shouldRender: true,
isDummy: false
} }
})
// 补齐缺少的列,避免渲染空洞 // 将矩阵中占用的格子涂上此单元格
while (cells.length < cols) { for (let dr = 0; dr < cellRowspan; dr++) {
const cellId = crypto.randomUUID() for (let dc = 0; dc < cellColspan; dc++) {
cells.push({ const targetR = rIdx + dr
id: cellId, const targetC = startCol + dc
if (targetR < numRows && targetC < cols) {
if (dr === 0 && dc === 0) {
occupied[targetR][targetC] = actualCell
} else {
// 记录被合并吞噬的虚设占位格
const dummyCell: TableCellModel = {
id: `${entryNode.id}-dummy-${targetR}-${targetC}`,
attributes: {}, attributes: {},
paragraphs: [{ paragraphs: [],
id: cellId, colspan: 1,
tagName: 'ENTRY', rowspan: 1,
text: '' colIdx: targetC,
}] rowIdx: targetR,
}) shouldRender: false,
isDummy: true
}
occupied[targetR][targetC] = dummyCell
resultRows[targetR].cells.push(dummyCell)
}
}
}
} }
return { resultRows[rIdx].cells.push(actualCell)
id: rowNode.id, nodeIdx++
cells cIdx = endCol
} }
}) })
// 按列号 colIdx 排序,让每一行的单元格列表在 DOM 中严格对齐
resultRows.forEach((row) => {
row.cells.sort((a, b) => (a.colIdx ?? 0) - (b.colIdx ?? 0))
})
return resultRows
} }
return { return {
cols, cols,
colSpecs, colSpecs,
theadRows: parseRows(thead), theadRows: parseRowsLayout(thead),
tbodyRows: parseRows(tbody), tbodyRows: parseRowsLayout(tbody),
tgroupId: tgroup.id, tgroupId: tgroup.id,
theadId: thead?.id || '', theadId: thead?.id || '',
tbodyId: tbody?.id || '' tbodyId: tbody?.id || ''
...@@ -163,35 +296,37 @@ export function useTableEditor() { ...@@ -163,35 +296,37 @@ export function useTableEditor() {
const tgroup = findTgroup(node) const tgroup = findTgroup(node)
if (!tgroup) return if (!tgroup) return
const findAndChange = (currentNode: XmlNode): boolean => { const cellNode = findXmlNodeById(tgroup, cellId)
if (currentNode.id === cellId) { if (!cellNode) return
if (currentNode.children && currentNode.children.length > 0) {
for (const child of currentNode.children) { const oldText = getDeepText(cellNode)
if (oldText === text) {
return // 值没有变化,不保存快照,不修改
}
store.saveSnapshot()
if (cellNode.children && cellNode.children.length > 0) {
for (const child of cellNode.children) {
setDeepText(child, text) setDeepText(child, text)
} }
} else { } else {
currentNode.textContent = text cellNode.textContent = text
if (currentNode.mixedContent.length > 0) { if (cellNode.mixedContent.length > 0) {
currentNode.mixedContent = [{ type: 'text', text }] cellNode.mixedContent = [{ type: 'text', text }]
} }
} }
return true
}
for (const child of currentNode.children) {
if (findAndChange(child)) return true
}
return false
}
findAndChange(tgroup) store.rebuildNodeMap()
store.triggerSync()
} }
const addRow = (node: XmlNode, section: 'THEAD' | 'TBODY' = 'TBODY'): void => { const addRow = (node: XmlNode, section: 'THEAD' | 'TBODY' = 'TBODY', activeRowId?: string, insertBelow = true): void => {
const tgroup = findTgroup(node) const tgroup = findTgroup(node)
if (!tgroup) return if (!tgroup) return
let sectionNode = tgroup.children.find(c => c.tagName === section) store.saveSnapshot()
let sectionNode = tgroup.children.find((c) => c.tagName === section)
if (!sectionNode) { if (!sectionNode) {
sectionNode = { sectionNode = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
...@@ -221,20 +356,32 @@ export function useTableEditor() { ...@@ -221,20 +356,32 @@ export function useTableEditor() {
newRow.children.push(createDefaultEntry(rowId)) newRow.children.push(createDefaultEntry(rowId))
} }
if (activeRowId) {
const idx = sectionNode.children.findIndex((c) => c.id === activeRowId)
if (idx !== -1) {
const insertIdx = insertBelow ? idx + 1 : idx
sectionNode.children.splice(insertIdx, 0, newRow)
store.rebuildNodeMap()
return
}
}
sectionNode.children.push(newRow) sectionNode.children.push(newRow)
store.triggerSync() store.rebuildNodeMap()
} }
const deleteRow = (node: XmlNode, rowId: string): void => { const deleteRow = (node: XmlNode, rowId: string): void => {
const tgroup = findTgroup(node) const tgroup = findTgroup(node)
if (!tgroup) return if (!tgroup) return
const thead = tgroup.children.find(c => c.tagName === 'THEAD') store.saveSnapshot()
const tbody = tgroup.children.find(c => c.tagName === 'TBODY')
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
const removeNode = (sectionNode?: XmlNode): boolean => { const removeNode = (sectionNode?: XmlNode): boolean => {
if (!sectionNode) return false if (!sectionNode) return false
const idx = sectionNode.children.findIndex(c => c.id === rowId) const idx = sectionNode.children.findIndex((c) => c.id === rowId)
if (idx !== -1) { if (idx !== -1) {
sectionNode.children.splice(idx, 1) sectionNode.children.splice(idx, 1)
return true return true
...@@ -243,25 +390,32 @@ export function useTableEditor() { ...@@ -243,25 +390,32 @@ export function useTableEditor() {
} }
if (removeNode(tbody) || removeNode(thead)) { if (removeNode(tbody) || removeNode(thead)) {
store.triggerSync() store.rebuildNodeMap()
} }
} }
const addColumn = (node: XmlNode): void => { const addColumn = (node: XmlNode, activeColIdx?: number, insertRight = true): void => {
const tgroup = findTgroup(node) const tgroup = findTgroup(node)
if (!tgroup) return if (!tgroup) return
store.saveSnapshot()
const currentCols = parseInt(tgroup.attributes.COLS || '0', 10) const currentCols = parseInt(tgroup.attributes.COLS || '0', 10)
const nextCols = currentCols + 1 const nextCols = currentCols + 1
tgroup.attributes.COLS = nextCols.toString() tgroup.attributes.COLS = nextCols.toString()
const colSpecIndex = tgroup.children.filter(c => c.tagName === 'COLSPEC').length let targetColIdx = currentCols
if (activeColIdx !== undefined) {
targetColIdx = insertRight ? activeColIdx + 1 : activeColIdx
}
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
const newColSpec: XmlNode = { const newColSpec: XmlNode = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
tagName: 'COLSPEC', tagName: 'COLSPEC',
attributes: { attributes: {
COLNAME: `col${colSpecIndex + 1}`, COLNAME: `col_temp_${Date.now()}`,
COLNUM: (colSpecIndex + 1).toString() COLNUM: ''
}, },
children: [], children: [],
textContent: '', textContent: '',
...@@ -269,28 +423,98 @@ export function useTableEditor() { ...@@ -269,28 +423,98 @@ export function useTableEditor() {
parentId: tgroup.id parentId: tgroup.id
} }
const lastColSpecIdx = tgroup.children.reduce((acc, curr, idx) => { if (colSpecs.length > 0) {
return curr.tagName === 'COLSPEC' ? idx : acc const lastSpecId = colSpecs[colSpecs.length - 1].id
}, -1) const lastSpecIdx = tgroup.children.findIndex((c) => c.id === lastSpecId)
tgroup.children.splice(lastColSpecIdx + 1, 0, newColSpec) let colspecInsertIdx = lastSpecIdx + 1
if (targetColIdx < colSpecs.length) {
const targetSpecId = colSpecs[targetColIdx].id
colspecInsertIdx = tgroup.children.findIndex((c) => c.id === targetSpecId)
}
tgroup.children.splice(colspecInsertIdx, 0, newColSpec)
} else {
tgroup.children.unshift(newColSpec)
}
const thead = tgroup.children.find(c => c.tagName === 'THEAD') const updatedColSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
const tbody = tgroup.children.find(c => c.tagName === 'TBODY') updatedColSpecs.forEach((spec, idx) => {
spec.attributes.COLNAME = `col${idx + 1}`
spec.attributes.COLNUM = (idx + 1).toString()
})
const appendCellToRows = (sectionNode?: XmlNode) => { const updateCellSpans = (sectionNode?: XmlNode) => {
if (!sectionNode) return if (!sectionNode) return
sectionNode.children sectionNode.children.forEach((row) => {
.filter(c => c.tagName === 'ROW') row.children.forEach((entry) => {
.forEach(row => { const namest = entry.attributes.NAMEST
const nameend = entry.attributes.NAMEEND
if (namest && nameend) {
const sColIdx = colSpecs.findIndex((s) => s.attributes.COLNAME === namest)
const eColIdx = colSpecs.findIndex((s) => s.attributes.COLNAME === nameend)
if (sColIdx !== -1 && eColIdx !== -1) {
let newSColIdx = sColIdx
let newEColIdx = eColIdx
if (sColIdx >= targetColIdx) {
newSColIdx = sColIdx + 1
}
if (eColIdx >= targetColIdx) {
newEColIdx = eColIdx + 1
}
entry.attributes.NAMEST = `col${newSColIdx + 1}`
entry.attributes.NAMEEND = `col${newEColIdx + 1}`
}
}
})
})
}
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
updateCellSpans(thead)
updateCellSpans(tbody)
const tableStructure = parseTable(node)
const insertCellToRows = (sectionNode?: XmlNode, structRows: any[] = []) => {
if (!sectionNode) return
const rows = sectionNode.children.filter((c) => c.tagName === 'ROW')
rows.forEach((row) => {
const structRow = structRows.find((r) => r.id === row.id)
if (!structRow) {
row.children.push(createDefaultEntry(row.id))
return
}
const rightCell = structRow.cells.find((c: any) => c.colIdx >= targetColIdx && !c.isDummy)
if (rightCell) {
const idx = row.children.findIndex((c) => c.id === rightCell.id)
if (idx !== -1) {
row.children.splice(idx, 0, createDefaultEntry(row.id))
return
}
}
const leftCell = [...structRow.cells].reverse().find((c: any) => c.colIdx < targetColIdx && !c.isDummy)
if (leftCell) {
const idx = row.children.findIndex((c) => c.id === leftCell.id)
if (idx !== -1) {
row.children.splice(idx + 1, 0, createDefaultEntry(row.id))
return
}
}
row.children.push(createDefaultEntry(row.id)) row.children.push(createDefaultEntry(row.id))
}) })
} }
appendCellToRows(thead) insertCellToRows(thead, tableStructure.theadRows)
appendCellToRows(tbody) insertCellToRows(tbody, tableStructure.tbodyRows)
store.triggerSync() store.rebuildNodeMap()
} }
const deleteColumn = (node: XmlNode, colIndex: number): void => { const deleteColumn = (node: XmlNode, colIndex: number): void => {
...@@ -303,27 +527,29 @@ export function useTableEditor() { ...@@ -303,27 +527,29 @@ export function useTableEditor() {
return return
} }
store.saveSnapshot()
tgroup.attributes.COLS = (currentCols - 1).toString() tgroup.attributes.COLS = (currentCols - 1).toString()
const colSpecs = tgroup.children.filter(c => c.tagName === 'COLSPEC') const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
if (colSpecs[colIndex]) { if (colSpecs[colIndex]) {
const specId = colSpecs[colIndex].id const specId = colSpecs[colIndex].id
const idx = tgroup.children.findIndex(c => c.id === specId) const idx = tgroup.children.findIndex((c) => c.id === specId)
if (idx !== -1) tgroup.children.splice(idx, 1) if (idx !== -1) tgroup.children.splice(idx, 1)
} }
const thead = tgroup.children.find(c => c.tagName === 'THEAD') const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find(c => c.tagName === 'TBODY') const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
const deleteCellFromRows = (sectionNode?: XmlNode) => { const deleteCellFromRows = (sectionNode?: XmlNode) => {
if (!sectionNode) return if (!sectionNode) return
sectionNode.children sectionNode.children
.filter(c => c.tagName === 'ROW') .filter((c) => c.tagName === 'ROW')
.forEach(row => { .forEach((row) => {
const entries = row.children.filter(c => c.tagName === 'ENTRY') const entries = row.children.filter((c) => c.tagName === 'ENTRY')
if (entries[colIndex]) { if (entries[colIndex]) {
const cellId = entries[colIndex].id const cellId = entries[colIndex].id
const entryIdx = row.children.findIndex(c => c.id === cellId) const entryIdx = row.children.findIndex((c) => c.id === cellId)
if (entryIdx !== -1) row.children.splice(entryIdx, 1) if (entryIdx !== -1) row.children.splice(entryIdx, 1)
} }
}) })
...@@ -332,15 +558,587 @@ export function useTableEditor() { ...@@ -332,15 +558,587 @@ export function useTableEditor() {
deleteCellFromRows(thead) deleteCellFromRows(thead)
deleteCellFromRows(tbody) deleteCellFromRows(tbody)
store.triggerSync() store.rebuildNodeMap()
}
const mergeMultipleCells = (
tableNode: XmlNode,
cellIds: string[],
minCol: number,
maxCol: number,
minRow: number,
maxRow: number,
isThead: boolean
): void => {
const tgroup = findTgroup(tableNode)
if (!tgroup) return
store.saveSnapshot()
const colspan = maxCol - minCol + 1
const rowspan = maxRow - minRow + 1
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
const ensureColName = (idx: number): string => {
let spec = colSpecs[idx]
if (!spec) {
spec = {
id: crypto.randomUUID(),
tagName: 'COLSPEC',
attributes: {
COLNAME: `col${idx + 1}`,
COLNUM: (idx + 1).toString()
},
children: [],
textContent: '',
mixedContent: [],
parentId: tgroup.id
}
const lastColSpecIdx = tgroup.children.reduce((acc, curr, curIdx) => {
return curr.tagName === 'COLSPEC' ? curIdx : acc
}, -1)
tgroup.children.splice(lastColSpecIdx + 1, 0, spec)
colSpecs.push(spec)
}
if (!spec.attributes.COLNAME) {
spec.attributes.COLNAME = `col${idx + 1}`
}
return spec.attributes.COLNAME
}
const startColName = ensureColName(minCol)
const endColName = ensureColName(maxCol)
const structure = parseTable(tableNode)
const targetRows = isThead ? structure.theadRows : structure.tbodyRows
let topLeftCellId: string | null = null
for (const row of targetRows) {
const found = row.cells.find((c) => c.colIdx === minCol && c.rowIdx === minRow)
if (found) {
topLeftCellId = found.id
break
}
}
if (!topLeftCellId) return
const topLeftCellXml = findXmlNodeById(tgroup, topLeftCellId)
if (!topLeftCellXml) return
if (colspan > 1) {
topLeftCellXml.attributes.NAMEST = startColName
topLeftCellXml.attributes.NAMEEND = endColName
} else {
delete topLeftCellXml.attributes.NAMEST
delete topLeftCellXml.attributes.NAMEEND
}
if (rowspan > 1) {
topLeftCellXml.attributes.MOREROWS = (rowspan - 1).toString()
} else {
delete topLeftCellXml.attributes.MOREROWS
}
// 删除其余被合并吞噬的真实 ENTRY 节点
const otherCellIds = cellIds.filter((id) => id !== topLeftCellId)
otherCellIds.forEach((id) => {
const cellXml = findXmlNodeById(tgroup, id)
if (cellXml && cellXml.parentId) {
const parentRow = findXmlNodeById(tgroup, cellXml.parentId)
if (parentRow) {
const idx = parentRow.children.findIndex((c) => c.id === id)
if (idx !== -1) {
parentRow.children.splice(idx, 1)
}
}
}
})
store.rebuildNodeMap()
}
const splitCell = (tableNode: XmlNode, cellId: string): void => {
const tgroup = findTgroup(tableNode)
if (!tgroup) return
const cellXml = findXmlNodeById(tgroup, cellId)
if (!cellXml) return
store.saveSnapshot()
const structure = parseTable(tableNode)
let originalCell: TableCellModel | null = null
for (const row of [...structure.theadRows, ...structure.tbodyRows]) {
const found = row.cells.find((c) => c.id === cellId)
if (found) {
originalCell = found
break
}
}
if (!originalCell) return
const colIdx = originalCell.colIdx ?? 0
const rowIdx = originalCell.rowIdx ?? 0
const colspan = originalCell.colspan ?? 1
const rowspan = originalCell.rowspan ?? 1
if (colspan === 1 && rowspan === 1) return
delete cellXml.attributes.NAMEST
delete cellXml.attributes.NAMEEND
delete cellXml.attributes.SPANNAME
delete cellXml.attributes.MOREROWS
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
let isThead = false
if (thead) {
isThead = thead.children.filter((c) => c.tagName === 'ROW').some((r) => r.id === cellXml.parentId)
}
const sectionNode = isThead ? thead : tbody
if (!sectionNode) return
const rowsList = sectionNode.children.filter((c) => c.tagName === 'ROW')
for (let dr = 0; dr < rowspan; dr++) {
const currentRIdx = rowIdx + dr
const parentRow = rowsList[currentRIdx]
if (!parentRow) continue
for (let dc = 0; dc < colspan; dc++) {
if (dr === 0 && dc === 0) continue
const targetColIdx = colIdx + dc
const newEntry = createDefaultEntry(parentRow.id)
let insertIdx = -1
const rowStructure = (isThead ? structure.theadRows : structure.tbodyRows)[currentRIdx]
if (rowStructure) {
const nextRealCell = rowStructure.cells.find((c) => (c.colIdx ?? 0) > targetColIdx && !c.isDummy)
if (nextRealCell) {
insertIdx = parentRow.children.findIndex((child) => child.id === nextRealCell.id)
}
}
if (insertIdx !== -1) {
parentRow.children.splice(insertIdx, 0, newEntry)
} else {
parentRow.children.push(newEntry)
}
}
}
store.rebuildNodeMap()
}
// 监听 props.node 的更新并自动同步结构
watch(
() => props.node,
(newVal) => {
structure.value = parseTable(newVal)
},
{ deep: true, immediate: true }
)
// 监听全局选中状态节点的变化并驱动选中单元格的同步
watch(
() => store.selectedNodeId,
(newId) => {
if (!newId) {
selectedCellIds.value = []
return
}
const allCells: TableCellModel[] = []
for (const row of [...structure.value.theadRows, ...structure.value.tbodyRows]) {
allCells.push(...row.cells)
}
const matchedCell = allCells.find((c) => c.id === newId || c.paragraphs.some((p) => p.id === newId))
if (matchedCell) {
if (selectedCellIds.value.includes(matchedCell.id)) {
return
}
selectedCellIds.value = [matchedCell.id]
} else {
selectedCellIds.value = []
}
}
)
const selectedCells = computed(() => {
const list: TableCellModel[] = []
const allCells: TableCellModel[] = []
for (const row of [...structure.value.theadRows, ...structure.value.tbodyRows]) {
allCells.push(...row.cells)
}
selectedCellIds.value.forEach((id) => {
const found = allCells.find((c) => c.id === id)
if (found && !found.isDummy) {
list.push(found)
}
})
return list
})
const canMergeSelected = computed(() => {
if (selectedCellIds.value.length < 2) return false
const cells = selectedCells.value
if (cells.length < 2) return false
let isThead = false
let isTbody = false
cells.forEach((c) => {
if (structure.value.theadRows.some((row) => row.cells.some((cell) => cell.id === c.id))) {
isThead = true
}
if (structure.value.tbodyRows.some((row) => row.cells.some((cell) => cell.id === c.id))) {
isTbody = true
}
})
if (isThead && isTbody) return false
const targetRows = isThead ? structure.value.theadRows : structure.value.tbodyRows
const cols = structure.value.cols
const rows = targetRows.length
let minCol = Infinity
let maxCol = -Infinity
let minRow = Infinity
let maxRow = -Infinity
let actualArea = 0
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
if (cIdx < minCol) minCol = cIdx
if (cIdx + cSpan - 1 > maxCol) maxCol = cIdx + cSpan - 1
if (rIdx < minRow) minRow = rIdx
if (rIdx + rSpan - 1 > maxRow) maxRow = rIdx + rSpan - 1
actualArea += cSpan * rSpan
}
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
})
const canSplitSelected = computed(() => {
if (selectedCellIds.value.length !== 1) return false
const cell = selectedCells.value[0]
if (!cell) return false
return (cell.colspan ?? 1) > 1 || (cell.rowspan ?? 1) > 1
})
const selectCellLogic = (cell: TableCellModel, e: MouseEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl) {
if (selectedCellIds.value.includes(cell.id)) {
selectedCellIds.value = selectedCellIds.value.filter((id) => id !== cell.id)
} else {
selectedCellIds.value.push(cell.id)
}
if (selectedCellIds.value.length > 0) {
store.setSelectedNodeId(selectedCellIds.value[selectedCellIds.value.length - 1])
}
} else {
selectedCellIds.value = [cell.id]
store.setSelectedNodeId(cell.id)
}
}
const handleCellClick = (cell: TableCellModel, e: MouseEvent) => {
selectCellLogic(cell, e)
}
const handleParaClick = (para: any, cell: TableCellModel, e: MouseEvent) => {
store.setSelectedNodeId(para.id)
selectCellLogic(cell, e)
}
const handleCellBlur = (cellId: string, e: FocusEvent) => {
const el = e.target as HTMLElement
const val = el.innerText || ''
updateCellText(props.node, cellId, val)
}
const handleMergeMultiple = () => {
if (!canMergeSelected.value) return
const cells = selectedCells.value
let isThead = false
cells.forEach((c) => {
if (structure.value.theadRows.some((row) => row.cells.some((cell) => cell.id === c.id))) {
isThead = true
}
})
let minCol = Infinity
let maxCol = -Infinity
let minRow = Infinity
let maxRow = -Infinity
cells.forEach((cell) => {
const cIdx = cell.colIdx ?? 0
const rIdx = cell.rowIdx ?? 0
const cSpan = cell.colspan ?? 1
const rSpan = cell.rowspan ?? 1
if (cIdx < minCol) minCol = cIdx
if (cIdx + cSpan - 1 > maxCol) maxCol = cIdx + cSpan - 1
if (rIdx < minRow) minRow = rIdx
if (rIdx + rSpan - 1 > maxRow) maxRow = rIdx + rSpan - 1
})
const targetRows = isThead ? structure.value.theadRows : structure.value.tbodyRows
const topLeftCell = targetRows[minRow]?.cells.find((c) => c.colIdx === minCol)
if (topLeftCell) {
selectedCellIds.value = [topLeftCell.id]
store.setSelectedNodeId(topLeftCell.id)
}
mergeMultipleCells(props.node, selectedCellIds.value, minCol, maxCol, minRow, maxRow, isThead)
}
const handleSplitSelected = () => {
if (selectedCellIds.value.length !== 1) return
const cellId = selectedCellIds.value[0]
splitCell(props.node, cellId)
selectedCellIds.value = [cellId]
store.setSelectedNodeId(cellId)
}
const handleRowDelete = async (rowId: string) => {
try {
await window.$dialog.warning({
title: '敏感删除确认',
content: '确定要删除这一行吗?该行内所有的单元格数据将被同时清除。'
})
deleteRow(props.node, rowId)
window.$message.success('删除行成功')
} catch (e) {
// 处理取消
}
}
const handleColumnDelete = async (colIndex: number) => {
try {
await window.$dialog.warning({
title: '敏感删除确认',
content: '确定要删除这一列吗?整列中所有行对应的单元格数据将被彻底清除!'
})
deleteColumn(props.node, colIndex)
window.$message.success('删除列成功')
} catch (e) {
// 处理取消
}
}
const rowAddOptions = computed(() => [
{
label: '在上方插入行',
key: 'above',
disabled: selectedCellIds.value.length === 0
},
{
label: '在下方插入行',
key: 'below',
disabled: selectedCellIds.value.length === 0
},
{
label: '在表格末尾追加行',
key: 'append'
}
])
const handleRowAddSelect = (key: string) => {
let section: 'THEAD' | 'TBODY' = 'TBODY'
let activeRowId: string | undefined = undefined
if (selectedCellIds.value.length > 0) {
const activeCellId = selectedCellIds.value[0]
for (const row of structure.value.theadRows) {
if (row.cells.some((c) => c.id === activeCellId)) {
section = 'THEAD'
activeRowId = row.id
break
}
}
for (const row of structure.value.tbodyRows) {
if (row.cells.some((c) => c.id === activeCellId)) {
section = 'TBODY'
activeRowId = row.id
break
}
}
}
if (key === 'above') {
addRow(props.node, section, activeRowId, false)
} else if (key === 'below') {
addRow(props.node, section, activeRowId, true)
} else {
addRow(props.node, section)
}
}
const colAddOptions = computed(() => [
{
label: '在左侧插入列',
key: 'left',
disabled: selectedCellIds.value.length === 0
},
{
label: '在右侧插入列',
key: 'right',
disabled: selectedCellIds.value.length === 0
},
{
label: '在表格最右侧追加列',
key: 'append'
}
])
const handleColAddSelect = (key: string) => {
let activeColIdx: number | undefined = undefined
if (selectedCellIds.value.length > 0) {
const activeCellId = selectedCellIds.value[0]
const allCells = [...structure.value.theadRows, ...structure.value.tbodyRows].flatMap((r) => r.cells)
const matched = allCells.find((c) => c.id === activeCellId)
if (matched) {
activeColIdx = matched.colIdx
}
}
if (key === 'left') {
addColumn(props.node, activeColIdx, false)
} else if (key === 'right') {
addColumn(props.node, activeColIdx, true)
} else {
addColumn(props.node)
}
}
const isNodeSelected = (targetId: string): boolean => {
const selectedId = store.selectedNodeId
if (!selectedId) return false
if (selectedId === targetId) return true
// 使用 store.nodeMap 进行 O(depth) 的向上祖先查找,避免递归遍历
let curr = store.nodeMap.get(selectedId)
while (curr) {
if (curr.node.id === targetId) return true
curr = curr.parent ? store.nodeMap.get(curr.parent.id) : undefined
}
return false
}
const isCellSelected = (cell: any, colIdx: number): boolean => {
if (selectedCellIds.value.includes(cell.id)) return true
const selectedId = store.selectedNodeId
if (!selectedId) return false
if (cell.paragraphs && cell.paragraphs.some((p: any) => p.id === selectedId)) return true
// 从全局扁平 map 中直接获取选中的节点对象,时间复杂度 O(1)
const selectedNodeEntry = store.nodeMap.get(selectedId)
if (!selectedNodeEntry) return false
const selectedNode = selectedNodeEntry.node
// 验证选中节点是否属于当前表格组件的子孙节点,避免跨表格的高亮误判
let isDescendantOfTable = false
let curr: { node: XmlNode; parent: { id: string } | null } | undefined = selectedNodeEntry
while (curr) {
if (curr.node.id === props.node.id) {
isDescendantOfTable = true
break
}
curr = curr.parent ? store.nodeMap.get(curr.parent.id) : undefined
}
if (!isDescendantOfTable) return false
if (selectedNode.tagName === 'COLSPEC') {
const colNum = parseInt(selectedNode.attributes.COLNUM || '0', 10)
const colName = (selectedNode.attributes.COLNAME || '').toLowerCase()
if (colNum && colIdx + 1 === colNum) return true
const cellColName = (cell.attributes.COLNAME || cell.attributes.colname || '').toLowerCase()
if (colName && cellColName === colName) return true
}
if (selectedNode.tagName === 'SPANSPEC') {
const spanName = (selectedNode.attributes.SPANNAME || '').toLowerCase()
const namest = (selectedNode.attributes.NAMEST || '').toLowerCase()
const nameend = (selectedNode.attributes.NAMEEND || '').toLowerCase()
const cellSpanName = (cell.attributes.SPANNAME || cell.attributes.spanname || '').toLowerCase()
const cellNamest = (cell.attributes.NAMEST || cell.attributes.namest || '').toLowerCase()
const cellNameend = (cell.attributes.NAMEEND || cell.attributes.nameend || '').toLowerCase()
if (spanName && cellSpanName === spanName) return true
if (namest && nameend && cellNamest === namest && cellNameend === nameend) return true
}
return false
} }
return { return {
structure,
selectedCellIds,
rowAddOptions,
colAddOptions,
canMergeSelected,
canSplitSelected,
selectedCells,
handleCellClick,
handleParaClick,
handleCellBlur,
handleRowAddSelect,
handleColAddSelect,
handleMergeMultiple,
handleSplitSelected,
handleRowDelete,
handleColumnDelete,
isNodeSelected,
isCellSelected,
// 底层逻辑
parseTable, parseTable,
updateCellText, updateCellText,
addRow, addRow,
deleteRow, deleteRow,
addColumn, addColumn,
deleteColumn deleteColumn,
mergeMultipleCells,
splitCell
} }
} }
<template> <template>
<div class="flex-1 flex flex-col p-4 space-y-4 overflow-auto bg-transparent min-h-0"> <div class="flex-1 flex flex-col p-4 space-y-4 overflow-auto bg-transparent min-h-0" @click.stop>
<!-- 表格工具栏 --> <!-- 表格工具栏 -->
<div class="flex items-center justify-between pb-2 border-b border-divider"> <div class="flex items-center justify-between pb-2 border-b border-divider">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
...@@ -8,14 +8,51 @@ ...@@ -8,14 +8,51 @@
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<CommonButton size="tiny" secondary type="primary" @click="handleColumnAdd"> <!-- 单元格合并与拆分快捷工具 -->
<template #icon><n-icon><add-outline /></n-icon></template> <template v-if="selectedCellIds.length > 0">
<div class="flex items-center space-x-1.5 mr-1" v-if="selectedCellIds.length >= 2 || canSplitSelected">
<CommonButton
v-if="selectedCellIds.length >= 2"
size="tiny"
secondary
:type="canMergeSelected ? 'primary' : 'default'"
:disabled="!canMergeSelected"
@click="handleMergeMultiple"
>
合并单元格
</CommonButton>
<span v-if="selectedCellIds.length >= 2 && !canMergeSelected" class="text-[11px] text-error select-none">
(选中的单元格不符合合并矩形规则)
</span>
<CommonButton v-if="canSplitSelected" size="tiny" secondary @click="handleSplitSelected">拆分单元格</CommonButton>
</div>
<n-divider vertical class="mx-1" v-if="selectedCellIds.length >= 2 || canSplitSelected" />
</template>
<n-dropdown
trigger="click"
:options="colAddOptions"
@select="handleColAddSelect"
>
<CommonButton size="tiny" secondary type="primary">
<template #icon>
<n-icon><add-outline /></n-icon>
</template>
添加列 添加列
</CommonButton> </CommonButton>
<CommonButton size="tiny" secondary type="primary" @click="handleRowAdd('TBODY')"> </n-dropdown>
<template #icon><n-icon><add-outline /></n-icon></template> <n-dropdown
trigger="click"
:options="rowAddOptions"
@select="handleRowAddSelect"
>
<CommonButton size="tiny" secondary type="primary">
<template #icon>
<n-icon><add-outline /></n-icon>
</template>
添加行 添加行
</CommonButton> </CommonButton>
</n-dropdown>
</div> </div>
</div> </div>
...@@ -25,16 +62,11 @@ ...@@ -25,16 +62,11 @@
class="w-full border-collapse text-sm table-fixed min-w-[600px] transition-all" class="w-full border-collapse text-sm table-fixed min-w-[600px] transition-all"
:data-node-id="structure.tgroupId" :data-node-id="structure.tgroupId"
:class="[isNodeSelected(structure.tgroupId) ? 'ring-2 ring-primary ring-offset-2 rounded' : '']" :class="[isNodeSelected(structure.tgroupId) ? 'ring-2 ring-primary ring-offset-2 rounded' : '']"
@click="editorStore.setSelectedNodeId(structure.tgroupId)"
> >
<!-- 表头规格 -->
<colgroup> <colgroup>
<!-- 行选择列 --> <col class="w-12" />
<col class="w-[45px]" /> <col v-for="(_, idx) in structure.cols" :key="idx" />
<!-- 数据列 --> <col class="w-12" />
<col v-for="i in structure.cols" :key="i" class="min-w-[120px]" />
<!-- 操作列 -->
<col class="w-[60px]" />
</colgroup> </colgroup>
<!-- THEAD 渲染 --> <!-- THEAD 渲染 -->
...@@ -42,62 +74,67 @@ ...@@ -42,62 +74,67 @@
v-if="structure.theadRows.length > 0" v-if="structure.theadRows.length > 0"
:data-node-id="structure.theadId" :data-node-id="structure.theadId"
@click.stop="editorStore.setSelectedNodeId(structure.theadId)" @click.stop="editorStore.setSelectedNodeId(structure.theadId)"
class="transition-all"
:class="[isNodeSelected(structure.theadId) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/5' : '']"
> >
<tr <tr
v-for="(row, rIdx) in structure.theadRows" v-for="(row, rIdx) in structure.theadRows"
:key="row.id" :key="row.id"
:data-node-id="row.id" :data-node-id="row.id"
class="border-b border-divider hover:bg-fill-3 group transition-colors cursor-pointer" class="border-b border-divider hover:bg-fill-3 group transition-colors cursor-pointer transition-all"
:class="[isNodeSelected(row.id) ? 'bg-primary/10' : '']" :class="[isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '']"
@click.stop="editorStore.setSelectedNodeId(row.id)" @click.stop="editorStore.setSelectedNodeId(row.id)"
> >
<!-- 表头行选择号 --> <!-- 表头行选择号 -->
<th <th
class="p-2 border border-divider text-center select-none cursor-pointer transition-colors bg-fill-4 text-color3 font-bold" class="p-2 border border-divider text-center select-none cursor-pointer transition-colors bg-fill-4 text-color3 font-bold"
:class="[ :class="[isNodeSelected(row.id) ? 'bg-primary text-white' : 'hover:bg-fill-3']"
isNodeSelected(row.id)
? 'bg-primary text-white'
: 'hover:bg-fill-3'
]"
@click.stop="editorStore.setSelectedNodeId(row.id)" @click.stop="editorStore.setSelectedNodeId(row.id)"
> >
H{{ structure.theadRows.length > 1 ? rIdx + 1 : '' }} H{{ structure.theadRows.length > 1 ? rIdx + 1 : '' }}
</th> </th>
<th <th
v-for="(cell, cIdx) in row.cells" v-for="cell in row.cells.filter((c) => c.shouldRender !== false)"
:key="cell.id" :key="cell.id"
:colspan="cell.colspan"
:rowspan="cell.rowspan"
:data-node-id="cell.id" :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" class="p-2 text-left font-bold bg-fill-4 border border-divider text-color1 transition-colors cursor-pointer relative"
:class="[ :class="[
isNodeSelected(cell.id) isCellSelected(cell, cell.colIdx ?? 0) ? 'bg-primary/10 outline outline-2 outline-primary outline-offset-[-2px]' : '',
? 'bg-primary/5 outline outline-2 outline-primary outline-offset-[-2px]' isNodeSelected(structure.theadId) ? 'bg-primary/10 border-primary/40' : ''
: '',
isNodeSelected(structure.theadId)
? 'bg-primary/10 border-primary/40'
: ''
]" ]"
@click.stop="editorStore.setSelectedNodeId(cell.id)" @click.stop="handleCellClick(cell, $event)"
> >
<!-- 如果 ENTRY 含有复杂子节点(UNLIST 等),用 DocNodeRenderer 渲染全部子节点 -->
<template v-if="cell.hasComplexChildren && cell.rawNode">
<DocNodeRenderer
v-for="child in cell.rawNode!.children"
:key="child.id"
:node="child"
:parent="cell.rawNode"
/>
</template>
<!-- 否则渲染普通 PARA 文本段落 -->
<template v-else>
<div <div
v-for="para in cell.paragraphs" v-for="para in cell.paragraphs"
:key="para.id" :key="para.id"
:data-node-id="para.id" :data-node-id="para.id"
contenteditable="true" contenteditable="true"
class="w-full min-h-[28px] px-1.5 py-1 rounded focus:outline-none focus:ring-1 focus:ring-primary focus:bg-fill-1 text-color1 leading-relaxed transition-all my-0.5" :class="[isNodeSelected(para.id) ? 'ring-2 ring-primary bg-primary/5 font-bold' : 'hover:bg-fill-3']"
:class="[ @click.stop="handleParaClick(para, cell, $event)"
isNodeSelected(para.id)
? 'ring-2 ring-primary bg-primary/5 font-bold'
: 'hover:bg-fill-3'
]"
@click.stop="editorStore.setSelectedNodeId(para.id)"
@blur="(e) => handleCellBlur(para.id, e)" @blur="(e) => handleCellBlur(para.id, e)"
v-text="para.text" v-text="para.text"
></div> ></div>
</template>
</th> </th>
<!-- 表头操作栏 --> <!-- 表头操作栏 -->
<th class="p-2 border border-divider bg-fill-4 text-center"> <th class="p-2 border border-divider bg-fill-4 text-center">
<CommonButton size="tiny" quaternary circle type="error" @click.stop="handleRowDelete(row.id)"> <CommonButton size="tiny" quaternary circle type="error" @click.stop="handleRowDelete(row.id)">
<template #icon><n-icon><trash-outline /></n-icon></template> <template #icon>
<n-icon><trash-outline /></n-icon>
</template>
</CommonButton> </CommonButton>
</th> </th>
</tr> </tr>
...@@ -107,75 +144,76 @@ ...@@ -107,75 +144,76 @@
<tbody <tbody
:data-node-id="structure.tbodyId" :data-node-id="structure.tbodyId"
@click.stop="editorStore.setSelectedNodeId(structure.tbodyId)" @click.stop="editorStore.setSelectedNodeId(structure.tbodyId)"
class="transition-all"
:class="[isNodeSelected(structure.tbodyId) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/5' : '']"
> >
<tr <tr
v-for="(row, rIdx) in structure.tbodyRows" v-for="(row, rIdx) in structure.tbodyRows"
:key="row.id" :key="row.id"
:data-node-id="row.id" :data-node-id="row.id"
class="border-b border-divider hover:bg-fill-3 group transition-colors cursor-pointer" class="border-b border-divider hover:bg-fill-3 group transition-colors cursor-pointer transition-all"
:class="[isNodeSelected(row.id) ? 'bg-primary/10' : '']" :class="[isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '']"
@click.stop="editorStore.setSelectedNodeId(row.id)" @click.stop="editorStore.setSelectedNodeId(row.id)"
> >
<!-- 行号选择单元格 --> <!-- 行号选择单元格 -->
<td <td
class="p-2 border border-divider text-center select-none cursor-pointer transition-colors font-bold" class="p-2 border border-divider text-center select-none cursor-pointer transition-colors font-bold"
:class="[ :class="[isNodeSelected(row.id) ? 'bg-primary text-white' : 'bg-fill-4 text-color3 hover:bg-fill-3']"
isNodeSelected(row.id)
? 'bg-primary text-white'
: 'bg-fill-4 text-color3 hover:bg-fill-3'
]"
@click.stop="editorStore.setSelectedNodeId(row.id)" @click.stop="editorStore.setSelectedNodeId(row.id)"
> >
{{ rIdx + 1 }} {{ rIdx + 1 }}
</td> </td>
<td <td
v-for="cell in row.cells" v-for="cell in row.cells.filter((c) => c.shouldRender !== false)"
:key="cell.id" :key="cell.id"
:colspan="cell.colspan"
:rowspan="cell.rowspan"
:data-node-id="cell.id" :data-node-id="cell.id"
class="p-2 border border-divider text-color2 transition-colors cursor-pointer relative" class="p-2 border border-divider text-color2 transition-colors cursor-pointer relative"
:class="[ :class="[
isNodeSelected(cell.id) isCellSelected(cell, cell.colIdx ?? 0) ? 'bg-primary/10 outline outline-2 outline-primary outline-offset-[-2px]' : '',
? 'bg-primary/5 outline outline-2 outline-primary outline-offset-[-2px]' isNodeSelected(structure.tbodyId) ? 'bg-primary/5 border-primary/30' : ''
: '',
isNodeSelected(structure.tbodyId)
? 'bg-primary/5 border-primary/30'
: ''
]" ]"
@click.stop="editorStore.setSelectedNodeId(cell.id)" @click.stop="handleCellClick(cell, $event)"
> >
<!-- 如果 ENTRY 含有复杂子节点(UNLIST 等),用 DocNodeRenderer 渲染全部子节点 -->
<template v-if="cell.hasComplexChildren && cell.rawNode">
<DocNodeRenderer
v-for="child in cell.rawNode!.children"
:key="child.id"
:node="child"
:parent="cell.rawNode"
/>
</template>
<!-- 否则渲染普通 PARA 文本段落 -->
<template v-else>
<div <div
v-for="para in cell.paragraphs" v-for="para in cell.paragraphs"
:key="para.id" :key="para.id"
:data-node-id="para.id" :data-node-id="para.id"
contenteditable="true" contenteditable="true"
class="w-full min-h-[28px] px-1.5 py-1 rounded focus:outline-none focus:ring-1 focus:ring-primary focus:bg-fill-1 text-color2 leading-relaxed transition-all my-0.5" :class="[isNodeSelected(para.id) ? 'ring-2 ring-primary bg-primary/5' : 'hover:bg-fill-3']"
:class="[ @click.stop="handleParaClick(para, cell, $event)"
isNodeSelected(para.id)
? 'ring-2 ring-primary bg-primary/5'
: 'hover:bg-fill-3'
]"
@click.stop="editorStore.setSelectedNodeId(para.id)"
@blur="(e) => handleCellBlur(para.id, e)" @blur="(e) => handleCellBlur(para.id, e)"
v-text="para.text" v-text="para.text"
></div> ></div>
</template>
</td> </td>
<!-- 行删除按钮 --> <!-- 行删除按钮 -->
<td class="p-2 border border-divider text-center"> <td class="p-2 border border-divider text-center">
<CommonButton size="tiny" quaternary circle type="error" @click.stop="handleRowDelete(row.id)"> <CommonButton size="tiny" quaternary circle type="error" @click.stop="handleRowDelete(row.id)">
<template #icon><n-icon><trash-outline /></n-icon></template> <template #icon>
<n-icon><trash-outline /></n-icon>
</template>
</CommonButton> </CommonButton>
</td> </td>
</tr> </tr>
<!-- 列操作管理辅助行 --> <!-- 列删除快捷按钮行 -->
<tr class="hover:bg-transparent"> <tr class="hover:bg-transparent">
<!-- 行号列占位 --> <!-- 行号列占位 -->
<td class="p-1 bg-transparent border-none"></td> <td class="p-1 bg-transparent border-none"></td>
<td <td v-for="(_, cIdx) in structure.cols" :key="cIdx" class="p-1 text-center bg-transparent border-none">
v-for="(_, cIdx) in structure.cols"
:key="cIdx"
class="p-1 text-center bg-transparent border-none"
>
<CommonButton <CommonButton
size="tiny" size="tiny"
quaternary quaternary
...@@ -184,7 +222,9 @@ ...@@ -184,7 +222,9 @@
:disabled="structure.cols <= 1" :disabled="structure.cols <= 1"
@click="handleColumnDelete(cIdx)" @click="handleColumnDelete(cIdx)"
> >
<template #icon><n-icon><trash-outline /></n-icon></template> <template #icon>
<n-icon><trash-outline /></n-icon>
</template>
</CommonButton> </CommonButton>
</td> </td>
<td class="p-1 bg-transparent border-none"></td> <td class="p-1 bg-transparent border-none"></td>
...@@ -201,92 +241,33 @@ import type { XmlNode } from '@/types/xmlNode' ...@@ -201,92 +241,33 @@ import type { XmlNode } from '@/types/xmlNode'
import { useTableEditor } from './functionals' import { useTableEditor } from './functionals'
import { CELL_EDIT_TIP } from './constants' import { CELL_EDIT_TIP } from './constants'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { TableCellModel } from './constants'
import DocNodeRenderer from '@/views/editor/components/DocNodeRenderer/index.vue'
const props = defineProps<{ const props = defineProps<{
node: XmlNode node: XmlNode
}>() }>()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const { parseTable, updateCellText, addRow, deleteRow, addColumn, deleteColumn } = useTableEditor() const {
structure,
const structure = ref(parseTable(props.node)) selectedCellIds,
rowAddOptions,
watch(() => props.node, (newVal) => { colAddOptions,
structure.value = parseTable(newVal) canMergeSelected,
}, { deep: true, immediate: true }) canSplitSelected,
handleCellClick,
const isNodeSelected = (targetId: string): boolean => { handleParaClick,
const selectedId = editorStore.selectedNodeId handleCellBlur,
if (!selectedId) return false handleRowAddSelect,
if (selectedId === targetId) return true handleColAddSelect,
handleMergeMultiple,
const findNode = (n: XmlNode, id: string): XmlNode | null => { handleSplitSelected,
if (n.id === id) return n handleRowDelete,
if (n.children) { handleColumnDelete,
for (const child of n.children) { isNodeSelected,
const found = findNode(child, id) isCellSelected
if (found) return found } = useTableEditor(props)
}
}
return null
}
const targetNode = findNode(props.node, targetId)
if (!targetNode) return false
const isDescendant = (parent: XmlNode, id: string): boolean => {
if (parent.children) {
for (const child of parent.children) {
if (child.id === id) return true
if (isDescendant(child, id)) return true
}
}
return false
}
return isDescendant(targetNode, selectedId)
}
function handleCellBlur(cellId: string, e: FocusEvent) {
const el = e.target as HTMLElement
const val = el.innerText || ''
updateCellText(props.node, cellId, val)
}
function handleRowAdd(section: 'THEAD' | 'TBODY') {
addRow(props.node, section)
}
async function handleRowDelete(rowId: string) {
try {
await window.$dialog.warning({
title: '敏感删除确认',
content: '确定要删除这一行吗?该行内所有的单元格数据将被同时清除。'
})
deleteRow(props.node, rowId)
window.$message.success('删除行成功')
} catch (e) {
// 取消删除
}
}
function handleColumnAdd() {
addColumn(props.node)
}
async function handleColumnDelete(colIndex: number) {
try {
await window.$dialog.warning({
title: '敏感删除确认',
content: '确定要删除这一列吗?整列中所有行对应的单元格数据将被彻底清除!'
})
deleteColumn(props.node, colIndex)
window.$message.success('删除列成功')
} catch (e) {
// 取消删除
}
}
</script> </script>
<style scoped> <style scoped></style>
</style>
import { useThemeVars } from 'naive-ui'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { import {
isMixedContentElement, isMixedContentElement,
getElementAttributes, getElementAttributes,
createDefaultAttributes createDefaultAttributes,
getElementRule,
isTextOnlyElement,
isEmptyElement,
getAllowedChildren
} from '@/utils/dtdManager' } from '@/utils/dtdManager'
import type { XmlNode, MixedContentItem } from '@/types/xmlNode' import type { XmlNode, MixedContentItem } from '@/types/xmlNode'
import type { SliceItem } from '../constants' import type { SliceItem } from '../constants'
...@@ -11,22 +16,52 @@ import { DEFAULT_INSERT_TEXT_VAL } from '../constants' ...@@ -11,22 +16,52 @@ import { DEFAULT_INSERT_TEXT_VAL } from '../constants'
/** /**
* 内容编辑面板 (TextBlockEditor) 组件专用 Hook 逻辑 * 内容编辑面板 (TextBlockEditor) 组件专用 Hook 逻辑
*/ */
export function useTextBlockEditor() { export function useTextBlockEditor(getNode: () => XmlNode) {
const store = useEditorStore() const store = useEditorStore()
const themeVars = useThemeVars()
const parseSlices = (node: XmlNode): SliceItem[] => { const node = computed(getNode)
const isMixed = isMixedContentElement(node.tagName)
if (!isMixed) return [] const dtdRule = computed(() => getElementRule(node.value.tagName))
const dtdDescription = computed(() => dtdRule.value?.description || '')
const isTextOnly = computed(() => isTextOnlyElement(node.value.tagName))
const isMixed = computed(() => isMixedContentElement(node.value.tagName))
const isEmpty = computed(() => isEmptyElement(node.value.tagName))
const pureTextVal = ref('')
watch(
() => node.value.id,
() => {
pureTextVal.value = node.value.textContent || ''
},
{ immediate: true }
)
const handleTextBlur = (e: FocusEvent) => {
const el = e.target as HTMLElement
const val = el.innerText || ''
if (val !== node.value.textContent) {
store.updateSelectedNodeText(val)
}
}
const slices = ref<SliceItem[]>([])
// 内部解析切片逻辑
const parseSlices = (nodeVal: XmlNode): SliceItem[] => {
const isMixedVal = isMixedContentElement(nodeVal.tagName)
if (!isMixedVal) return []
const list: SliceItem[] = [] const list: SliceItem[] = []
if (node.mixedContent.length === 0 && node.textContent) { if (nodeVal.mixedContent.length === 0 && nodeVal.textContent) {
list.push({ list.push({
type: 'text', type: 'text',
text: node.textContent, text: nodeVal.textContent,
attributes: {} attributes: {}
}) })
} else { } else {
for (const item of node.mixedContent) { for (const item of nodeVal.mixedContent) {
if (item.type === 'text') { if (item.type === 'text') {
list.push({ list.push({
type: 'text', type: 'text',
...@@ -34,7 +69,7 @@ export function useTextBlockEditor() { ...@@ -34,7 +69,7 @@ export function useTextBlockEditor() {
attributes: {} attributes: {}
}) })
} else if (item.type === 'element' && item.nodeId) { } else if (item.type === 'element' && item.nodeId) {
const child = node.children.find(c => c.id === item.nodeId) const child = nodeVal.children.find(c => c.id === item.nodeId)
if (child) { if (child) {
list.push({ list.push({
type: 'element', type: 'element',
...@@ -49,11 +84,21 @@ export function useTextBlockEditor() { ...@@ -49,11 +84,21 @@ export function useTextBlockEditor() {
return list return list
} }
const syncSlices = (nodeId: string, slicesList: SliceItem[]): void => { watch(
() => node.value.id,
() => {
if (isMixed.value) {
slices.value = parseSlices(node.value)
}
},
{ immediate: true }
)
const syncSlices = (): void => {
const newMixedContent: MixedContentItem[] = [] const newMixedContent: MixedContentItem[] = []
const newChildren: XmlNode[] = [] const newChildren: XmlNode[] = []
for (const slice of slicesList) { for (const slice of slices.value) {
if (slice.type === 'text') { if (slice.type === 'text') {
newMixedContent.push({ newMixedContent.push({
type: 'text', type: 'text',
...@@ -72,7 +117,7 @@ export function useTextBlockEditor() { ...@@ -72,7 +117,7 @@ export function useTextBlockEditor() {
children: [], children: [],
textContent: '', textContent: '',
mixedContent: [], mixedContent: [],
parentId: nodeId parentId: node.value.id
}) })
} }
} }
...@@ -80,41 +125,106 @@ export function useTextBlockEditor() { ...@@ -80,41 +125,106 @@ export function useTextBlockEditor() {
store.updateSelectedNodeMixedContent(newMixedContent, newChildren) store.updateSelectedNodeMixedContent(newMixedContent, newChildren)
} }
const insertSlice = (slicesList: SliceItem[], key: string, index: number): SliceItem[] => { const handleSliceTextBlur = (index: number, e: FocusEvent) => {
const newList = [...slicesList] const el = e.target as HTMLElement
const newText = el.innerText || ''
if (slices.value[index].text !== newText) {
slices.value[index].text = newText
syncSlices()
}
}
const insertOptions = computed(() => {
const options: any[] = [{ label: '插入文本片段', key: 'insert-text' }]
const allowed = getAllowedChildren(node.value.tagName)
if (allowed.length > 0) {
options.push({ type: 'divider', key: 'div1' })
allowed.forEach((tag) => {
options.push({
label: `行内元素: ${tag}`,
key: `insert-element-${tag}`
})
})
}
return options
})
const handleInsertSelect = (key: string, index: number) => {
if (key === 'insert-text') { if (key === 'insert-text') {
newList.splice(index, 0, { slices.value.splice(index, 0, {
type: 'text', type: 'text',
text: DEFAULT_INSERT_TEXT_VAL, text: DEFAULT_INSERT_TEXT_VAL,
attributes: {} attributes: {}
}) })
} else if (key.startsWith('insert-element-')) { } else if (key.startsWith('insert-element-')) {
const tag = key.replace('insert-element-', '') const tag = key.replace('insert-element-', '')
newList.splice(index, 0, { slices.value.splice(index, 0, {
type: 'element', type: 'element',
elementTagName: tag, elementTagName: tag,
attributes: createDefaultAttributes(tag), attributes: createDefaultAttributes(tag),
attributesDef: getElementAttributes(tag) attributesDef: getElementAttributes(tag)
}) })
} }
return newList syncSlices()
}
const handleAddHeaderSlice = () => {
slices.value.splice(0, 0, {
type: 'text',
text: DEFAULT_INSERT_TEXT_VAL,
attributes: {}
})
syncSlices()
}
const deleteSlice = async (index: number) => {
try {
await window.$dialog.warning({
title: '敏感删除确认',
content: '确定要删除该片段吗?该操作会将该段文本或行内子节点及其所有关联属性彻底清除。'
})
slices.value.splice(index, 1)
syncSlices()
window.$message.success('删除片段成功')
} catch (e) {
// 取消删除
}
} }
const moveSlice = (slicesList: SliceItem[], index: number, direction: 'up' | 'down'): SliceItem[] => { const handleMoveSlice = (index: number, direction: 'up' | 'down') => {
const newList = [...slicesList]
const target = direction === 'up' ? index - 1 : index + 1 const target = direction === 'up' ? index - 1 : index + 1
if (target < 0 || target >= newList.length) return newList if (target < 0 || target >= slices.value.length) return
const temp = slices.value[index]
slices.value[index] = slices.value[target]
slices.value[target] = temp
syncSlices()
}
const temp = newList[index] const formatAttributes = (attrs: Record<string, string>): string => {
newList[index] = newList[target] return Object.entries(attrs)
newList[target] = temp .map(([k, v]) => `${k}="${v}"`)
return newList .join(' ')
} }
return { return {
parseSlices, themeVars,
syncSlices, dtdDescription,
insertSlice, isTextOnly,
moveSlice isMixed,
isEmpty,
pureTextVal,
slices,
insertOptions,
handleTextBlur,
handleSliceTextBlur,
handleInsertSelect,
handleAddHeaderSlice,
deleteSlice,
handleMoveSlice,
formatAttributes,
syncMixedContent: syncSlices
} }
} }
...@@ -6,9 +6,7 @@ ...@@ -6,9 +6,7 @@
<n-tag type="info" size="small">{{ node.tagName }}</n-tag> <n-tag type="info" size="small">{{ node.tagName }}</n-tag>
<span class="text-xs text-color3">{{ dtdDescription }}</span> <span class="text-xs text-color3">{{ dtdDescription }}</span>
</div> </div>
<div v-if="isMixed" class="text-xs text-color3"> <div v-if="isMixed" class="text-xs text-color3">混合内容节点(支持嵌入行内元素)</div>
混合内容节点(支持嵌入行内元素)
</div>
</div> </div>
<!-- 情况 1:纯文本编辑 --> <!-- 情况 1:纯文本编辑 -->
...@@ -29,7 +27,9 @@ ...@@ -29,7 +27,9 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs font-bold text-color2">混合内容片段流:</span> <span class="text-xs font-bold text-color2">混合内容片段流:</span>
<CommonButton size="tiny" secondary type="primary" @click="handleAddHeaderSlice"> <CommonButton size="tiny" secondary type="primary" @click="handleAddHeaderSlice">
<template #icon><n-icon><add-outline /></n-icon></template> <template #icon>
<n-icon><add-outline /></n-icon>
</template>
头部插入片段 头部插入片段
</CommonButton> </CommonButton>
</div> </div>
...@@ -44,9 +44,7 @@ ...@@ -44,9 +44,7 @@
}" }"
> >
<!-- 片段序号 --> <!-- 片段序号 -->
<div class="text-xs text-color3 mt-1.5 w-6 select-none text-center"> <div class="text-xs text-color3 mt-1.5 w-6 select-none text-center">#{{ index + 1 }}</div>
#{{ index + 1 }}
</div>
<!-- 1. 文本片段 --> <!-- 1. 文本片段 -->
<div v-if="item.type === 'text'" class="flex-1 flex items-center space-x-2"> <div v-if="item.type === 'text'" class="flex-1 flex items-center space-x-2">
...@@ -77,12 +75,11 @@ ...@@ -77,12 +75,11 @@
<div class="grid grid-cols-2 gap-2 p-2 rounded bg-fill-3 border border-divider text-xs"> <div class="grid grid-cols-2 gap-2 p-2 rounded bg-fill-3 border border-divider text-xs">
<div v-for="(attrDef, attrName) in item.attributesDef" :key="attrName" class="flex items-center space-x-1"> <div v-for="(attrDef, attrName) in item.attributesDef" :key="attrName" class="flex items-center space-x-1">
<span class="text-color3 w-20 truncate">{{ attrName }}:</span> <span class="text-color3 w-20 truncate">{{ attrName }}:</span>
<n-select <CommonSelect
v-if="attrDef.enumValues && attrDef.enumValues.length > 0" v-if="attrDef.enumValues && attrDef.enumValues.length > 0"
v-model:value="item.attributes[attrName]" v-model:value="item.attributes[attrName]"
:options="attrDef.enumValues.map((v: any) => ({ label: v, value: v }))" :options="attrDef.enumValues.map((v: any) => ({ label: v, value: v }))"
size="tiny" size="tiny"
placeholder="选择"
class="flex-1" class="flex-1"
@update:value="syncMixedContent" @update:value="syncMixedContent"
/> />
...@@ -90,7 +87,6 @@ ...@@ -90,7 +87,6 @@
v-else v-else
v-model:value="item.attributes[attrName]" v-model:value="item.attributes[attrName]"
size="tiny" size="tiny"
placeholder="输入"
class="flex-1" class="flex-1"
@input="syncMixedContent" @input="syncMixedContent"
/> />
...@@ -101,26 +97,30 @@ ...@@ -101,26 +97,30 @@
<!-- 右侧控制区 --> <!-- 右侧控制区 -->
<div class="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div class="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<CommonButton size="tiny" quaternary circle @click="handleMoveSlice(index, 'up')" :disabled="index === 0"> <CommonButton size="tiny" quaternary circle @click="handleMoveSlice(index, 'up')" :disabled="index === 0">
<template #icon><n-icon><arrow-up-outline /></n-icon></template> <template #icon>
<n-icon><arrow-up-outline /></n-icon>
</template>
</CommonButton> </CommonButton>
<CommonButton size="tiny" quaternary circle @click="handleMoveSlice(index, 'down')" :disabled="index === slices.length - 1"> <CommonButton size="tiny" quaternary circle @click="handleMoveSlice(index, 'down')" :disabled="index === slices.length - 1">
<template #icon><n-icon><arrow-down-outline /></n-icon></template> <template #icon>
<n-icon><arrow-down-outline /></n-icon>
</template>
</CommonButton> </CommonButton>
<CommonButton size="tiny" quaternary circle type="error" @click="deleteSlice(index)"> <CommonButton size="tiny" quaternary circle type="error" @click="deleteSlice(index)">
<template #icon><n-icon><trash-outline /></n-icon></template> <template #icon>
<n-icon><trash-outline /></n-icon>
</template>
</CommonButton> </CommonButton>
</div> </div>
</div> </div>
<!-- 底部添加按钮 --> <!-- 底部添加按钮 -->
<div class="flex justify-center py-2"> <div class="flex justify-center py-2">
<n-dropdown <n-dropdown trigger="click" :options="insertOptions" @select="(key) => handleInsertSelect(key, slices.length)">
trigger="click"
:options="insertOptions"
@select="(key) => handleInsertSelect(key, slices.length)"
>
<CommonButton size="small" dashed type="primary"> <CommonButton size="small" dashed type="primary">
<template #icon><n-icon><add-outline /></n-icon></template> <template #icon>
<n-icon><add-outline /></n-icon>
</template>
追加片段 追加片段
</CommonButton> </CommonButton>
</n-dropdown> </n-dropdown>
...@@ -130,137 +130,47 @@ ...@@ -130,137 +130,47 @@
<!-- 情况 3:空节点 --> <!-- 情况 3:空节点 -->
<div v-else-if="isEmpty" class="flex-1 flex items-center justify-center"> <div v-else-if="isEmpty" class="flex-1 flex items-center justify-center">
<n-result status="info" title="空元素" description="此节点属于空元素类型,无需填写文本。请在上方属性面板配置其所需属性值。"> <n-result status="info" title="空元素" description="此节点属于空元素类型,无需填写文本。请在上方属性面板配置其所需属性值。"></n-result>
</n-result>
</div> </div>
<!-- 情况 4:容器节点 --> <!-- 情况 4:容器节点 -->
<div v-else class="flex-1 flex items-center justify-center"> <div v-else class="flex-1 flex items-center justify-center">
<n-result status="success" title="容器结构节点" description="此节点是文档树中的容器分类,无单独的文本内容。请在左侧节点树管理它的子节点。"> <n-result
</n-result> status="success"
title="容器结构节点"
description="此节点是文档树中的容器分类,无单独的文本内容。请在左侧节点树管理它的子节点。"
></n-result>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { AddOutline, TrashOutline, ArrowUpOutline, ArrowDownOutline } from '@vicons/ionicons5' import { AddOutline, TrashOutline, ArrowUpOutline, ArrowDownOutline } from '@vicons/ionicons5'
import type { DropdownOption } from 'naive-ui'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import {
isTextOnlyElement,
isMixedContentElement,
isEmptyElement,
getElementRule,
getAllowedChildren
} from '@/utils/dtdManager'
import { useEditorStore } from '@/store/editor'
import { useTextBlockEditor } from './functionals' import { useTextBlockEditor } from './functionals'
import type { SliceItem } from './constants'
const props = defineProps<{ const props = defineProps<{
node: XmlNode node: XmlNode
}>() }>()
const themeVars = useThemeVars() const {
const editorStore = useEditorStore() themeVars,
const { parseSlices, syncSlices, insertSlice, moveSlice } = useTextBlockEditor() dtdDescription,
isTextOnly,
const dtdRule = computed(() => getElementRule(props.node.tagName)) isMixed,
const dtdDescription = computed(() => dtdRule.value?.description || '') isEmpty,
pureTextVal,
const isTextOnly = computed(() => isTextOnlyElement(props.node.tagName)) slices,
const isMixed = computed(() => isMixedContentElement(props.node.tagName)) insertOptions,
const isEmpty = computed(() => isEmptyElement(props.node.tagName)) handleTextBlur,
handleSliceTextBlur,
const pureTextVal = ref('') handleInsertSelect,
watch(() => props.node.id, () => { handleAddHeaderSlice,
pureTextVal.value = props.node.textContent || '' deleteSlice,
}, { immediate: true }) handleMoveSlice,
formatAttributes,
function handleTextBlur(e: FocusEvent) { syncMixedContent
const el = e.target as HTMLElement } = useTextBlockEditor(() => props.node)
const val = el.innerText || ''
if (val !== props.node.textContent) {
editorStore.updateSelectedNodeText(val)
}
}
const slices = ref<SliceItem[]>([])
watch(() => props.node.id, () => {
if (isMixed.value) {
slices.value = parseSlices(props.node)
}
}, { immediate: true })
function formatAttributes(attrs: Record<string, string>): string {
return Object.entries(attrs)
.map(([k, v]) => `${k}="${v}"`)
.join(' ')
}
// 同步切片,委托给 Hook
function syncMixedContent() {
syncSlices(props.node.id, slices.value)
}
function handleSliceTextBlur(index: number, e: FocusEvent) {
const el = e.target as HTMLElement
const newText = el.innerText || ''
if (slices.value[index].text !== newText) {
slices.value[index].text = newText
syncMixedContent()
}
}
const insertOptions = computed<DropdownOption[]>(() => {
const options: DropdownOption[] = [
{ label: '插入文本片段', key: 'insert-text' }
]
const allowed = getAllowedChildren(props.node.tagName)
if (allowed.length > 0) {
options.push({ type: 'divider', key: 'div1' })
allowed.forEach(tag => {
options.push({
label: `行内元素: ${tag}`,
key: `insert-element-${tag}`
})
})
}
return options
})
// 插入片段,委托给 Hook
function handleInsertSelect(key: string, index: number) {
slices.value = insertSlice(slices.value, key, index)
syncMixedContent()
}
function handleAddHeaderSlice() {
slices.value = insertSlice(slices.value, 'insert-text', 0)
syncMixedContent()
}
async function deleteSlice(index: number) {
try {
await window.$dialog.warning({
title: '敏感删除确认',
content: '确定要删除该片段吗?该操作会将该段文本或行内子节点及其所有关联属性彻底清除。'
})
slices.value.splice(index, 1)
syncMixedContent()
window.$message.success('删除片段成功')
} catch (e) {
// 取消删除
}
}
function handleMoveSlice(index: number, direction: 'up' | 'down') {
slices.value = moveSlice(slices.value, index, direction)
syncMixedContent()
}
</script> </script>
<style scoped> <style scoped>
......
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { loadDtdSchema, getElementRule } from '@/utils/dtdManager' import { loadDtdSchema, getElementRule } from '@/utils/dtdManager'
import { parseXmlToTree, serializeTreeToXml } from '@/utils/xmlParser' import { parseXmlToTreeAsync, serializeTreeToXml } from '@/utils/xmlParser'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { DEFAULT_FILE_NAME } from '../constants' import { DEFAULT_FILE_NAME } from '../constants'
...@@ -15,22 +15,24 @@ import xmlText from '@/assets/file/AMEA-A282400-02-1_0_0.xml?raw' ...@@ -15,22 +15,24 @@ import xmlText from '@/assets/file/AMEA-A282400-02-1_0_0.xml?raw'
export function useEditor() { export function useEditor() {
const store = useEditorStore() const store = useEditorStore()
const initialize = (): void => { const initialize = async (): Promise<void> => {
window.$loading?.show('加载数据中…')
try { try {
loadDtdSchema(dtdJson as any) loadDtdSchema(dtdJson as any)
const tree = parseXmlToTree(xmlText) const tree = await parseXmlToTreeAsync(xmlText)
store.setXmlTree(tree) store.setXmlTree(tree)
window.$message.success('工卡 XML 数据与 DTD 规则加载成功')
} catch (e: any) { } catch (e: any) {
window.$notification.error(e.message || '加载 XML 配置文件出错,请检查语法', { window.$notification.error(e.message || '加载 XML 配置文件出错,请检查语法', {
title: '初始化失败' title: '初始化失败'
}) })
} finally {
window.$loading?.hide()
} }
} }
const save = (): void => { const save = (): void => {
if (!store.xmlTree) return if (!store.xmlTree) return
const xml = serializeTreeToXml(store.xmlTree) const xml = serializeTreeToXml(store.xmlTree, 0, true)
console.log('保存的 XML 数据:\n', xml) console.log('保存的 XML 数据:\n', xml)
window.$message.success('本地修改已保存(可查看浏览器控制台输出)') window.$message.success('本地修改已保存(可查看浏览器控制台输出)')
} }
...@@ -38,16 +40,14 @@ export function useEditor() { ...@@ -38,16 +40,14 @@ export function useEditor() {
const exportXml = (): void => { const exportXml = (): void => {
if (!store.xmlTree) return if (!store.xmlTree) return
try { try {
const xml = serializeTreeToXml(store.xmlTree) const xml = serializeTreeToXml(store.xmlTree, 0, true)
const blob = new Blob([xml], { type: 'application/xml;charset=utf-8;' }) const blob = new Blob([xml], { type: 'application/xml;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a') openDownloadModal({
link.href = url fileName: DEFAULT_FILE_NAME,
link.setAttribute('download', DEFAULT_FILE_NAME) title: '导出 XML',
document.body.appendChild(link) localBlob: blob
link.click() })
document.body.removeChild(link)
window.$message.success('工卡 XML 导出下载成功')
} catch (e: any) { } catch (e: any) {
window.$message.error(`导出失败: ${e.message}`) window.$message.error(`导出失败: ${e.message}`)
} }
...@@ -96,8 +96,11 @@ export function useEditor() { ...@@ -96,8 +96,11 @@ export function useEditor() {
} else { } else {
window.$notification.warning(`发现 ${warnings.length} 处潜在的合规性问题。`, { window.$notification.warning(`发现 ${warnings.length} 处潜在的合规性问题。`, {
title: 'DTD 语法检测警告', title: 'DTD 语法检测警告',
meta: () => h('div', { class: 'mt-2 max-h-48 overflow-auto space-y-1' }, meta: () =>
warnings.slice(0, 10).map(w => h('div', { class: 'text-xs text-warning' }, w)) h(
'div',
{ class: 'mt-2 max-h-48 overflow-auto space-y-1' },
warnings.slice(0, 10).map((w) => h('div', { class: 'text-xs text-warning' }, w))
) )
}) })
} }
......
...@@ -10,10 +10,7 @@ ...@@ -10,10 +10,7 @@
/> />
<!-- 主体布局:自定义可拖拽分割面板 --> <!-- 主体布局:自定义可拖拽分割面板 -->
<div <div class="flex-1 flex min-h-0 overflow-hidden relative">
ref="containerRef"
class="flex-1 flex min-h-0 overflow-hidden relative"
>
<!-- 左侧:节点树 --> <!-- 左侧:节点树 -->
<div <div
class="flex flex-col overflow-hidden shrink-0 transition-[width] duration-150" class="flex flex-col overflow-hidden shrink-0 transition-[width] duration-150"
...@@ -23,35 +20,11 @@ ...@@ -23,35 +20,11 @@
<NodeTree v-model:expandedKeys="expandedKeys" /> <NodeTree v-model:expandedKeys="expandedKeys" />
</div> </div>
<!-- 拖动分割条 --> <!-- 拖动分割条组件 -->
<div <Splitter v-model:width="leftWidthPx" v-model:collapsed="isCollapsed" />
class="split-divider group"
:class="{ 'is-dragging': isDragging, 'is-collapsed': isCollapsed }"
@mousedown.prevent="startDrag"
@click="handleDividerClick"
>
<!-- 折叠状态:展开箭头 -->
<div v-if="isCollapsed" class="split-expand-btn">
<svg class="w-3 h-3" viewBox="0 0 12 12" fill="currentColor">
<path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- 展开状态:可视指示线 + 手柄圆点 -->
<template v-else>
<div class="split-divider-line" />
<div class="split-divider-handle">
<div class="handle-dot" />
<div class="handle-dot" />
<div class="handle-dot" />
</div>
</template>
</div>
<!-- 右侧:编辑区 --> <!-- 右侧:编辑区 -->
<div <div class="flex-1 flex flex-col overflow-hidden" :style="{ background: themeVars.colorBg1 }">
class="flex-1 flex flex-col overflow-hidden"
:style="{ background: themeVars.colorBg1 }"
>
<EditorPanel /> <EditorPanel />
</div> </div>
</div> </div>
...@@ -64,11 +37,16 @@ import { useEditor } from './functionals' ...@@ -64,11 +37,16 @@ import { useEditor } from './functionals'
import EditorToolbar from './components/EditorToolbar/index.vue' import EditorToolbar from './components/EditorToolbar/index.vue'
import NodeTree from './components/NodeTree/index.vue' import NodeTree from './components/NodeTree/index.vue'
import EditorPanel from './components/EditorPanel/index.vue' import EditorPanel from './components/EditorPanel/index.vue'
import Splitter from './components/Splitter/index.vue'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const expandedKeys = ref<string[]>([]) const expandedKeys = ref<string[]>([])
// 界面拖动分割状态
const leftWidthPx = ref(560)
const isCollapsed = ref(false)
// 实例化业务逻辑 Hook // 实例化业务逻辑 Hook
const { initialize, save, exportXml, getAllNodeKeys, validate } = useEditor() const { initialize, save, exportXml, getAllNodeKeys, validate } = useEditor()
...@@ -76,87 +54,13 @@ onMounted(() => { ...@@ -76,87 +54,13 @@ onMounted(() => {
initialize() initialize()
}) })
// ── 自定义拖动分割逻辑 ────────────────────────────────────────────────────────
const containerRef = ref<HTMLElement | null>(null)
const COLLAPSE_THRESHOLD = 150 // px:低于此宽度时自动折叠
const DEFAULT_WIDTH = 560 // px:初始/恢复宽度
const MAX_RATIO = 0.6
const leftWidthPx = ref(DEFAULT_WIDTH)
const isCollapsed = ref(false)
const isDragging = ref(false)
let startX = 0
let startWidth = 0
let hasDragged = false // 区分拖动与点击
const startDrag = (e: MouseEvent) => {
isDragging.value = true
hasDragged = false
startX = e.clientX
// 折叠状态下从 0 开始拖动
startWidth = isCollapsed.value ? 0 : leftWidthPx.value
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onDrag)
window.addEventListener('mouseup', stopDrag)
}
const onDrag = (e: MouseEvent) => {
if (!isDragging.value || !containerRef.value) return
const delta = e.clientX - startX
if (Math.abs(delta) > 3) hasDragged = true
const raw = startWidth + delta
const containerWidth = containerRef.value.clientWidth
const maxWidth = containerWidth * MAX_RATIO
if (raw < COLLAPSE_THRESHOLD) {
// 低于阈值:预览折叠
leftWidthPx.value = Math.max(0, raw)
isCollapsed.value = raw < COLLAPSE_THRESHOLD / 2
} else {
isCollapsed.value = false
leftWidthPx.value = Math.min(maxWidth, raw)
}
}
const stopDrag = () => {
isDragging.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
// 松手后:如果宽度低于阈值,完全折叠
if (!isCollapsed.value && leftWidthPx.value < COLLAPSE_THRESHOLD) {
isCollapsed.value = true
}
// 如果宽度非常小但没折叠,恢复到最小可用宽度
if (!isCollapsed.value && leftWidthPx.value < COLLAPSE_THRESHOLD) {
leftWidthPx.value = COLLAPSE_THRESHOLD
}
}
// 点击分割条:折叠时展开,展开时无操作(避免误触)
const handleDividerClick = () => {
if (hasDragged) return
if (isCollapsed.value) {
isCollapsed.value = false
leftWidthPx.value = DEFAULT_WIDTH
}
}
onUnmounted(() => {
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
})
// ── 工具栏事件 ──────────────────────────────────────────────────────────────── // ── 工具栏事件 ────────────────────────────────────────────────────────────────
const handleSave = () => save() const handleSave = () => save()
const handleExport = () => exportXml() const handleExport = () => exportXml()
const handleExpandAll = () => { expandedKeys.value = getAllNodeKeys() } const handleExpandAll = () => {
expandedKeys.value = getAllNodeKeys()
}
const handleCollapseAll = () => { const handleCollapseAll = () => {
if (editorStore.xmlTree) { if (editorStore.xmlTree) {
expandedKeys.value = [editorStore.xmlTree.id] expandedKeys.value = [editorStore.xmlTree.id]
...@@ -165,103 +69,4 @@ const handleCollapseAll = () => { ...@@ -165,103 +69,4 @@ const handleCollapseAll = () => {
const handleValidate = () => validate() const handleValidate = () => validate()
</script> </script>
<style scoped> <style scoped></style>
/* ── 分割条容器 ─────────────────────────────────────────────────────────────── */
.split-divider {
position: relative;
width: 10px;
flex-shrink: 0;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: background-color 0.2s;
}
.split-divider:hover,
.split-divider.is-dragging {
background-color: var(--primary-color, #18a058)1a;
}
/* ── 可视线 ───────────────────────────────────────────────────────────────── */
.split-divider-line {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
background-color: var(--divider-color, rgba(0, 0, 0, 0.08));
transition: background-color 0.2s, width 0.2s;
}
.split-divider:hover .split-divider-line,
.split-divider.is-dragging .split-divider-line {
background-color: var(--primary-color, #18a058);
width: 2px;
}
/* ── 手柄圆点 ────────────────────────────────────────────────────────────── */
.split-divider-handle {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 3px;
padding: 4px 3px;
border-radius: 8px;
background: var(--fill-color-2, rgba(0, 0, 0, 0.04));
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
opacity: 0;
transform: scaleY(0.8);
transition: opacity 0.2s, transform 0.2s, background 0.2s;
}
.split-divider:hover .split-divider-handle,
.split-divider.is-dragging .split-divider-handle {
opacity: 1;
transform: scaleY(1);
background: var(--primary-color, #18a058);
border-color: var(--primary-color, #18a058);
}
.handle-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--divider-color, rgba(0, 0, 0, 0.2));
transition: background-color 0.2s;
}
.split-divider:hover .handle-dot,
.split-divider.is-dragging .handle-dot {
background-color: #fff;
}
/* ── 折叠状态 ─────────────────────────────────────────────────────────────── */
.split-divider.is-collapsed {
width: 16px;
cursor: pointer;
background-color: var(--fill-color-3, rgba(0, 0, 0, 0.06));
border-right: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
}
.split-divider.is-collapsed:hover {
background-color: color-mix(in srgb, var(--primary-color, #18a058) 12%, transparent);
}
.split-expand-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--text-color-3, rgba(0, 0, 0, 0.38));
transition: color 0.2s;
}
.split-divider.is-collapsed:hover .split-expand-btn {
color: var(--primary-color, #18a058);
}
</style>
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