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;
} }
.loading-text { .dark .global-loading-overlay {
margin-top: 12px; 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);
}
}
.custom-loading-text {
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()
if (success) { } else if (options.localBlob) {
progress.value = 100 // 如果提供了本地 Blob,直接触发本地下载
window.$message.success('下载任务已完成') const downloadUrl = window.URL.createObjectURL(options.localBlob)
// 延迟关闭,让用户看到 100% 成功状态 const a = document.createElement('a')
setTimeout(() => { a.href = downloadUrl
show.value = false a.download = name || `export_${new Date().getTime()}.xml`
options.callback?.(true) document.body.appendChild(a)
}, 800) a.click()
window.URL.revokeObjectURL(downloadUrl)
document.body.removeChild(a)
} else { } else {
show.value = false // 兜底模拟
options.callback?.(false) await new Promise((resolve) => setTimeout(resolve, 800))
} }
} catch (err) {
progress.value = 100
window.$message.success('下载任务已完成')
setTimeout(() => {
show.value = false
options.callback?.(true)
}, 800)
} catch (err: any) {
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 }) uploadProgress.value = 100
if (res.code === 200) { window.$message.success('上传成功')
uploadProgress.value = 100 ctx.onSuccess?.(res, file)
window.$message.success('上传成功') setTimeout(() => {
ctx.onSuccess?.(res) show.value = false
setTimeout(() => { }, 500)
show.value = false
}, 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,29 +37,59 @@ const handleGlobalKeydown = (e: KeyboardEvent) => { ...@@ -35,29 +37,59 @@ 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
window.$uploadModal = globalUploadModalRef.value window.$uploadModal = globalUploadModalRef.value
window.$previewModal = globalPreviewModalRef.value window.$previewModal = globalPreviewModalRef.value
window.$downloadModal = globalDownloadModalRef.value window.$downloadModal = globalDownloadModalRef.value
// 初始化应用主题 // 初始化应用主题
appStore.applyTheme() appStore.applyTheme()
}) })
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 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(
const nextModel: Record<string, string> = {} () => props.node.id,
for (const name of Object.keys(attributesDef.value)) { () => {
nextModel[name] = props.node.attributes[name] || '' const nextModel: Record<string, string> = {}
} for (const name of Object.keys(attributesDef.value)) {
model.value = nextModel nextModel[name] = props.node.attributes[name] || ''
}, { immediate: true }) }
model.value = nextModel
},
{ 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 { 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(
heightsMap.value = {} () => editorStore.xmlTree?.id,
}) () => {
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"
:key="n.id" :key="n.id"
@click="editorStore.setSelectedNodeId(n.id)" @click="editorStore.setSelectedNodeId(n.id)"
class="cursor-pointer hover:text-primary transition-colors text-color2 font-medium" class="cursor-pointer hover:text-primary transition-colors text-color2 font-medium"
...@@ -19,47 +17,49 @@ ...@@ -19,47 +17,49 @@
</n-breadcrumb> </n-breadcrumb>
<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" <CommonButton size="tiny" secondary @click="toggleFindReplace">
secondary <template #icon>
:disabled="!selectedNode" <n-icon>
@click="handleEditSelectedNode" <search-outline />
> </n-icon>
<template #icon> </template>
<n-icon> 查找替换 (Ctrl+F)
<settings-outline /> </CommonButton>
</n-icon> <!-- 修改属性按钮(触发弹框) -->
</template> <CommonButton size="tiny" secondary :disabled="!selectedNode" @click="handleEditSelectedNode">
修改属性 <template #icon>
</CommonButton> <n-icon>
<settings-outline />
</n-icon>
</template>
修改属性
</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 尚未完全更新时报错
}
} }
...@@ -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 // 回退到基于正则的简单高亮
return text const text = checkRuleData.value.humanReadable || checkRuleData.value.rawModel
.replace(/\|/g, '<span class="text-primary font-bold">|</span>') return text
.replace(/[?*+]/g, '<span class="text-warning">$&</span>') .replace(/\|/g, '<span class="text-color3 font-bold">|</span>')
.replace(/[()]/g, '<span class="text-color3">$&</span>') .replace(/[?*+]/g, '<span class="text-warning">$&</span>')
.replace(/([A-Z][A-Z0-9\-]*)/g, '<span class="text-success font-semibold">$1</span>') .replace(/[()]/g, '<span class="text-color3">$&</span>')
.replace(/#PCDATA/g, '<span class="text-danger">#PCDATA</span>') .replace(/([A-Z][A-Z0-9\-]*)/g, '<span class="text-primary font-semibold">$1</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
}
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
} }
// 表格组件相关的提示文本定义 // 表格组件相关的提示文本定义
......
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 moveSlice = (slicesList: SliceItem[], index: number, direction: 'up' | 'down'): SliceItem[] => { const deleteSlice = async (index: number) => {
const newList = [...slicesList] try {
await window.$dialog.warning({
title: '敏感删除确认',
content: '确定要删除该片段吗?该操作会将该段文本或行内子节点及其所有关联属性彻底清除。'
})
slices.value.splice(index, 1)
syncSlices()
window.$message.success('删除片段成功')
} catch (e) {
// 取消删除
}
}
const handleMoveSlice = (index: number, direction: 'up' | 'down') => {
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
} }
} }
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,9 +96,12 @@ export function useEditor() { ...@@ -96,9 +96,12 @@ 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