Commit 68090924 by pangchong

feat(config): 补充 XML 标签配置及复合容器规范

- 新增 `.env.xm` 环境变量配置支持 xm 模式构建与运行
- 在 package.json 中添加 dev:xm 和 build:xm 脚本命令
- 详细补充了 xmlTags.ts 中 XML 节点标签分类,如 HEADER_TAGS、INLINE_ELEMENTS
- 新增 EMPTY_MARKER_TAGS、NOTE_VARIANT_TAGS、HIDDEN_STRUCTURAL_TAGS 等节点类型定义
- 完善 COMPOSITE_CONTAINER_MAP 复合结构特殊容器的注释说明和规范
- 在 README.md 里新增复合结构特殊容器的使用规则及渲染原则说明
- 精细化规范文档规则及格式调整,添加多条项目开发规范说明
- 修正 API 请求中对响应 code 判断逻辑,统一以 200 作为成功标识
parent 66bf1bb7
VITE_PARTITION_NAME = 'from_project_001'
VITE_CUSTOM_CODE = 'xm'
...@@ -7,15 +7,18 @@ ...@@ -7,15 +7,18 @@
--- ---
## 1. 思考与语言规范 ## 1. 思考与语言规范
- AI 助手在分析问题、编写规划、输出日志及用户沟通的整个生命周期中,**必须**统一使用**中文** - AI 助手在分析问题、编写规划、输出日志及用户沟通的整个生命周期中,**必须**统一使用**中文**
- 自动生成的 git Commit Message 或文件注释也必须使用中文。 - 自动生成的 git Commit Message 或文件注释也必须使用中文。
--- ---
## 2. 目录规范与模块解耦 (三层标准结构) ## 2. 目录规范与模块解耦 (三层标准结构)
为了确保项目结构的绝对统一,所有业务页面 (Page) 及其下属子组件 (Component) 必须采用以下目录树结构,严格执行代码逻辑、类型与常量的拆分: 为了确保项目结构的绝对统一,所有业务页面 (Page) 及其下属子组件 (Component) 必须采用以下目录树结构,严格执行代码逻辑、类型与常量的拆分:
### 页面级模块目录结构 ### 页面级模块目录结构
```text ```text
[ParentDir]/ # 父级目录 (若有多个模块并列) [ParentDir]/ # 父级目录 (若有多个模块并列)
├── [module_name]/ # 该模块的逻辑隔离子目录 (强制) ├── [module_name]/ # 该模块的逻辑隔离子目录 (强制)
...@@ -41,7 +44,9 @@ ...@@ -41,7 +44,9 @@
--- ---
## 3. Vue 单文件结构规范 ## 3. Vue 单文件结构规范
所有 `.vue` 文件的内部结构必须严格遵循以下顺序: 所有 `.vue` 文件的内部结构必须严格遵循以下顺序:
1. **`<template>`**:视图模板放在最上方。 1. **`<template>`**:视图模板放在最上方。
2. **`<script>`**:逻辑脚本紧随其后。 2. **`<script>`**:逻辑脚本紧随其后。
3. **`<style>`**:CSS 样式(如果有)放在最下方。 3. **`<style>`**:CSS 样式(如果有)放在最下方。
...@@ -49,77 +54,83 @@ ...@@ -49,77 +54,83 @@
--- ---
## 4. 自动导入 (Auto Import) 规范 ## 4. 自动导入 (Auto Import) 规范
- **禁止手动导入常用 API、Hooks 与 Utils**:项目已配置 `unplugin-auto-import`。对 `vue``vue-router``pinia``naive-ui` 的常用 API,以及位于 `src/hooks``src/utils``src/api` 目录下的所有自定义 Hooks、工具函数和 API 实例(例如 `useKeyboardShortcuts``service``formatDate``useTableSearch` 等),**均不需要且严禁手动编写 `import` 语句**,直接全局使用即可。
- **禁止手动导入常用 API、Hooks 与 Utils**:项目已配置 `unplugin-auto-import`。对 `vue``vue-router``pinia``naive-ui` 的常用 API,以及位于 `src/hooks``src/utils``src/api` 目录下的所有自定义 Hooks、工具函数和 API 实例(例如 `useKeyboardShortcuts``service``formatDate``useTableSearch` 等),**均不需要且严禁手动编写 `import` 语句**,直接全局使用即可。
--- ---
## 5. 异步编程规范 ## 5. 异步编程规范
- 全项目所有异步操作(如 API 请求、弹窗确认、文件上传等)**必须**统一使用 `async/await` 语法。 - 全项目所有异步操作(如 API 请求、弹窗确认、文件上传等)**必须**统一使用 `async/await` 语法。
- **严禁**使用 `.then()``.catch()``.finally()` 链式调用,以确保业务代码逻辑呈线性流。 - **严禁**使用 `.then()``.catch()``.finally()` 链式调用,以确保业务代码逻辑呈线性流。
--- ---
## 6. Tailwind CSS 优先与颜色值使用限制 ## 6. Tailwind CSS 优先与颜色值使用限制
- **基础样式**:间距、对齐、扁平布局等应优先使用 Tailwind CSS 类名。 - **基础样式**:间距、对齐、扁平布局等应优先使用 Tailwind CSS 类名。
- **严禁硬编码颜色值**:严禁硬编码任何 Hex、RGB 或原生 CSS 颜色(如 `#a8a297``red`)。 - **严禁硬编码颜色值**:严禁硬编码任何 Hex、RGB 或原生 CSS 颜色(如 `#a8a297``red`)。
- **必须使用系统主题色**:可通过 `useThemeVars()` 获取并绑定(如 `themeVars.textColorDisabled``themeVars.primaryColor` 等),或利用 CSS 变量传递给 scoped style。 - **必须使用系统主题色**:可通过 `useThemeVars()` 获取并绑定(如 `themeVars.textColorDisabled``themeVars.primaryColor` 等),或利用 CSS 变量传递给 scoped style。
- **禁止硬编码灰度/普通 Tailwind 颜色类**:背景、边框及文字颜色,严禁使用固定的 Tailwind 灰度梯度类(例如 `bg-gray-50`, `bg-gray-100`, `border-gray-200`, `text-gray-700` 等),必须使用全局自适应的主题变量类: - **禁止硬编码灰度/普通 Tailwind 颜色类**:背景、边框及文字颜色,严禁使用固定的 Tailwind 灰度梯度类(例如 `bg-gray-50`, `bg-gray-100`, `border-gray-200`, `text-gray-700` 等),必须使用全局自适应的主题变量类:
- **背景色**`bg-fill-1` ~ `bg-fill-4` - **背景色**`bg-fill-1` ~ `bg-fill-4`
- **边框色**`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 框架及骨架情况方可采用系统内置主题配置。 - **特定技术手册/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 框架及骨架情况方可采用系统内置主题配置。
### 主题配置参考 ### 主题配置参考
系统内置的亮暗主题颜色配置如下,可通过主题变量自适应: 系统内置的亮暗主题颜色配置如下,可通过主题变量自适应:
| 属性名称 | 亮色主题 (Light) | 暗色主题 (Dark) | | 属性名称 | 亮色主题 (Light) | 暗色主题 (Dark) |
|---|---|---| | ---------------- | ------------------------ | --------------------------- |
| **primaryColor** | `#165DFF` | `#3c7eff` | | **primaryColor** | `#165DFF` | `#3c7eff` |
| **successColor** | `#00B42A` | `#3c7eff` | | **successColor** | `#00B42A` | `#3c7eff` |
| **warningColor** | `#FF7D00` | `#f2c97d` | | **warningColor** | `#FF7D00` | `#f2c97d` |
| **errorColor** | `#F53F3F` | `#e88080` | | **errorColor** | `#F53F3F` | `#e88080` |
| **dividerColor** | `#e8eaed` | `rgba(255, 255, 255, 0.09)` | | **dividerColor** | `#e8eaed` | `rgba(255, 255, 255, 0.09)` |
| **borderColor** | `#dee2e6` | `rgba(255, 255, 255, 0.24)` | | **borderColor** | `#dee2e6` | `rgba(255, 255, 255, 0.24)` |
| **bodyColor** | `#f5f7fa` | `rgb(16, 16, 20)` | | **bodyColor** | `#f5f7fa` | `rgb(16, 16, 20)` |
| **cardColor** | `#fff` | `rgb(24, 24, 28)` | | **cardColor** | `#fff` | `rgb(24, 24, 28)` |
| **inputColor** | `rgba(255, 255, 255, 1)` | `rgba(255, 255, 255, 0.1)` | | **inputColor** | `rgba(255, 255, 255, 1)` | `rgba(255, 255, 255, 0.1)` |
--- ---
## 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 上下文依赖。 - **消息提示规范 (强制)**:全项目**禁止**在任何业务组件、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: '取消'`
- **代码书写约定** - **代码书写约定**
```typescript ```typescript
try { try {
await window.$dialog.warning({ await window.$dialog.warning({
title: '敏感操作确认', title: '敏感操作确认',
content: '确定要执行此操作吗?' content: '确定要执行此操作吗?'
}) })
// 执行业务逻辑 // 执行业务逻辑
} catch (e) { } catch (e) {
// 处理取消 // 处理取消
} }
``` ```
--- ---
## 8. 按钮组件与检索对齐规范 ## 8. 按钮组件与检索对齐规范
- **禁止使用 `n-button`**:在所有业务页面中,禁止使用 Naive UI 原生的 `n-button`**强制要求**统一使用项目封装的 `CommonButton` - **禁止使用 `n-button`**:在所有业务页面中,禁止使用 Naive UI 原生的 `n-button`**强制要求**统一使用项目封装的 `CommonButton`
- **搜索区域对齐与查询重置 (强制)** - **搜索区域对齐与查询重置 (强制)**
- 检索区域内,操作按钮组**必须**直接使用 `<CommonQueryButtons />``<CommonSearchForm />` 组件,**禁止**在业务页面手动包裹 `n-form-item``div` 来排版查询和重置按钮。 - 检索区域内,操作按钮组**必须**直接使用 `<CommonQueryButtons />``<CommonSearchForm />` 组件,**禁止**在业务页面手动包裹 `n-form-item``div` 来排版查询和重置按钮。
- **禁止冗余刷新按钮**:在列表页面的操作区(如 `extra` 插槽)中,**禁止**额外添加名为“刷新”的按钮,因为“查询”按钮本身已具备刷新功能。 - **禁止冗余刷新按钮**:在列表页面的操作区(如 `extra` 插槽)中,**禁止**额外添加名为“刷新”的按钮,因为“查询”按钮本身已具备刷新功能。
- **按钮图标规范** - **按钮图标规范**
- 表格上方工具栏区域(通常在 `CommonPageContainer``extra` 插槽内)的所有 `CommonButton` **必须**配置图标。使用 `#icon` 插槽配合 `n-icon` 渲染,图标库统一使用 `@vicons/ionicons5` - 表格上方工具栏区域(通常在 `CommonPageContainer``extra` 插槽内)的所有 `CommonButton` **必须**配置图标。使用 `#icon` 插槽配合 `n-icon` 渲染,图标库统一使用 `@vicons/ionicons5`
- 表格行内操作按钮推荐保持 `text` 属性,且不配置图标,保持界面简洁。 - 表格行内操作按钮推荐保持 `text` 属性,且不配置图标,保持界面简洁。
--- ---
## 9. 表格与操作栏规范 ## 9. 表格与操作栏规范
- **操作栏按钮间距**:全局样式中已定义 `.n-button:not(:last-child) { margin-right: 10px; }`,自动生效。 - **操作栏按钮间距**:全局样式中已定义 `.n-button:not(:last-child) { margin-right: 10px; }`,自动生效。
- **禁止包装操作列**:在 `CommonTable``columns` 定义中,操作列的 `render` 函数**禁止**使用 `NSpace` 包装按钮,直接返回按钮数组:`[ h(CommonButton, ...), h(CommonButton, ...) ]` - **禁止包装操作列**:在 `CommonTable``columns` 定义中,操作列的 `render` 函数**禁止**使用 `NSpace` 包装按钮,直接返回按钮数组:`[ h(CommonButton, ...), h(CommonButton, ...) ]`
- **禁用居中对齐**:表格列定义中**禁止**使用 `align: 'center'`(包括操作列,全项目表格统一不再居中对齐),禁止手动添加额外的居中样式。 - **禁用居中对齐**:表格列定义中**禁止**使用 `align: 'center'`(包括操作列,全项目表格统一不再居中对齐),禁止手动添加额外的居中样式。
...@@ -128,6 +139,7 @@ ...@@ -128,6 +139,7 @@
--- ---
## 10. 日期组件与格式化规范 ## 10. 日期组件与格式化规范
- **禁用 `n-date-picker`**:强制统一使用封装好的 `CommonDatePicker` 组件。 - **禁用 `n-date-picker`**:强制统一使用封装好的 `CommonDatePicker` 组件。
- **强制使用全局 `formatDate`**:在日期回显、表格列 `render`、或表单详情初始化中,将日期格式化或截取为 `yyyy-MM-dd` 时,**禁止**使用原生 `.split(' ')[0]``.substring(0, 10)` 等截取写法,**强制要求统一使用全局工具方法 `formatDate(value)`** - **强制使用全局 `formatDate`**:在日期回显、表格列 `render`、或表单详情初始化中,将日期格式化或截取为 `yyyy-MM-dd` 时,**禁止**使用原生 `.split(' ')[0]``.substring(0, 10)` 等截取写法,**强制要求统一使用全局工具方法 `formatDate(value)`**
- **合理使用日期格式**:必须根据业务场景选用 `formatDate``formatDateTime`。若该字段需要包含时分秒(如创建时间、修改时间),应直接回显或使用 `formatDateTime`,禁止盲目使用 `formatDate` 导致数据丢失。 - **合理使用日期格式**:必须根据业务场景选用 `formatDate``formatDateTime`。若该字段需要包含时分秒(如创建时间、修改时间),应直接回显或使用 `formatDateTime`,禁止盲目使用 `formatDate` 导致数据丢失。
...@@ -135,32 +147,35 @@ ...@@ -135,32 +147,35 @@
--- ---
## 11. Naive UI 全局组件属性 ## 11. Naive UI 全局组件属性
- **Clearable 默认开启**:已全局配置 `clearable``true`,在 `.vue` 模板中编写 `n-input``n-select` 等组件时,**禁止**手动添加 `clearable` 属性(特殊要求关闭的除外)。 - **Clearable 默认开启**:已全局配置 `clearable``true`,在 `.vue` 模板中编写 `n-input``n-select` 等组件时,**禁止**手动添加 `clearable` 属性(特殊要求关闭的除外)。
--- ---
## 12. 全局 Modal 封装与交互规范 ## 12. 全局 Modal 封装与交互规范
- **统一组件**:所有业务弹窗必须使用封装的 `CommonModal` 组件。 - **统一组件**:所有业务弹窗必须使用封装的 `CommonModal` 组件。
- **宽度配置**:弹窗宽度定义**必须**使用 `:width="数字"` 形式(如 `:width="600"`),禁止使用非绑定的 `width="600"` 或手写样式。 - **宽度配置**:弹窗宽度定义**必须**使用 `:width="数字"` 形式(如 `:width="600"`),禁止使用非绑定的 `width="600"` 或手写样式。
- **双向绑定**:在业务逻辑中,**禁止**使用 `v-model:show="xxx"` 语法,必须统一简写为 **`v-model="xxx"`** - **双向绑定**:在业务逻辑中,**禁止**使用 `v-model:show="xxx"` 语法,必须统一简写为 **`v-model="xxx"`**
- **组件内置事件优先**:提交/确认动作**必须**绑定至组件的 **`@confirm`** 事件,严禁在业务层手动通过 `footer` slot 覆写确认按钮。配置确定文本必须通过 **`confirm-text`** 属性。 - **组件内置事件优先**:提交/确认动作**必须**绑定至组件的 **`@confirm`** 事件,严禁在业务层手动通过 `footer` slot 覆写确认按钮。配置确定文本必须通过 **`confirm-text`** 属性。
- **弹窗加载状态与反馈** - **弹窗加载状态与反馈**
- **禁用全局 Loading**:弹窗内进行请求时,**禁止**调用 `window.$loadingBar`,且在请求配置中**严禁**开启 `showLoading: true` - **禁用全局 Loading**:弹窗内进行请求时,**禁止**调用 `window.$loadingBar`,且在请求配置中**严禁**开启 `showLoading: true`
- **使用组件 loading 属性**:统一利用 Modal 组件提供的 `loading` 属性进行同步,通常绑定为组合状态 **`:loading="fetching || saving"`** - **使用组件 loading 属性**:统一利用 Modal 组件提供的 `loading` 属性进行同步,通常绑定为组合状态 **`:loading="fetching || saving"`**
- **加载时机 (先开窗后加载)**:执行 `async` 拉取详情前,必须**先**设置 `show.value = true` 立即打开弹窗,随后立即设置 `fetching.value = true` 开启加载动画。**禁止**等待接口返回后再显示弹窗。 - **加载时机 (先开窗后加载)**:执行 `async` 拉取详情前,必须**先**设置 `show.value = true` 立即打开弹窗,随后立即设置 `fetching.value = true` 开启加载动画。**禁止**等待接口返回后再显示弹窗。
- **状态管理**:必须在 `functionals/index.ts` 的业务类中定义 `fetching``saving` 两个响应式变量,并利用 `try...finally` 块确保状态闭环。 - **状态管理**:必须在 `functionals/index.ts` 的业务类中定义 `fetching``saving` 两个响应式变量,并利用 `try...finally` 块确保状态闭环。
- **禁止手动包裹 `n-spin`**:弹窗内 `n-form` 等内容,禁止手动包裹 `n-spin` 标签,统一通过 `CommonModal` 自身的 `loading` 控制。 - **禁止手动包裹 `n-spin`**:弹窗内 `n-form` 等内容,禁止手动包裹 `n-spin` 标签,统一通过 `CommonModal` 自身的 `loading` 控制。
- **高度与滚动约束****禁止**`CommonModal` 内部的主容器/表单上显式设置 `overflow-y-auto`、最大高度限制(如 `max-h-[70vh]`)或任何硬编码的高度(如 `h-[500px]`)。这部分高度自适应和内部滚动已由 `CommonModal` 内置完成。 - **高度与滚动约束****禁止**`CommonModal` 内部的主容器/表单上显式设置 `overflow-y-auto`、最大高度限制(如 `max-h-[70vh]`)或任何硬编码的高度(如 `h-[500px]`)。这部分高度自适应和内部滚动已由 `CommonModal` 内置完成。
- **按钮 Loading 联动**:弹窗内的提交及控制按钮(包含关闭按钮)必须绑定 `saving``loading``disabled` 状态,确保在接口调用期间按钮被禁用,防止二次提交或意外关闭弹窗。 - **按钮 Loading 联动**:弹窗内的提交及控制按钮(包含关闭按钮)必须绑定 `saving``loading``disabled` 状态,确保在接口调用期间按钮被禁用,防止二次提交或意外关闭弹窗。
- **参数传递 (open 方法强制)** - **参数传递 (open 方法强制)**
- 父组件调用子弹窗时,**禁止**使用 `v-bind="modalParams"` 或定义过多的 `props` 来同步业务参数。 - 父组件调用子弹窗时,**禁止**使用 `v-bind="modalParams"` 或定义过多的 `props` 来同步业务参数。
- 子弹窗**必须**通过 `defineExpose` 暴露一个 `open` 方法,供父组件调用,调用形式为 `modalRef.value?.open(row.PKID, 'edit', ...)` - 子弹窗**必须**通过 `defineExpose` 暴露一个 `open` 方法,供父组件调用,调用形式为 `modalRef.value?.open(row.PKID, 'edit', ...)`
- **数据选择器 (Picker) 交互**:单选类选择器弹窗必须开启 `CommonTable``choose` 模式,实现点击行即选中的交互,禁止使用 `selection` 单选框列。 - **数据选择器 (Picker) 交互**:单选类选择器弹窗必须开启 `CommonTable``choose` 模式,实现点击行即选中的交互,禁止使用 `selection` 单选框列。
- **附件上传**:必须通过 `src/utils/render.ts` 中提供的 `openUploadModal` 方法,严禁直接调用 `window.$uploadModal?.open` - **附件上传**:必须通过 `src/utils/render.ts` 中提供的 `openUploadModal` 方法,严禁直接调用 `window.$uploadModal?.open`
--- ---
## 13. API 请求与异常处理 ## 13. API 请求与异常处理
- **非弹窗场景加载蒙层**:在列表、详情页面等非 Modal 环境中,点击操作直接发起网络请求,**必须**在请求配置中开启 `showLoading: true` 或指定具体加载文案。 - **非弹窗场景加载蒙层**:在列表、详情页面等非 Modal 环境中,点击操作直接发起网络请求,**必须**在请求配置中开启 `showLoading: true` 或指定具体加载文案。
- **禁止冗余报错**:拦截器已实现错误探测与底层自动弹窗,业务层**禁止**手动调用 `window.$message.error(res.msg)` - **禁止冗余报错**:拦截器已实现错误探测与底层自动弹窗,业务层**禁止**手动调用 `window.$message.error(res.msg)`
- **判定成功**:全项目统一规定,**仅且只有** `res.code === 200` 判定为请求成功,禁止包含 `res.code === 0` 等其他冗余判断。 - **判定成功**:全项目统一规定,**仅且只有** `res.code === 200` 判定为请求成功,禁止包含 `res.code === 0` 等其他冗余判断。
...@@ -168,40 +183,44 @@ ...@@ -168,40 +183,44 @@
--- ---
## 14. Pinia 仓库目录规范 ## 14. Pinia 仓库目录规范
- **禁止单文件声明 Store**:所有的 Pinia 仓库(Store)必须拆分为独立文件夹,严禁使用单文件声明(例如 `store/editorStore.ts`)。 - **禁止单文件声明 Store**:所有的 Pinia 仓库(Store)必须拆分为独立文件夹,严禁使用单文件声明(例如 `store/editorStore.ts`)。
- **目录结构约束**:每个 Pinia 仓库必须遵循以下两文件结构: - **目录结构约束**:每个 Pinia 仓库必须遵循以下两文件结构:
```text ```text
src/store/[store_name]/ src/store/[store_name]/
├── index.ts # 存放仓库的定义(state、getters、actions 等) ├── index.ts # 存放仓库的定义(state、getters、actions 等)
└── types.ts # 存放该仓库相关的 TypeScript 类型与接口定义 └── types.ts # 存放该仓库相关的 TypeScript 类型与接口定义
``` ```
- **禁止手动导入 `defineStore`**`defineStore` 已配置为全局自动导入,在 `index.ts` 中直接使用即可,无需编写 `import { defineStore } from 'pinia'` - **禁止手动导入 `defineStore`**`defineStore` 已配置为全局自动导入,在 `index.ts` 中直接使用即可,无需编写 `import { defineStore } from 'pinia'`
--- ---
## 15. XML 编辑器树与编辑区联动定位规范 ## 15. XML 编辑器树与编辑区联动定位规范
- **最近块定位 (最近渲染块容器)** - **最近块定位 (最近渲染块容器)**
- 定位编辑区时,虚拟滚动的块位置定位**严禁**使用顶层大块(如 `TOPIC` 等远祖先容器)进行直接命中,否则会导致视图跳到整个章节开头。 - 定位编辑区时,虚拟滚动的块位置定位**严禁**使用顶层大块(如 `TOPIC` 等远祖先容器)进行直接命中,否则会导致视图跳到整个章节开头。
- **必须**使用自底向上的祖先链遍历方法(如 `findNearestBlockIdx`),查找最近(即最深层)的已注册块容器(如 `SUBTASK`, `PRETOPIC` 等),以确保精确定位到目标节点所在的子块。 - **必须**使用自底向上的祖先链遍历方法(如 `findNearestBlockIdx`),查找最近(即最深层)的已注册块容器(如 `SUBTASK`, `PRETOPIC` 等),以确保精确定位到目标节点所在的子块。
- **未独立渲染节点的定位与选中回退 (选中状态同步)** - **未独立渲染节点的定位与选中回退 (选中状态同步)**
-`DocNodeRenderer` 中部分节点被合并渲染或剔除了独立 DOM(即自身在 DOM 树中没有 `data-node-id` 标签,如 `CONNBR`, `CONNAME` ),当左侧树点击此类节点时,定位逻辑应由下至上沿着路径(`nodePath`)查找第一个已挂载的父/祖先 DOM 节点,并直接对该父/祖先 DOM 节点进行滚动居中。 -`DocNodeRenderer` 中部分节点被合并渲染或剔除了独立 DOM(即自身在 DOM 树中没有 `data-node-id` 标签,如 `CONNBR`, `CONNAME` ),当左侧树点击此类节点时,定位逻辑应由下至上沿着路径(`nodePath`)查找第一个已挂载的父/祖先 DOM 节点,并直接对该父/祖先 DOM 节点进行滚动居中。
- 在定位到父/祖先 DOM 节点后,**必须**同步将 `editorStore.setSelectedNodeId` 更新为该实际高亮/定位的父/祖先节点 ID,以便右侧编辑区在该父/祖先节点区域上呈现选中高亮状态,左侧树也同步选中该节点,保持焦点一致。 - 在定位到父/祖先 DOM 节点后,**必须**同步将 `editorStore.setSelectedNodeId` 更新为该实际高亮/定位的父/祖先节点 ID,以便右侧编辑区在该父/祖先节点区域上呈现选中高亮状态,左侧树也同步选中该节点,保持焦点一致。
--- ---
## 16. 全局常用表单、选择与状态组件规范 (强制) ## 16. 全局常用表单、选择与状态组件规范 (强制)
- **统一使用全局组件**:在所有业务界面中,对于选择框、多选框、数字输入框与状态标签,**禁止**直接使用 Naive UI 原生的组件,**强制要求**统一使用项目封装好的全局组件进行替代: - **统一使用全局组件**:在所有业务界面中,对于选择框、多选框、数字输入框与状态标签,**禁止**直接使用 Naive UI 原生的组件,**强制要求**统一使用项目封装好的全局组件进行替代:
- **下拉选择框**:使用 **`CommonSelect`** 替代 `n-select`。对 `CommonSelect` 监听值变更回调时,**必须**绑定组件导出的 **`@change`** 事件,**禁止**使用 `@update:value` - **下拉选择框**:使用 **`CommonSelect`** 替代 `n-select`。对 `CommonSelect` 监听值变更回调时,**必须**绑定组件导出的 **`@change`** 事件,**禁止**使用 `@update:value`
- **数字输入框**:使用 **`CommonInputNumber`** 替代 `n-input-number`。若绑定业务数据需要强制保留 number 类型,应显式配置属性 **`:strict="true"`** - **数字输入框**:使用 **`CommonInputNumber`** 替代 `n-input-number`。若绑定业务数据需要强制保留 number 类型,应显式配置属性 **`:strict="true"`**
- **多选/单选框组**:使用 **`CommonCheckbox`** 替代 `n-checkbox-group` 及原生的 `v-for` 循环列表。对于需要使用单个 `n-checkbox` 作为独立布尔值开关的场景,可通过将绑定的响应式变量类型转为 `string[]`,并对接传入 `:options="[{ label: '文案', value: 'yes' }]"` 来完全对齐 `<CommonCheckbox>` 全局组件的使用。 - **多选/单选框组**:使用 **`CommonCheckbox`** 替代 `n-checkbox-group` 及原生的 `v-for` 循环列表。对于需要使用单个 `n-checkbox` 作为独立布尔值开关的场景,可通过将绑定的响应式变量类型转为 `string[]`,并对接传入 `:options="[{ label: '文案', value: 'yes' }]"` 来完全对齐 `<CommonCheckbox>` 全局组件的使用。
- **状态标签**:使用 **`CommonTag`** 替代 `n-tag`。支持插槽、字典配置及 autoColor 自适应着色。 - **状态标签**:使用 **`CommonTag`** 替代 `n-tag`。支持插槽、字典配置及 autoColor 自适应着色。
- **禁用冗余的 placeholder 占位符** - **禁用冗余的 placeholder 占位符**
- 在编写 `CommonSelect``n-input` 组件时,对于普通属性、枚举选择或内容输入,**禁止**手动添加 `placeholder="请选择"``placeholder="选择"``placeholder="请输入"` 等冗余的占位字符。 - 在编写 `CommonSelect``n-input` 组件时,对于普通属性、枚举选择或内容输入,**禁止**手动添加 `placeholder="请选择"``placeholder="选择"``placeholder="请输入"` 等冗余的占位字符。
- 只在具有明确指示性特指文案的业务场景下(如“请选择切换动画”等特有场景),方可添加具体的占位文本。 - 只在具有明确指示性特指文案的业务场景下(如“请选择切换动画”等特有场景),方可添加具体的占位文本。
--- ---
## 17. XML 节点与标签使用规范 (一元化管理) ## 17. XML 节点与标签使用规范 (一元化管理)
- **禁止直接在业务代码或常量中硬编码 XML 节点标签数组或映射**(如 `['PARA', 'PARAC', 'TITLE']` 等元素集合)。 - **禁止直接在业务代码或常量中硬编码 XML 节点标签数组或映射**(如 `['PARA', 'PARAC', 'TITLE']` 等元素集合)。
- **强制要求统一收拢管理**:所有关于 DTD 定义的各类 XML 标签、节点分类(如文档型标签、列表条目标签、复杂容器标签、头部元数据标签等)必须统一在 [xmlTags.ts](file:///e:/refactor-Editor/Ifar-Xml-Editor/src/configs/xmlTags.ts) 文件中进行定义和导出。 - **强制要求统一收拢管理**:所有关于 DTD 定义的各类 XML 标签、节点分类(如文档型标签、列表条目标签、复杂容器标签、头部元数据标签等)必须统一在 [xmlTags.ts](file:///e:/refactor-Editor/Ifar-Xml-Editor/src/configs/xmlTags.ts) 文件中进行定义和导出。
- **使用规范**:在任何业务组件、逻辑 Hook 或常量模块中需要判断或使用节点标签集合时,必须先在 `xmlTags.ts` 中维护,随后在对应的代码文件中通过 `import` 引用,严禁私自在局部硬编码私有数组,以确保 DTD 分类规则的一致性与后期维护的便捷度。 - **使用规范**:在任何业务组件、逻辑 Hook 或常量模块中需要判断或使用节点标签集合时,必须先在 `xmlTags.ts` 中维护,随后在对应的代码文件中通过 `import` 引用,严禁私自在局部硬编码私有数组,以确保 DTD 分类规则的一致性与后期维护的便捷度。
...@@ -209,8 +228,16 @@ ...@@ -209,8 +228,16 @@
--- ---
## 18. XML 编辑器节点操作与偏好设置联动规范 ## 18. XML 编辑器节点操作与偏好设置联动规范
- **偏好设置与行为控制**:节点操作(如新增节点、粘贴 XML 片段等)和表格操作(如新增行、新增列等)应当尊重用户的全局偏好设置。 - **偏好设置与行为控制**:节点操作(如新增节点、粘贴 XML 片段等)和表格操作(如新增行、新增列等)应当尊重用户的全局偏好设置。
- **插入节点自动展开 (autoExpandOnInsert) 规范** - **插入节点自动展开 (autoExpandOnInsert) 规范**
- 如果开启了“插入节点自动展开”偏好设置,在树上新增/粘贴节点或在表格新增行列时,应当自动展开新增节点自身(以及包含的子节点结构),并展开对应的父节点。 - 如果开启了“插入节点自动展开”偏好设置,在树上新增/粘贴节点或在表格新增行列时,应当自动展开新增节点自身(以及包含的子节点结构),并展开对应的父节点。
- 如果关闭了该设置,在新增/粘贴节点或新增表格行列时,**必须跳过**对新增节点、其子节点以及其所有父/祖先节点的展开操作,保持原有的折叠状态。 - 如果关闭了该设置,在新增/粘贴节点或新增表格行列时,**必须跳过**对新增节点、其子节点以及其所有父/祖先节点的展开操作,保持原有的折叠状态。
- **实现要求**:为防止选中节点更新(`selectedNodeId`)时自动触发的树祖先节点级联展开动作,必须在选中变化前设置 store 的 `skipExpandOnSelect` 状态进行拦截限制,随后在 `nextTick` 中恢复,以实现无级联展开的静默插入。 - **实现要求**:为防止选中节点更新(`selectedNodeId`)时自动触发的树祖先节点级联展开动作,必须在选中变化前设置 store 的 `skipExpandOnSelect` 状态进行拦截限制,随后在 `nextTick` 中恢复,以实现无级联展开的静默插入。
---
## 19. XML 节点插入与 UUID 唯一标识规范 (必须使用随机 UUID)
- **唯一 ID 约束 (强制)**:在业务代码中不管是通过对话框交互、还是通过特定逻辑手动拼装并向 XML 树中插入自定义节点(如 `TABLE``SIGNOFF``customNode` 传入 `insertNode`)时,**必须**确保该自定义节点(以及其包含的所有下属子节点)在构建时被显式分配了一个全局唯一的 UUID(使用浏览器原生的 **`crypto.randomUUID()`** 生成),**严禁**`id` 初始化为空字符串 `''``null` 或未定义。
- **定位与映射绑定**:未配置有效唯一 UUID 的新插入节点,将导致编辑器内的 `nodeMap` 混乱、大纲树选中状态失效,以及右侧编辑面板的 `scrollIntoView` 滚动定位由于 `data-node-id` 缺失或为空而彻底崩溃。
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh" . "$(dirname -- "$0")/_/husky.sh"
npm run format
echo '***************************************************' echo '***************************************************'
echo '********************注意提交格式*******************' echo '********************注意提交格式*******************'
echo '***************************************************' echo '***************************************************'
......
...@@ -9,27 +9,31 @@ ...@@ -9,27 +9,31 @@
为了保证编辑器的高内聚、低耦合与易维护性,所有开发人员(及 AI 助手)在新增、修改 XML 节点渲染时,**必须严格遵守以下规则** 为了保证编辑器的高内聚、低耦合与易维护性,所有开发人员(及 AI 助手)在新增、修改 XML 节点渲染时,**必须严格遵守以下规则**
### 1. 配置集中化原则 ### 1. 配置集中化原则
* **禁止硬编码**:所有 XML 标签集合、分类常量、特殊标签匹配,必须定义在 `src/configs/xmlTags.ts` 文件中。
* **禁止局部私有定义**:所有业务组件需要进行标签判定时,必须从该配置文件中导入对应的常量,严禁在业务代码中出现局部私有的硬编码判断。 - **禁止硬编码**:所有 XML 标签集合、分类常量、特殊标签匹配,必须定义在 `src/configs/xmlTags.ts` 文件中。
- **禁止局部私有定义**:所有业务组件需要进行标签判定时,必须从该配置文件中导入对应的常量,严禁在业务代码中出现局部私有的硬编码判断。
### 2. 复合结构特殊容器规则 (`COMPOSITE_CONTAINER_MAP`) ### 2. 复合结构特殊容器规则 (`COMPOSITE_CONTAINER_MAP`)
当某些节点不是普通的块级节点,而是需要自定义布局、拼装表格或组合行内方式渲染其子节点(但其子节点又必须支持独立被选中、就地编辑和精确定位)时,它们被称为**复合结构特殊容器** 当某些节点不是普通的块级节点,而是需要自定义布局、拼装表格或组合行内方式渲染其子节点(但其子节点又必须支持独立被选中、就地编辑和精确定位)时,它们被称为**复合结构特殊容器**
这些映射关系定义在 `src/configs/xmlTags.ts``COMPOSITE_CONTAINER_MAP` 对象中: 这些映射关系定义在 `src/configs/xmlTags.ts``COMPOSITE_CONTAINER_MAP` 对象中:
```typescript ```typescript
export const COMPOSITE_CONTAINER_MAP: Record<string, string[]> = { export const COMPOSITE_CONTAINER_MAP: Record<string, string[]> = {
CBDATA: ['PAN', 'CBNAME', 'CB', 'CBLOC'], // 电路断路器行与四列子元素 CBDATA: ['PAN', 'CBNAME', 'CB', 'CBLOC'], // 电路断路器行与四列子元素
TED: ['TOOLNAME', 'TOOLNBR'], // 工具名称与工具件号 TED: ['TOOLNAME', 'TOOLNBR'], // 工具名称与工具件号
CON: ['CONNAME', 'CONNBR'], // 消耗品名称与消耗品件号 CON: ['CONNAME', 'CONNBR'], // 消耗品名称与消耗品件号
GRAPHIC: ['TITLE'], // 附图与附图标题 GRAPHIC: ['TITLE'], // 附图与附图标题
EINDATA: ['EIN'] // 适用性组与具体功能号 EINDATA: ['EIN'] // 适用性组与具体功能号
} }
``` ```
#### 📌 渲染与解析规范: #### 📌 渲染与解析规范:
1. **容器排除**:在判断一个节点是否为普通列表容器(`isContainer`)时,必须通过 `!COMPOSITE_CONTAINER_MAP[tagName]` 将这些复合容器排除,防止它们被错误渲染为普通块列表。 1. **容器排除**:在判断一个节点是否为普通列表容器(`isContainer`)时,必须通过 `!COMPOSITE_CONTAINER_MAP[tagName]` 将这些复合容器排除,防止它们被错误渲染为普通块列表。
2. **动态渲染(禁止写死)** 2. **动态渲染(禁止写死)**
在渲染这些容器下的子节点时,**必须使用 `v-for` 遍历 `COMPOSITE_CONTAINER_MAP[node.tagName]` 进行动态渲染**,严禁使用 `v-if="node.children.find(c => c.tagName === 'SPECIFIC_TAG')"` 这种死代码。 在渲染这些容器下的子节点时,**必须使用 `v-for` 遍历 `COMPOSITE_CONTAINER_MAP[node.tagName]` 进行动态渲染**,严禁使用 `v-if="node.children.find(c => c.tagName === 'SPECIFIC_TAG')"` 这种死代码。
3. **高亮与定位联动** 3. **高亮与定位联动**
* 必须在每一个可被编辑或选中的子节点 DOM 元素上挂载 `:data-node-id="child.id"`,以确保搜索及左侧树点击时能精确定位到该节点。 - 必须在每一个可被编辑或选中的子节点 DOM 元素上挂载 `:data-node-id="child.id"`,以确保搜索及左侧树点击时能精确定位到该节点。
* 父级包装器(如表格的 `<tr>`)如需在子节点被选中时联动高亮,必须通过遍历 `COMPOSITE_CONTAINER_MAP[parentTag]` 并判断 `child.id === selectedNodeId` 来动态激活高亮样式,确保高亮状态同步。 - 父级包装器(如表格的 `<tr>`)如需在子节点被选中时联动高亮,必须通过遍历 `COMPOSITE_CONTAINER_MAP[parentTag]` 并判断 `child.id === selectedNodeId` 来动态激活高亮样式,确保高亮状态同步。
...@@ -7,8 +7,10 @@ ...@@ -7,8 +7,10 @@
"dev": "vite", "dev": "vite",
"dev:prod": "vite --mode prod", "dev:prod": "vite --mode prod",
"dev:test": "vite --mode test", "dev:test": "vite --mode test",
"dev:xm": "vite --mode xm",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"build:prod": "vue-tsc -b && vite build --mode prod", "build:prod": "vue-tsc -b && vite build --mode prod",
"build:xm": "vue-tsc -b && vite build --mode xm",
"preview": "vite preview", "preview": "vite preview",
"prepare": "husky install", "prepare": "husky install",
"commitlint": "commitlint --config commitlint.config.cjs -e -V", "commitlint": "commitlint --config commitlint.config.cjs -e -V",
......
...@@ -50,11 +50,7 @@ const createService = (baseURL: string) => { ...@@ -50,11 +50,7 @@ const createService = (baseURL: string) => {
if (isJson) { if (isJson) {
json = (await response.json()) as ResponseData json = (await response.json()) as ResponseData
if (json) { if (json) {
if ( if (json.code === 200 || json.code === '200' || (json.code === undefined && json.success === undefined)) {
json.code === 200 ||
json.code === '200' ||
(json.code === undefined && json.success === undefined)
) {
json.code = 200 json.code = 200
return json return json
} }
...@@ -80,11 +76,7 @@ const createService = (baseURL: string) => { ...@@ -80,11 +76,7 @@ const createService = (baseURL: string) => {
} }
if (json) { if (json) {
if ( if (json.code === 200 || json.code === '200' || (json.code === undefined && json.success === undefined)) {
json.code === 200 ||
json.code === '200' ||
(json.code === undefined && json.success === undefined)
) {
json.code = 200 json.code = 200
} }
} }
......
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -12,9 +12,7 @@ ...@@ -12,9 +12,7 @@
content-style="padding: 0; background: transparent;" content-style="padding: 0; background: transparent;"
class="download-progress-modal" class="download-progress-modal"
> >
<div <div class="relative overflow-hidden rounded-2xl bg-fill-1/80 backdrop-blur-xl p-4 border border-color1/20">
class="relative overflow-hidden rounded-2xl bg-fill-1/80 backdrop-blur-xl p-4 border border-color1/20"
>
<!-- 装饰性背景光效 --> <!-- 装饰性背景光效 -->
<div class="absolute -top-24 -right-24 w-48 h-48 bg-primary/10 rounded-full blur-3xl animate-pulse"></div> <div class="absolute -top-24 -right-24 w-48 h-48 bg-primary/10 rounded-full blur-3xl animate-pulse"></div>
<div class="absolute -bottom-24 -left-24 w-48 h-48 bg-primary/5 rounded-full blur-3xl animate-pulse" style="animation-delay: 1s"></div> <div class="absolute -bottom-24 -left-24 w-48 h-48 bg-primary/5 rounded-full blur-3xl animate-pulse" style="animation-delay: 1s"></div>
......
...@@ -28,8 +28,6 @@ ...@@ -28,8 +28,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const show = ref(false) const show = ref(false)
const loading = ref(false) const loading = ref(false)
const exportProgress = ref(0) const exportProgress = ref(0)
......
...@@ -55,7 +55,6 @@ ...@@ -55,7 +55,6 @@
import { CloudUploadOutline, DownloadOutline } from '@vicons/ionicons5' import { CloudUploadOutline, DownloadOutline } from '@vicons/ionicons5'
import type { UploadFileInfo } from 'naive-ui' import type { UploadFileInfo } from 'naive-ui'
interface ImportOptions { interface ImportOptions {
title?: string title?: string
api: string api: string
......
...@@ -86,7 +86,6 @@ ...@@ -86,7 +86,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
......
...@@ -36,8 +36,7 @@ ...@@ -36,8 +36,7 @@
:style="{ backgroundColor: themeVars.actionColor, color: themeVars.textColor2 }" :style="{ backgroundColor: themeVars.actionColor, color: themeVars.textColor2 }"
> >
<!-- 左侧:占位 --> <!-- 左侧:占位 -->
<div class="mr-auto flex items-center"> <div class="mr-auto flex items-center"></div>
</div>
<div v-if="!compact" class="flex items-center space-x-3"> <div v-if="!compact" class="flex items-center space-x-3">
<span>显示 {{ pageStart }}{{ pageEnd }}, 共 {{ displayTotal }} 记录</span> <span>显示 {{ pageStart }}{{ pageEnd }}, 共 {{ displayTotal }} 记录</span>
...@@ -128,7 +127,6 @@ ...@@ -128,7 +127,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
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'
......
...@@ -16,7 +16,6 @@ ...@@ -16,7 +16,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { PropType, ComponentPublicInstance } from 'vue' import type { PropType, ComponentPublicInstance } from 'vue'
const props = defineProps({ const props = defineProps({
......
...@@ -39,7 +39,6 @@ ...@@ -39,7 +39,6 @@
import { CloudUploadOutline } from '@vicons/ionicons5' import { CloudUploadOutline } from '@vicons/ionicons5'
import type { UploadFileInfo } from 'naive-ui' import type { UploadFileInfo } from 'naive-ui'
export interface UploadOptions { export interface UploadOptions {
/** 弹窗标题 */ /** 弹窗标题 */
title?: string title?: string
......
...@@ -10,9 +10,16 @@ ...@@ -10,9 +10,16 @@
* 包含 SMJC/LMJC/NRCJC 等各类工卡头部节点。 * 包含 SMJC/LMJC/NRCJC 等各类工卡头部节点。
*/ */
export const HEADER_TAGS = [ export const HEADER_TAGS = [
'SMJC-HEADER', 'LMJC-HEADER', 'NRCJC-HEADER', 'SMJC-HEADER',
'TCJC-HEADER', 'QECJC-HEADER', 'EOTK-HEADER', 'LMJC-HEADER',
'DRJC-HEADER', 'CMJC-HEADER', 'EOJC-HEADER', 'MAOJC-HEADER' 'NRCJC-HEADER',
'TCJC-HEADER',
'QECJC-HEADER',
'EOTK-HEADER',
'DRJC-HEADER',
'CMJC-HEADER',
'EOJC-HEADER',
'MAOJC-HEADER'
] ]
/** /**
...@@ -21,24 +28,46 @@ export const HEADER_TAGS = [ ...@@ -21,24 +28,46 @@ export const HEADER_TAGS = [
*/ */
export const INLINE_ELEMENTS = [ export const INLINE_ELEMENTS = [
// 引用类 // 引用类
'REFBLOCK', 'REFINT', 'REFEXT', 'GRPHCREF', 'REFBLOCK',
'REFINT',
'REFEXT',
'GRPHCREF',
// 零件编号类 // 零件编号类
'EIN', 'EINMFR', 'PAN', 'SBNBR', 'EIN',
'EINMFR',
'PAN',
'SBNBR',
// 工具类 // 工具类
'TOOLNBR', 'TOOLNAME', 'TED', 'TOOLNBR',
'TOOLNAME',
'TED',
// 标识类 // 标识类
'STDNAME', 'STDNBR', 'STDNAME',
'STDNBR',
// 区域 // 区域
'ZONE', 'ZONE',
// 适用性标记 // 适用性标记
'EFFECT', 'CONEFFECT', 'EFFECT',
'CONEFFECT',
// CB 类(工具箱) // CB 类(工具箱)
'CB', 'CBNAME', 'CBLOC', 'CB',
'CBNAME',
'CBLOC',
// 上下标 // 上下标
'SUPER', 'SUPERSCRIPT', 'SUB', 'SUBSCRIPT', 'SUPER',
'SUPERSCRIPT',
'SUB',
'SUBSCRIPT',
// 其他常见行内 // 其他常见行内
'SSI', 'ACRO', 'SP', 'KWD', 'CSN', 'CON', 'NCON', 'SSI',
'REVST', 'REVEND' 'ACRO',
'SP',
'KWD',
'CSN',
'CON',
'NCON',
'REVST',
'REVEND'
] ]
// 段落类节点标签(PARA 为英文段落,PARAC 为中文段落) // 段落类节点标签(PARA 为英文段落,PARAC 为中文段落)
...@@ -81,6 +110,25 @@ export const CB_COMPONENT_TAGS = ['CB', 'CBNAME', 'CBLOC'] ...@@ -81,6 +110,25 @@ export const CB_COMPONENT_TAGS = ['CB', 'CBNAME', 'CBLOC']
// 上下标标签 // 上下标标签
export const SUPER_SUB_TAGS = ['SUPER', 'SUPERSCRIPT', 'SUB', 'SUBSCRIPT'] export const SUPER_SUB_TAGS = ['SUPER', 'SUPERSCRIPT', 'SUB', 'SUBSCRIPT']
/**
* EMPTY 类型的空标记节点(DTD contentModel = EMPTY)
* 这些节点在 XML 中没有内容,仅作为语义标记,渲染时必须输出空,
* 否则 fallback 处理器会把 node.tagName 当文本显示,造成界面污染。
*/
export const EMPTY_MARKER_TAGS = ['REVST', 'REVEND', 'COCST', 'COCEND', 'COCEFF', 'HOTLINK', 'ISEMPTY', 'DELETED']
/**
* NOTE 变体标签(与 NOTE 结构相同,但语义上属于不同类型的注意事项)
* 渲染时使用 NOTE 的样式,但显示不同的标签文字。
*/
export const NOTE_VARIANT_TAGS = ['HNANOTE', 'ACPANOTE', 'APAPNOTE', 'EAPPNOTE', 'INTNOTE', 'SPECNOTE']
/**
* 隐藏结构型元数据容器(在 DocNodeRenderer 中不显示内容,仅选中时显示提示)
* 这些节点包含工卡的关联数据、任务要求等,不属于正文内容。
*/
export const HIDDEN_STRUCTURAL_TAGS = ['ASSODATA', 'TASKREQ', 'ELAPSTIM', 'MANHOUR', 'NBRPERS', 'SFTLST', 'SFTDATA', 'SPRL', 'SPRLS', 'SPID', 'SPPHASE', 'SPNAME', 'SPSKILL']
// 复合结构特殊容器及其子节点关系映射表(键为直接父级节点标签名,值为其下需要特殊高亮/选中定位的子节点标签名) // 复合结构特殊容器及其子节点关系映射表(键为直接父级节点标签名,值为其下需要特殊高亮/选中定位的子节点标签名)
export const COMPOSITE_CONTAINER_MAP: Record<string, string[]> = { export const COMPOSITE_CONTAINER_MAP: Record<string, string[]> = {
CBDATA: ['PAN', 'CBNAME', 'CB', 'CBLOC'], CBDATA: ['PAN', 'CBNAME', 'CB', 'CBLOC'],
...@@ -105,37 +153,40 @@ export const ROMAN_LOOKUP: Array<[string, number]> = [ ...@@ -105,37 +153,40 @@ export const ROMAN_LOOKUP: Array<[string, number]> = [
// ─── 派生组合标签集(由以上原子集合组合而成)──────────────────────────────── // ─── 派生组合标签集(由以上原子集合组合而成)────────────────────────────────
// 文档型叶子节点(用于在左侧树提取文本副标题展示) // 文档型叶子节点(用于在左侧树提取文本副标题展示)
export const DOCUMENT_LIKE_TAGS = [ export const DOCUMENT_LIKE_TAGS = ['PARA', 'PARAC', 'TITLE', 'TITLEC', 'REGULATION', 'REFBLOCK', 'GRPHCREF', 'REFINT', 'EQU']
'PARA', 'PARAC', 'TITLE', 'TITLEC', 'REGULATION',
'REFBLOCK', 'GRPHCREF', 'REFINT', 'EQU'
]
// 所有列表容器标签(有序 LIST1-7 + 无序 UNLIST/NUMLIST) // 所有列表容器标签(有序 LIST1-7 + 无序 UNLIST/NUMLIST)
export const ALL_LIST_TAGS = [...ORDERED_LIST_TAGS, 'UNLIST', 'NUMLIST'] export const ALL_LIST_TAGS = [...ORDERED_LIST_TAGS, 'UNLIST', 'NUMLIST']
// 警示及适用性关联标签(WARNING_LIKE + CAUTION + NOTE,在列表拆分等场景用作前置拦截) // 警示及适用性关联标签(WARNING_LIKE + CAUTION + NOTE 及其变体,在列表拆分等场景用作前置拦截)
export const ALERT_AND_EFF_TAGS = [...WARNING_LIKE_TAGS, 'CAUTION', 'NOTE'] export const ALERT_AND_EFF_TAGS = [...WARNING_LIKE_TAGS, 'CAUTION', 'NOTE', 'HNANOTE', 'ACPANOTE', 'APAPNOTE', 'EAPPNOTE', 'INTNOTE', 'SPECNOTE']
// 所有需要自定义颜色显示的特殊节点(WARNING_LIKE + CAUTION + NOTE_AND_REF) // 所有需要自定义颜色显示的特殊节点(WARNING_LIKE + CAUTION + NOTE_AND_REF)
export const COLORED_TAGS = [...WARNING_LIKE_TAGS, 'CAUTION', ...NOTE_AND_REF_TAGS] export const COLORED_TAGS = [...WARNING_LIKE_TAGS, 'CAUTION', ...NOTE_AND_REF_TAGS]
// 表格单元格下的复杂内容容器标签(当包含这些标签时使用 DocNodeRenderer 渲染) // 表格单元格下的复杂内容容器标签(当包含这些标签时使用 DocNodeRenderer 渲染)
export const COMPLEX_ENTRY_TAGS = [ export const COMPLEX_ENTRY_TAGS = [...ORDERED_LIST_TAGS, 'UNLIST', 'NUMLIST', 'WARNING', 'CAUTION', 'NOTE', 'GRAPHIC', 'TABLE']
...ORDERED_LIST_TAGS, 'UNLIST', 'NUMLIST',
'WARNING', 'CAUTION', 'NOTE',
'GRAPHIC', 'TABLE'
]
// 树结构容器节点集合(在获取文本预览等场景中跳过这些节点的文本提取) // 树结构容器节点集合(在获取文本预览等场景中跳过这些节点的文本提取)
export const STRUCTURAL_TAGS = [ export const STRUCTURAL_TAGS = [
...TRANSPARENT_TAGS, ...TRANSPARENT_TAGS,
'PRETOPIC', 'TOPIC', 'SUBTASK', 'STEP', 'PRETOPIC',
'TOPIC',
'SUBTASK',
'STEP',
...ORDERED_LIST_TAGS, ...ORDERED_LIST_TAGS,
...LIST_ITEM_TAGS, ...LIST_ITEM_TAGS,
'UNLIST', 'NUMLIST', 'UNLIST',
'NUMLIST',
...CALS_TABLE_ALL_TAGS, ...CALS_TABLE_ALL_TAGS,
'WARNING', 'CAUTION', 'NOTE', 'HNANOTE', 'WARNING',
'CBSUBLST', 'GRAPHIC', 'FTNOTE', 'APPEND' 'CAUTION',
'NOTE',
'HNANOTE',
'CBSUBLST',
'GRAPHIC',
'FTNOTE',
'APPEND'
] ]
// 中文段落优先的标签排序列表 // 中文段落优先的标签排序列表
...@@ -143,4 +194,3 @@ export const CHINESE_FIRST_PARA_TAGS = ['PARAC', 'PARA'] ...@@ -143,4 +194,3 @@ export const CHINESE_FIRST_PARA_TAGS = ['PARAC', 'PARA']
// 默认翻译目标标签列表 // 默认翻译目标标签列表
export const TRANSLATE_TARGET_TAGS = ['PARAC', 'TITLEC'] export const TRANSLATE_TARGET_TAGS = ['PARAC', 'TITLEC']
import mitt from 'mitt' import mitt from 'mitt'
type Fn = (...args: any[]) => void type Fn = (...args: any[]) => void
interface Option { interface Option {
......
...@@ -7,57 +7,58 @@ interface ShortcutOptions { ...@@ -7,57 +7,58 @@ interface ShortcutOptions {
onEscape?: () => void onEscape?: () => void
} }
export function useKeyboardShortcuts( export function useKeyboardShortcuts(options: ShortcutOptions = {}, enableGlobal = false) {
options: ShortcutOptions = {},
enableGlobal = false
) {
const appStore = useAppStore() const appStore = useAppStore()
const editorStore = useEditorStore() const editorStore = useEditorStore()
// 1. 全局快捷键(使用 useEventListener 并开启 capture: true 捕获事件) // 1. 全局快捷键(使用 useEventListener 并开启 capture: true 捕获事件)
if (enableGlobal) { if (enableGlobal) {
useEventListener('keydown', (e: KeyboardEvent) => { useEventListener(
const ctrl = e.ctrlKey || e.metaKey 'keydown',
const shift = e.shiftKey (e: KeyboardEvent) => {
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
// 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
} }
const target = e.target as HTMLElement | null const target = e.target as HTMLElement | null
const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
if (isInput) return if (isInput) return
if (ctrl) { if (ctrl) {
const key = e.key.toLowerCase() const key = e.key.toLowerCase()
if (key === 'z') { if (key === 'z') {
e.preventDefault() e.preventDefault()
const activeEl = document.activeElement as HTMLElement | null const activeEl = document.activeElement as HTMLElement | null
if (activeEl && activeEl.isContentEditable) { if (activeEl && activeEl.isContentEditable) {
activeEl.blur() activeEl.blur()
}
setTimeout(() => {
if (shift) {
editorStore.redo()
} else {
editorStore.undo()
} }
}, 0) setTimeout(() => {
} else if (key === 'y') { if (shift) {
e.preventDefault() editorStore.redo()
const activeEl = document.activeElement as HTMLElement | null } else {
if (activeEl && activeEl.isContentEditable) { editorStore.undo()
activeEl.blur() }
}, 0)
} else if (key === 'y') {
e.preventDefault()
const activeEl = document.activeElement as HTMLElement | null
if (activeEl && activeEl.isContentEditable) {
activeEl.blur()
}
setTimeout(() => {
editorStore.redo()
}, 0)
} }
setTimeout(() => {
editorStore.redo()
}, 0)
} }
} },
}, { capture: true }) { capture: true }
)
} }
// 2. 局部/上下文快捷键(使用 onKeyStroke,在冒泡阶段响应) // 2. 局部/上下文快捷键(使用 onKeyStroke,在冒泡阶段响应)
......
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import MainLayout from '@/layouts/MainLayout.vue' import MainLayout from '@/layouts/MainLayout.vue'
// 静态路由 // 静态路由
const constantRoutes: Array<RouteRecordRaw> = [ const constantRoutes: Array<RouteRecordRaw> = [
{ {
...@@ -41,8 +40,6 @@ const router = createRouter({ ...@@ -41,8 +40,6 @@ const router = createRouter({
routes: constantRoutes routes: constantRoutes
}) })
router.beforeEach(() => { router.beforeEach(() => {
window.$loadingBar?.start() window.$loadingBar?.start()
}) })
...@@ -61,6 +58,4 @@ router.onError(() => { ...@@ -61,6 +58,4 @@ router.onError(() => {
window.$loadingBar?.error() window.$loadingBar?.error()
}) })
export default router export default router
...@@ -561,7 +561,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -561,7 +561,7 @@ export const useEditorStore = defineStore('editor', {
if (id.includes('-txt-')) { if (id.includes('-txt-')) {
realId = id.split('-txt-')[0] realId = id.split('-txt-')[0]
} }
let curr = this.nodeMap.get(realId) let curr = this.nodeMap.get(realId)
while (curr) { while (curr) {
if (nodeSelectedRefs.has(curr.node.id)) { if (nodeSelectedRefs.has(curr.node.id)) {
...@@ -570,7 +570,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -570,7 +570,7 @@ export const useEditorStore = defineStore('editor', {
} }
curr = curr.parent ? this.nodeMap.get(curr.parent.id) : undefined curr = curr.parent ? this.nodeMap.get(curr.parent.id) : undefined
} }
if (!renderedId) { if (!renderedId) {
renderedId = realId renderedId = realId
} }
...@@ -741,15 +741,11 @@ export const useEditorStore = defineStore('editor', { ...@@ -741,15 +741,11 @@ export const useEditorStore = defineStore('editor', {
// 退化校验:如果子元素为空,直接收归为纯文本并清空混合数组 // 退化校验:如果子元素为空,直接收归为纯文本并清空混合数组
if (parent.children.length === 0) { if (parent.children.length === 0) {
const mergedText = parent.mixedContent const mergedText = parent.mixedContent.map((item: any) => (item.type === 'text' ? item.text || '' : '')).join('')
.map((item: any) => (item.type === 'text' ? item.text || '' : ''))
.join('')
parent.textContent = mergedText parent.textContent = mergedText
parent.mixedContent = [] parent.mixedContent = []
} else { } else {
parent.textContent = parent.mixedContent parent.textContent = parent.mixedContent.map((item: any) => (item.type === 'text' ? item.text || '' : '')).join('')
.map((item: any) => (item.type === 'text' ? item.text || '' : ''))
.join('')
} }
// 选中父节点 // 选中父节点
...@@ -758,7 +754,6 @@ export const useEditorStore = defineStore('editor', { ...@@ -758,7 +754,6 @@ export const useEditorStore = defineStore('editor', {
this.rebuildNodeMap() this.rebuildNodeMap()
}, },
/** /**
* 批量删除指定的多个节点(支持跨层级跨父节点) * 批量删除指定的多个节点(支持跨层级跨父节点)
*/ */
......
...@@ -17,9 +17,6 @@ declare global { ...@@ -17,9 +17,6 @@ declare global {
[key: string]: any [key: string]: any
} }
/** /**
* 业务封装后的 Dialog API (Promise 风格) * 业务封装后的 Dialog API (Promise 风格)
*/ */
......
/** /**
* 通用列表数据请求工具 * 通用列表数据请求工具
* 逻辑来源:抽离自 CommonTable 和 CommonSelect 的数据获取逻辑 * 逻辑来源:抽离自 CommonTable 和 CommonSelect 的数据获取逻辑
......
...@@ -11,7 +11,45 @@ let _schema: DtdSchema | null = null ...@@ -11,7 +11,45 @@ let _schema: DtdSchema | null = null
* 加载 DTD Schema * 加载 DTD Schema
*/ */
export function loadDtdSchema(json: DtdSchema): void { export function loadDtdSchema(json: DtdSchema): void {
_schema = json // 深度复制一份原始 schema 避免直接改变导入的数据
const schema = JSON.parse(JSON.stringify(json)) as DtdSchema
// 根据定制环境代号 VITE_CUSTOM_CODE 自动加载对应的 DTD 扩展配置
const customCode = import.meta.env.VITE_CUSTOM_CODE
let dtdExtension: any = null
if (customCode) {
// 使用 import.meta.glob 匹配并载入所有子目录下的 assets/json 目录中的 json 文件
const modules = import.meta.glob('../**/assets/json/*.json', { eager: true }) as Record<string, any>
const matchKey = Object.keys(modules).find(
(key) => key.toLowerCase().includes(`/${customCode.toLowerCase()}/`) && key.toLowerCase().endsWith('/dtdextension.json')
)
if (matchKey) {
dtdExtension = modules[matchKey].default || modules[matchKey]
}
}
// 合并自定义 DTD 扩展配置
if (dtdExtension && dtdExtension.elements) {
for (const [tagName, elementExt] of Object.entries(dtdExtension.elements as Record<string, any>)) {
if (schema.elements[tagName]) {
const targetElement = schema.elements[tagName]
if (elementExt && elementExt.attributes) {
for (const [attrName, attrExt] of Object.entries(elementExt.attributes as Record<string, any>)) {
if (targetElement.attributes[attrName]) {
// 覆盖属性定义
Object.assign(targetElement.attributes[attrName], attrExt)
} else {
// 新增属性定义
targetElement.attributes[attrName] = attrExt as any
}
}
}
}
}
}
_schema = schema
} }
/** /**
......
import { NText } from 'naive-ui' import { NText } from 'naive-ui'
import CommonButton from '@/components/CommonButton.vue' import CommonButton from '@/components/CommonButton.vue'
......
...@@ -66,8 +66,8 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode ...@@ -66,8 +66,8 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
let textContent = '' let textContent = ''
const childNodes = Array.from(element.childNodes) const childNodes = Array.from(element.childNodes)
const hasElementChildren = childNodes.some(n => n.nodeType === Node.ELEMENT_NODE) const hasElementChildren = childNodes.some((n) => n.nodeType === Node.ELEMENT_NODE)
const hasTextChildren = childNodes.some(n => n.nodeType === Node.TEXT_NODE && n.textContent?.trim()) const hasTextChildren = childNodes.some((n) => n.nodeType === Node.TEXT_NODE && n.textContent?.trim())
if (hasElementChildren && hasTextChildren) { if (hasElementChildren && hasTextChildren) {
// 混合内容节点(如 PARA, PARAC 中嵌有 REFBLOCK 等行内元素) // 混合内容节点(如 PARA, PARAC 中嵌有 REFBLOCK 等行内元素)
...@@ -132,7 +132,7 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b ...@@ -132,7 +132,7 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b
if (item.type === 'text') { if (item.type === 'text') {
content += escapeXmlText(item.text || '') content += escapeXmlText(item.text || '')
} 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, compact) content += serializeTreeToXml(child, 0, compact)
} }
...@@ -147,7 +147,7 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b ...@@ -147,7 +147,7 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b
} }
// 纯元素子节点 // 纯元素子节点
const childrenXml = node.children.map(c => serializeTreeToXml(c, indent + 1, compact)).join(newline) const childrenXml = node.children.map((c) => serializeTreeToXml(c, indent + 1, compact)).join(newline)
return `${pad}<${openTag}>${newline}${childrenXml}${newline}${pad}</${node.tagName}>` return `${pad}<${openTag}>${newline}${childrenXml}${newline}${pad}</${node.tagName}>`
} }
...@@ -216,9 +216,9 @@ export function cloneNode(node: XmlNode, newParentId: string | null = null): Xml ...@@ -216,9 +216,9 @@ export function cloneNode(node: XmlNode, newParentId: string | null = null): Xml
id: newId, id: newId,
tagName: node.tagName, tagName: node.tagName,
attributes: { ...node.attributes }, attributes: { ...node.attributes },
children: node.children.map(c => cloneNode(c, newId)), children: node.children.map((c) => cloneNode(c, newId)),
textContent: node.textContent, textContent: node.textContent,
mixedContent: node.mixedContent.map(item => { mixedContent: node.mixedContent.map((item) => {
if (item.type === 'text') return { ...item } if (item.type === 'text') return { ...item }
// 元素引用需要更新 nodeId,但这里只做浅复制标记 // 元素引用需要更新 nodeId,但这里只做浅复制标记
return { ...item } return { ...item }
...@@ -234,7 +234,7 @@ export function getNodeDisplayName(node: XmlNode): string { ...@@ -234,7 +234,7 @@ export function getNodeDisplayName(node: XmlNode): string {
const tag = node.tagName const tag = node.tagName
// 有标题的节点 // 有标题的节点
const titleChild = node.children.find(c => c.tagName === 'TITLEC' || c.tagName === 'TITLE') const titleChild = node.children.find((c) => c.tagName === 'TITLEC' || c.tagName === 'TITLE')
if (titleChild) { if (titleChild) {
const title = titleChild.textContent || getTextFromMixedContent(titleChild) const title = titleChild.textContent || getTextFromMixedContent(titleChild)
if (title) return `${tag}: ${title.slice(0, 40)}${title.length > 40 ? '...' : ''}` if (title) return `${tag}: ${title.slice(0, 40)}${title.length > 40 ? '...' : ''}`
...@@ -270,7 +270,7 @@ export function getNodeDisplayName(node: XmlNode): string { ...@@ -270,7 +270,7 @@ export function getNodeDisplayName(node: XmlNode): string {
function getTextFromMixedContent(node: XmlNode): string { function getTextFromMixedContent(node: XmlNode): string {
if (node.textContent) return node.textContent if (node.textContent) return node.textContent
return node.mixedContent return node.mixedContent
.filter(item => item.type === 'text') .filter((item) => item.type === 'text')
.map(item => item.text || '') .map((item) => item.text || '')
.join('') .join('')
} }
...@@ -135,6 +135,42 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu ...@@ -135,6 +135,42 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
return `${idx + 1}.` return `${idx + 1}.`
} }
if (node.tagName === 'L1ITEM') { if (node.tagName === 'L1ITEM') {
// 1. 尝试向上寻找 SUBTASK 和 TOPIC
let subtaskNode: XmlNode | null = null
let topicNode: XmlNode | null = null
let curr = editorStore.nodeMap.get(node.id)
while (curr) {
if (curr.node.tagName === 'SUBTASK') {
subtaskNode = curr.node
} else if (curr.node.tagName === 'TOPIC') {
topicNode = curr.node
break
}
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
}
// 2. 如果存在 SUBTASK 且在 TOPIC 容器中,按 SUBTASK 的 (FUNC, SEQ) 进行去重编号
if (subtaskNode && topicNode) {
const subtasks = topicNode.children.filter((c) => c.tagName === 'SUBTASK')
const uniqueKeys: string[] = []
for (const sub of subtasks) {
const func = sub.attributes?.FUNC || ''
const seq = sub.attributes?.SEQ || ''
const key = `${func}-${seq}`
if (!uniqueKeys.includes(key)) {
uniqueKeys.push(key)
}
}
const currentFunc = subtaskNode.attributes?.FUNC || ''
const currentSeq = subtaskNode.attributes?.SEQ || ''
const currentKey = `${currentFunc}-${currentSeq}`
const idx = uniqueKeys.indexOf(currentKey)
if (idx !== -1) {
return `${String.fromCharCode(65 + idx)}.`
}
}
// 3. 兜底逻辑:正常在 LIST1 中的索引
const parent = props.parent const parent = props.parent
if (!parent) return 'A.' if (!parent) return 'A.'
const idx = parent.children.filter((c) => c.tagName === 'L1ITEM').findIndex((c) => c.id === node.id) const idx = parent.children.filter((c) => c.tagName === 'L1ITEM').findIndex((c) => c.id === node.id)
...@@ -179,14 +215,56 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu ...@@ -179,14 +215,56 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
return '•' return '•'
} }
// 获取 TOPIC / PRETOPIC 序号 // 获取 TOPIC / PRETOPIC 序号 (与 PDF 端的 XSL 解析逻辑对齐)
const getTopicSeqNum = (node: XmlNode): string => { const getTopicSeqNum = (node: XmlNode): string => {
const parent = props.parent let rootNode: XmlNode | null = null
if (!parent) return '' let isAlphaFormat = false
if (parent.tagName === 'CEP' || parent.tagName === 'TASK') { let curr = editorStore.nodeMap.get(node.id)
const topicSiblings = parent.children.filter((c) => c.tagName === 'TOPIC' || c.tagName === 'PRETOPIC') while (curr) {
const idx = topicSiblings.findIndex((c) => c.id === node.id) if (curr.node.tagName === 'CEP' || curr.node.tagName === 'TASK') {
if (idx !== -1) { rootNode = curr.node
isAlphaFormat = false
break
}
if (curr.node.tagName === 'JC-TASK') {
rootNode = curr.node
isAlphaFormat = true
break
}
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
}
if (!rootNode) return ''
// 在 rootNode 下收集所有符合条件的 TOPIC 和 PRETOPIC 节点
const collected: XmlNode[] = []
const traverse = (n: XmlNode) => {
if (n.tagName === 'TOPIC') {
const parentItem = editorStore.nodeMap.get(n.id)?.parent
if (parentItem && (parentItem.tagName === 'CEP' || parentItem.tagName === 'TASK' || parentItem.tagName === 'JC-TASK')) {
collected.push(n)
}
} else if (n.tagName === 'PRETOPIC') {
const parentItem = editorStore.nodeMap.get(n.id)?.parent
if (parentItem && parentItem.tagName === 'TFMATR') {
const grandParentItem = editorStore.nodeMap.get(parentItem.id)?.parent
if (
grandParentItem &&
(grandParentItem.tagName === 'CEP' || grandParentItem.tagName === 'TASK' || grandParentItem.tagName === 'JC-TASK')
) {
collected.push(n)
}
}
}
n.children.forEach(traverse)
}
traverse(rootNode)
const idx = collected.findIndex((n) => n.id === node.id)
if (idx !== -1) {
if (isAlphaFormat) {
return `${String.fromCharCode(65 + idx)}. `
} else {
return `${idx + 1}. ` return `${idx + 1}. `
} }
} }
...@@ -329,8 +407,8 @@ export const getSplitListChildren = (children?: XmlNode[]) => { ...@@ -329,8 +407,8 @@ export const getSplitListChildren = (children?: XmlNode[]) => {
} }
export const getCepTaskNumber = (node: XmlNode): string => { export const getCepTaskNumber = (node: XmlNode): string => {
const { CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes || {} const { CHAPPREF, CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes || {}
const parts = [CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean) const parts = [CHAPPREF, CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean)
return parts.join('-') return parts.join('-')
} }
......
...@@ -94,36 +94,74 @@ ...@@ -94,36 +94,74 @@
<!-- 6. PARA / PARAC 段落文本处理 --> <!-- 6. PARA / PARAC 段落文本处理 -->
<template v-else-if="PARA_SET.has(node.tagName)"> <template v-else-if="PARA_SET.has(node.tagName)">
<!-- 如果有子节点,说明属于容器段落,就不在 PARA 中展示或编辑文本内容,只展示其子元素节点 -->
<div <div
v-if="node.children && node.children.length > 0"
class="text-sm leading-relaxed py-1 flex flex-wrap items-center" class="text-sm leading-relaxed py-1 flex flex-wrap items-center"
:class="[isInsideAlert ? 'text-inherit' : 'text-color2']" :class="[isInsideAlert ? 'text-inherit' : 'text-color2']"
:style="{ justifyContent: 'var(--cell-align, flex-start)' }"
> >
<DocNodeRenderer <template v-if="node.mixedContent && node.mixedContent.length > 0">
v-for="child in node.children" <template v-for="(item, index) in node.mixedContent" :key="index">
:key="child.id" <span
:node="child" v-if="item.type === 'text'"
:parent="node" contenteditable="true"
is-inline class="focus:outline-none focus:bg-fill-3 px-0.5 rounded inline"
:inside-ref-block="insideRefBlock" @blur="handleMixedTextBlur(index, $event)"
/> v-text="item.text"
></span>
<DocNodeRenderer
v-else-if="item.type === 'element' && getChildNode(item.nodeId!)"
:node="getChildNode(item.nodeId!)!"
:parent="node"
is-inline
:inside-ref-block="insideRefBlock"
/>
</template>
</template>
<!-- 如果没有 mixedContent,但是有子节点,说明属于容器段落,就不在 PARA 中展示或编辑文本内容,只展示其子元素节点 -->
<template v-else-if="node.children && node.children.length > 0">
<DocNodeRenderer
v-for="child in node.children"
:key="child.id"
:node="child"
:parent="node"
is-inline
:inside-ref-block="insideRefBlock"
/>
</template>
<!-- 否则,如果是一个纯文本的段落,直接展示和编辑文本 -->
<template v-else>
<div
contenteditable="true"
class="w-full focus:outline-none focus:bg-fill-3 px-1 rounded transition-colors"
@blur="handleTextBlur"
v-text="node.textContent"
></div>
</template>
</div> </div>
<!-- 否则,如果是一个纯文本的段落,直接展示和编辑文本 -->
<div
v-else
contenteditable="true"
class="text-sm leading-relaxed py-1 focus:outline-none focus:bg-fill-3 px-1 rounded transition-colors"
:class="[isInsideAlert ? 'text-inherit' : 'text-color2']"
@blur="handleTextBlur"
v-text="node.textContent"
></div>
</template> </template>
<!-- 7. REFBLOCK 特殊处理 --> <!-- 7. REFBLOCK 特殊处理 -->
<template v-else-if="node.tagName === 'REFBLOCK'"> <template v-else-if="node.tagName === 'REFBLOCK'">
<span class="inline-ref-block font-medium" style="color: blue"> <span class="inline-ref-block font-medium" style="color: blue">
<template v-if="node.children && node.children.length > 0"> <template v-if="node.mixedContent && node.mixedContent.length > 0">
<template v-for="(item, index) in node.mixedContent" :key="index">
<span
v-if="item.type === 'text'"
contenteditable="true"
class="focus:outline-none focus:bg-fill-3 px-0.5 rounded inline"
@blur="handleMixedTextBlur(index, $event)"
v-text="item.text"
></span>
<DocNodeRenderer
v-else-if="item.type === 'element' && getChildNode(item.nodeId!)"
:node="getChildNode(item.nodeId!)!"
:parent="node"
is-inline
:inside-ref-block="true"
/>
</template>
</template>
<template v-else-if="node.children && node.children.length > 0">
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" is-inline :inside-ref-block="true" /> <DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" is-inline :inside-ref-block="true" />
</template> </template>
<template v-else> <template v-else>
...@@ -246,17 +284,16 @@ ...@@ -246,17 +284,16 @@
<!-- 13. TED / CON (工具/消耗品数据组合) 统一配置化渲染 --> <!-- 13. TED / CON (工具/消耗品数据组合) 统一配置化渲染 -->
<template v-else-if="node.tagName === 'TED' || node.tagName === 'CON'"> <template v-else-if="node.tagName === 'TED' || node.tagName === 'CON'">
<span class="text-color1 cursor-pointer select-none inline-flex items-center space-x-1" @click.stop="editorStore.setSelectedNodeId(node.id)"> <span
class="text-color1 cursor-pointer select-none inline-flex items-center space-x-1"
@click.stop="editorStore.setSelectedNodeId(node.id)"
>
<template v-for="childTag in COMPOSITE_CONTAINER_MAP[node.tagName]" :key="childTag"> <template v-for="childTag in COMPOSITE_CONTAINER_MAP[node.tagName]" :key="childTag">
<template v-if="node.children.find((c) => c.tagName === childTag)"> <template v-if="node.children.find((c) => c.tagName === childTag)">
<span v-if="childTag.endsWith('NBR')" class="text-color3 select-none mx-0.5"> <span v-if="childTag.endsWith('NBR')" class="text-color3 select-none mx-0.5">
{{ childTag === 'CONNBR' ? '(Material Ref. ' : '(' }} {{ childTag === 'CONNBR' ? '(Material Ref. ' : '(' }}
</span> </span>
<DocNodeRenderer <DocNodeRenderer :node="node.children.find((c) => c.tagName === childTag)!" :parent="node" is-inline />
:node="node.children.find((c) => c.tagName === childTag)!"
:parent="node"
is-inline
/>
<span v-if="childTag.endsWith('NBR')" class="text-color3 select-none mx-0.5">)</span> <span v-if="childTag.endsWith('NBR')" class="text-color3 select-none mx-0.5">)</span>
</template> </template>
</template> </template>
...@@ -318,9 +355,11 @@ ...@@ -318,9 +355,11 @@
:data-node-id="cbData.id" :data-node-id="cbData.id"
class="h-8 hover:bg-fill-3 transition-colors cursor-pointer" class="h-8 hover:bg-fill-3 transition-colors cursor-pointer"
:class="[ :class="[
editorStore.selectedNodeId === cbData.id || editorStore.selectedNodeId === cbData.id ||
cbData.children.some(c => c.id === editorStore.selectedNodeId && COMPOSITE_CONTAINER_MAP['CBDATA']?.includes(c.tagName)) cbData.children.some(
? 'bg-primary/5 font-bold' (c) => c.id === editorStore.selectedNodeId && COMPOSITE_CONTAINER_MAP['CBDATA']?.includes(c.tagName)
)
? 'bg-primary/5 font-bold'
: '' : ''
]" ]"
@click.stop="editorStore.setSelectedNodeId(cbData.id)" @click.stop="editorStore.setSelectedNodeId(cbData.id)"
...@@ -495,7 +534,8 @@ ...@@ -495,7 +534,8 @@
<!-- 23. SIGNOFF (签字点) 处理 (仿照 PDF 签字表) --> <!-- 23. SIGNOFF (签字点) 处理 (仿照 PDF 签字表) -->
<template v-else-if="node.tagName === 'SIGNOFF'"> <template v-else-if="node.tagName === 'SIGNOFF'">
<div class="my-3 overflow-x-auto select-none"> <component v-if="CustomSignoff" :is="CustomSignoff" :node="node" />
<div v-else class="my-3 overflow-x-auto select-none">
<table class="min-w-[400px] border-collapse border border-black text-xs"> <table class="min-w-[400px] border-collapse border border-black text-xs">
<tbody> <tbody>
<tr class="h-8"> <tr class="h-8">
...@@ -554,6 +594,7 @@ ...@@ -554,6 +594,7 @@
<!-- 24.5 SUBTASK 特殊处理 (加上左侧虚线框和段落缩进) --> <!-- 24.5 SUBTASK 特殊处理 (加上左侧虚线框和段落缩进) -->
<template v-else-if="node.tagName === 'SUBTASK'"> <template v-else-if="node.tagName === 'SUBTASK'">
<div class="space-y-1 pl-4 border-l border-dashed border-divider/60 my-2"> <div class="space-y-1 pl-4 border-l border-dashed border-divider/60 my-2">
<div class="text-xs font-bold text-color2/80 mb-2 select-none">子任务 {{ getCepTaskNumber(node) }}</div>
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" /> <DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" />
</div> </div>
</template> </template>
...@@ -569,16 +610,19 @@ ...@@ -569,16 +610,19 @@
<template v-else-if="node.tagName === 'EINLST'"> <template v-else-if="node.tagName === 'EINLST'">
<div class="my-2 py-1 select-text"> <div class="my-2 py-1 select-text">
<template v-if="isAllEinDataSameEffect(node)"> <template v-if="isAllEinDataSameEffect(node)">
<div class="flex flex-wrap items-center gap-1.5 text-sm"> <div class="flex flex-wrap items-center gap-1.5 text-sm" :style="{ justifyContent: 'var(--cell-align, flex-start)' }">
<span class="text-color2 font-medium">FIN:</span> <span class="text-color2 font-medium">FIN:</span>
<template v-for="eindata in node.children" :key="eindata.id"> <template v-for="eindata in node.children" :key="eindata.id">
<template v-for="child in eindata.children.filter((c) => COMPOSITE_CONTAINER_MAP['EINDATA']?.includes(c.tagName))" :key="child.id"> <template
v-for="child in eindata.children.filter((c) => COMPOSITE_CONTAINER_MAP['EINDATA']?.includes(c.tagName))"
:key="child.id"
>
<span <span
class="font-mono cursor-pointer hover:underline transition-all" class="font-mono cursor-pointer hover:underline transition-all"
:data-node-id="child.id" :data-node-id="child.id"
:class="[ :class="[
editorStore.selectedNodeId === child.id editorStore.selectedNodeId === child.id
? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/20 px-0.5' ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/20 px-0.5'
: 'underline' : 'underline'
]" ]"
style="color: blue" style="color: blue"
...@@ -602,13 +646,16 @@ ...@@ -602,13 +646,16 @@
:parent="eindata" :parent="eindata"
/> />
<div class="pl-2"> <div class="pl-2">
<template v-for="child in eindata.children.filter((c) => COMPOSITE_CONTAINER_MAP['EINDATA']?.includes(c.tagName))" :key="child.id"> <template
v-for="child in eindata.children.filter((c) => COMPOSITE_CONTAINER_MAP['EINDATA']?.includes(c.tagName))"
:key="child.id"
>
<span <span
class="font-mono cursor-pointer hover:underline transition-all" class="font-mono cursor-pointer hover:underline transition-all"
:data-node-id="child.id" :data-node-id="child.id"
:class="[ :class="[
editorStore.selectedNodeId === child.id editorStore.selectedNodeId === child.id
? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/20 px-0.5' ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/20 px-0.5'
: 'underline' : 'underline'
]" ]"
style="color: blue" style="color: blue"
...@@ -660,6 +707,45 @@ ...@@ -660,6 +707,45 @@
</span> </span>
</template> </template>
<!-- E1. EMPTY 类型空标记节点(REVST/REVEND/COCST/COCEND/COCEFF/HOTLINK 等)-->
<!-- 这些节点是 DTD 中的 EMPTY 元素,没有任何内容,不得显示其 tagName,必须输出为空 -->
<template v-else-if="EMPTY_MARKER_SET.has(node.tagName)">
<!-- 完全不渲染任何内容 -->
</template>
<!-- E2. NOTE 变体节点(HNANOTE/ACPANOTE/APAPNOTE/EAPPNOTE/INTNOTE/SPECNOTE-->
<!-- 结构上等同于 NOTE,但含义不同,用不同的标签文字区分 -->
<template v-else-if="NOTE_VARIANT_SET.has(node.tagName)">
<div class="relative my-3 text-sm" style="padding-left: 80px; color: blue">
<span class="absolute left-0 top-0 font-bold underline select-none" style="color: blue">
{{
node.tagName === 'HNANOTE'
? '操作注意 HNANOTE:'
: node.tagName === 'ACPANOTE'
? '盖板注意 ACPANOTE:'
: node.tagName === 'APAPNOTE'
? '适用件注意 APAPNOTE:'
: node.tagName === 'EAPPNOTE'
? '现装件注意 EAPPNOTE:'
: node.tagName === 'INTNOTE'
? '内部注意 INTNOTE:'
: '专项注意 SPECNOTE:'
}}
</span>
<div class="space-y-1">
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" />
</div>
</div>
</template>
<!-- E3. 隐藏结构型元数据容器(ASSODATA/TASKREQ/ELAPSTIM/MANHOUR/NBRPERS 等)-->
<!-- 这些节点包含工卡关联数据和任务要求,不是正文内容,仅选中时提示 -->
<template v-else-if="HIDDEN_STRUCTURAL_SET.has(node.tagName)">
<div v-if="isSelected" class="p-2 border border-dashed border-divider rounded bg-fill-2 text-center text-xs text-color3 select-none">
[ {{ node.tagName }} 关联元数据节点,不在正文区域显示 ]
</div>
</template>
<!-- 27. 兜底未匹配的普通文本标签 --> <!-- 27. 兜底未匹配的普通文本标签 -->
<template v-else> <template v-else>
<span <span
...@@ -686,14 +772,28 @@ import { ImageOutline, GridOutline } from '@vicons/ionicons5' ...@@ -686,14 +772,28 @@ import { ImageOutline, GridOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import TableEditor from '../TableEditor/index.vue' import TableEditor from '../TableEditor/index.vue'
import { useDocNodeRenderer, getSplitListChildren, getCepTaskNumber, isAllEinDataSameEffect } from './functionals' import { useDocNodeRenderer, getSplitListChildren, getCepTaskNumber, isAllEinDataSameEffect } from './functionals'
import { ALERT_BLOCK_TAGS, PARA_TAGS, CB_COMPONENT_TAGS, SUPER_SUB_TAGS, COMPOSITE_CONTAINER_TAGS, COMPOSITE_CONTAINER_MAP } from '@/configs/xmlTags' import { ALERT_BLOCK_TAGS, PARA_TAGS, CB_COMPONENT_TAGS, SUPER_SUB_TAGS, COMPOSITE_CONTAINER_MAP, EMPTY_MARKER_TAGS, NOTE_VARIANT_TAGS, HIDDEN_STRUCTURAL_TAGS } from '@/configs/xmlTags'
// 动态载入各个定制环境下的 SignoffRenderer.vue
const customCode = import.meta.env.VITE_CUSTOM_CODE
const customSignoffModules = import.meta.glob('../../../../**/SignoffRenderer.vue', { eager: true }) as Record<string, any>
const getCustomSignoff = () => {
if (!customCode) return null
const matchKey = Object.keys(customSignoffModules).find((key) => key.toLowerCase().includes(`/${customCode.toLowerCase()}/`))
return matchKey ? customSignoffModules[matchKey].default || customSignoffModules[matchKey] : null
}
const CustomSignoff = getCustomSignoff()
// 预构建 Set,供模板 v-else-if 判断使用 // 预构建 Set,供模板 v-else-if 判断使用
const ALERT_BLOCK_SET = new Set(ALERT_BLOCK_TAGS) const ALERT_BLOCK_SET = new Set(ALERT_BLOCK_TAGS)
const PARA_SET = new Set(PARA_TAGS) const PARA_SET = new Set(PARA_TAGS)
const CB_COMPONENT_SET = new Set(CB_COMPONENT_TAGS) const CB_COMPONENT_SET = new Set(CB_COMPONENT_TAGS)
const SUPER_SUB_SET = new Set(SUPER_SUB_TAGS) const SUPER_SUB_SET = new Set(SUPER_SUB_TAGS)
const COMPOSITE_CONTAINER_SET = new Set(COMPOSITE_CONTAINER_TAGS) const EMPTY_MARKER_SET = new Set(EMPTY_MARKER_TAGS)
const NOTE_VARIANT_SET = new Set(NOTE_VARIANT_TAGS)
const HIDDEN_STRUCTURAL_SET = new Set(HIDDEN_STRUCTURAL_TAGS)
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
...@@ -722,15 +822,12 @@ const { ...@@ -722,15 +822,12 @@ const {
getTopicSeqNum, getTopicSeqNum,
getTopicTitleNodes, getTopicTitleNodes,
getTopicContentNodes, getTopicContentNodes,
getChildNode,
handleTextBlur, handleTextBlur,
handleChildTextBlur, handleChildTextBlur,
getGraphicTitle, handleMixedTextBlur,
handleGraphicTitleBlur,
getTedText,
getConText,
hasNonCbDataChildren, hasNonCbDataChildren,
getNonCbDataChildren, getNonCbDataChildren
getCbValue
} = useDocNodeRenderer(props) } = useDocNodeRenderer(props)
</script> </script>
......
...@@ -19,9 +19,12 @@ export function useFindReplace( ...@@ -19,9 +19,12 @@ export function useFindReplace(
const matchItemRefs = ref<any[]>([]) const matchItemRefs = ref<any[]>([])
// 每次匹配列表变化时重置 ref 数组 // 每次匹配列表变化时重置 ref 数组
watch(() => matches.value, () => { watch(
matchItemRefs.value = [] () => matches.value,
}) () => {
matchItemRefs.value = []
}
)
const scrollToActiveMatch = () => { const scrollToActiveMatch = () => {
nextTick(() => { nextTick(() => {
...@@ -30,7 +33,7 @@ export function useFindReplace( ...@@ -30,7 +33,7 @@ export function useFindReplace(
if (activeEl && containerEl) { if (activeEl && containerEl) {
const activeRect = activeEl.getBoundingClientRect() const activeRect = activeEl.getBoundingClientRect()
const containerRect = containerEl.getBoundingClientRect() const containerRect = containerEl.getBoundingClientRect()
// 若高亮项已超出可视区域的上边缘或下边缘,则将其滚动入屏 // 若高亮项已超出可视区域的上边缘或下边缘,则将其滚动入屏
if (activeRect.top < containerRect.top || activeRect.bottom > containerRect.bottom) { if (activeRect.top < containerRect.top || activeRect.bottom > containerRect.bottom) {
activeEl.scrollIntoView({ activeEl.scrollIntoView({
...@@ -100,7 +103,7 @@ export function useFindReplace( ...@@ -100,7 +103,7 @@ export function useFindReplace(
if (index < 0 || index >= matches.value.length) return if (index < 0 || index >= matches.value.length) return
currentMatchIndex.value = index currentMatchIndex.value = index
const match = matches.value[index] const match = matches.value[index]
// 选中该节点并调用父级导出的平滑滚动方法 // 选中该节点并调用父级导出的平滑滚动方法
editorStore.setSelectedNodeId(match.nodeId) editorStore.setSelectedNodeId(match.nodeId)
props.syncEditorScroll(match.nodeId, true) props.syncEditorScroll(match.nodeId, true)
...@@ -129,14 +132,14 @@ export function useFindReplace( ...@@ -129,14 +132,14 @@ export function useFindReplace(
const replaceCurrent = () => { const replaceCurrent = () => {
if (matches.value.length === 0 || currentMatchIndex.value === -1) return if (matches.value.length === 0 || currentMatchIndex.value === -1) return
const match = matches.value[currentMatchIndex.value] const match = matches.value[currentMatchIndex.value]
const success = editorStore.replaceMatch(match, replaceQuery.value) const success = editorStore.replaceMatch(match, replaceQuery.value)
if (success) { if (success) {
window.$message?.success('替换成功') window.$message?.success('替换成功')
// 重新查找以保持精准的位置和剩余结果统计 // 重新查找以保持精准的位置和剩余结果统计
const prevIndex = currentMatchIndex.value const prevIndex = currentMatchIndex.value
doSearch() doSearch()
// 如果仍然有匹配,定位到下一个或上一个 // 如果仍然有匹配,定位到下一个或上一个
if (matches.value.length > 0) { if (matches.value.length > 0) {
const nextIdx = Math.min(prevIndex, matches.value.length - 1) const nextIdx = Math.min(prevIndex, matches.value.length - 1)
...@@ -154,7 +157,7 @@ export function useFindReplace( ...@@ -154,7 +157,7 @@ export function useFindReplace(
matchCase: matchCase.value, matchCase: matchCase.value,
regExp: regExp.value regExp: regExp.value
}) })
if (count > 0) { if (count > 0) {
window.$message?.success(`成功替换了 ${count} 处匹配`) window.$message?.success(`成功替换了 ${count} 处匹配`)
matches.value = [] matches.value = []
...@@ -173,7 +176,7 @@ export function useFindReplace( ...@@ -173,7 +176,7 @@ export function useFindReplace(
const padding = isExpanded.value ? 35 : 15 const padding = isExpanded.value ? 35 : 15
const startOffset = Math.max(0, start - padding) const startOffset = Math.max(0, start - padding)
const endOffset = Math.min(text.length, start + len + padding) const endOffset = Math.min(text.length, start + len + padding)
let context = text.substring(startOffset, endOffset) let context = text.substring(startOffset, endOffset)
if (startOffset > 0) context = '...' + context if (startOffset > 0) context = '...' + context
if (endOffset < text.length) context = context + '...' if (endOffset < text.length) context = context + '...'
......
...@@ -5,9 +5,12 @@ ...@@ -5,9 +5,12 @@
v-if="visible" v-if="visible"
class="find-replace-panel absolute top-20 right-4 z-[999] bg-card/90 backdrop-blur-md border border-divider shadow-2xl rounded-xl p-4 flex flex-col space-y-3 select-none" class="find-replace-panel absolute top-20 right-4 z-[999] bg-card/90 backdrop-blur-md border border-divider shadow-2xl rounded-xl p-4 flex flex-col space-y-3 select-none"
:style="{ :style="{
width: isExpanded ? '800px' : '500px', width: computedWidth,
height: computedHeight,
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)`, transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)`,
transition: dragging ? 'none' : 'width 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.1s ease-out' transition: dragging
? 'none'
: 'width 0.3s cubic-bezier(0.16, 1, 0.3, 1), height 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.1s ease-out'
}" }"
:class="{ 'is-expanded': isExpanded }" :class="{ 'is-expanded': isExpanded }"
> >
...@@ -116,14 +119,15 @@ ...@@ -116,14 +119,15 @@
</div> </div>
<!-- 匹配文本摘要预览(引入 CommonNodeDetailList 全新统一布局组件,采用 flat 平铺和响应式 dense 尺寸) --> <!-- 匹配文本摘要预览(引入 CommonNodeDetailList 全新统一布局组件,采用 flat 平铺和响应式 dense 尺寸) -->
<div v-if="matches.length > 0" class="border border-divider/50 rounded-lg overflow-hidden"> <div v-if="matches.length > 0" class="border border-divider/50 rounded-lg overflow-hidden flex-1 min-h-0 flex flex-col">
<CommonNodeDetailList <CommonNodeDetailList
v-model:selected-id="activeMatchNodeId" v-model:selected-id="activeMatchNodeId"
:node-ids="matchNodeIds" :node-ids="matchNodeIds"
:flat="true" :flat="true"
:dense="!isExpanded" :dense="!isExpanded"
mode="radio" mode="radio"
:max-height="isExpanded ? 480 : 240" :max-height="'100%'"
class="h-full"
:active-match-index="currentMatchIndex" :active-match-index="currentMatchIndex"
:node-match-stats="nodeMatchStats" :node-match-stats="nodeMatchStats"
:highlight="findQuery" :highlight="findQuery"
...@@ -136,6 +140,7 @@ ...@@ -136,6 +140,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SearchOutline, CloseOutline, ChevronUpOutline, ChevronDownOutline, CodeOutline } from '@vicons/ionicons5' import { SearchOutline, CloseOutline, ChevronUpOutline, ChevronDownOutline, CodeOutline } from '@vicons/ionicons5'
import { useWindowSize } from '@vueuse/core'
import { useFindReplace, useDraggable } from './functionals' import { useFindReplace, useDraggable } from './functionals'
const props = defineProps<{ const props = defineProps<{
...@@ -172,6 +177,33 @@ const { ...@@ -172,6 +177,33 @@ const {
closePanel closePanel
} = useFindReplace(props, emit) } = useFindReplace(props, emit)
const { width: windowWidth, height: windowHeight } = useWindowSize()
// 计算弹框宽度,基于窗口宽度动态计算
const computedWidth = computed(() => {
if (isExpanded.value) {
// 放大模式:屏幕宽度的 55%,限制在 700px 到 1200px 之间
return `${Math.min(1200, Math.max(700, Math.round(windowWidth.value * 0.55)))}px`
} else {
// 折叠模式:屏幕宽度的 30%,限制在 380px 到 600px 之间
return `${Math.min(600, Math.max(380, Math.round(windowWidth.value * 0.3)))}px`
}
})
// 计算弹框高度,基于窗口高度和是否有匹配结果动态计算
const computedHeight = computed(() => {
if (matches.value.length === 0) {
return 'auto'
}
if (isExpanded.value) {
// 放大模式:屏幕高度的 70%,限制在 550px 到 900px 之间
return `${Math.min(900, Math.max(550, Math.round(windowHeight.value * 0.7)))}px`
} else {
// 折叠模式:屏幕高度的 45%,限制在 350px 到 550px 之间
return `${Math.min(550, Math.max(350, Math.round(windowHeight.value * 0.45)))}px`
}
})
const matchNodeIds = computed(() => { const matchNodeIds = computed(() => {
const ids = matches.value.map((m) => m.nodeId) const ids = matches.value.map((m) => m.nodeId)
return Array.from(new Set(ids)) return Array.from(new Set(ids))
......
...@@ -14,21 +14,32 @@ export const ESTIMATED_HEIGHT = 200 ...@@ -14,21 +14,32 @@ export const ESTIMATED_HEIGHT = 200
/** 按节点类型获取预估高度(用于虚拟列表初始高度计算) */ /** 按节点类型获取预估高度(用于虚拟列表初始高度计算) */
export const getEstimatedHeight = (tagName: string): number => { export const getEstimatedHeight = (tagName: string): number => {
switch (tagName) { switch (tagName) {
case 'TABLE': return 400 // 表格通常较高 case 'TABLE':
case 'GRAPHIC': return 300 // 图片/图纸 return 400 // 表格通常较高
case 'WARNING': return 180 // 警告块 case 'GRAPHIC':
case 'CAUTION': return 180 return 300 // 图片/图纸
case 'NOTE': return 150 case 'WARNING':
case 'PRETOPIC': return 160 // 模板段落 return 180 // 警告块
case 'CAUTION':
return 180
case 'NOTE':
return 150
case 'PRETOPIC':
return 160 // 模板段落
case 'UNLIST': case 'UNLIST':
case 'LIST1': case 'LIST1':
case 'LIST2': case 'LIST2':
case 'LIST3': return 200 // 列表 case 'LIST3':
return 200 // 列表
case 'PARA': case 'PARA':
case 'PARAC': return 80 // 普通段落 case 'PARAC':
case 'SMUC-HEADER': return 120 return 80 // 普通段落
case 'FINLIST': return 100 case 'SMUC-HEADER':
default: return ESTIMATED_HEIGHT return 120
case 'FINLIST':
return 100
default:
return ESTIMATED_HEIGHT
} }
} }
......
...@@ -41,7 +41,12 @@ ...@@ -41,7 +41,12 @@
</div> </div>
<!-- 文档编辑区(虚拟滚动容器) --> <!-- 文档编辑区(虚拟滚动容器) -->
<div ref="viewportRef" class="flex-1 overflow-y-auto min-h-0 leading-relaxed relative" @scroll="handleScroll" @contextmenu="handleContextMenu"> <div
ref="viewportRef"
class="flex-1 overflow-y-auto min-h-0 leading-relaxed relative"
@scroll="handleScroll"
@contextmenu="handleContextMenu"
>
<!-- 占位撑高,模拟全量内容总高度 --> <!-- 占位撑高,模拟全量内容总高度 -->
<div :style="{ height: totalHeight + 'px', position: 'relative' }"> <div :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 仅渲染可视区块,通过 translateY 定位 --> <!-- 仅渲染可视区块,通过 translateY 定位 -->
......
...@@ -99,7 +99,6 @@ ...@@ -99,7 +99,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CheckmarkCircleOutline } from '@vicons/ionicons5' import { CheckmarkCircleOutline } from '@vicons/ionicons5'
import { useBatchTranslate } from './functionals' import { useBatchTranslate } from './functionals'
......
import type { FormRules } from 'naive-ui'
export const SIGN_FORM_RULES: FormRules = {
ckLevel: { required: true, message: '请选择签字等级', trigger: ['blur', 'change'] }
}
import type { FormInst } from 'naive-ui'
import { SIGN_FORM_RULES } from '../constants'
export function useCreateSignoffModal(emit: (event: 'confirm', ckLevel: string) => void) {
const show = ref(false)
const formRef = ref<FormInst | null>(null)
const form = reactive({
ckLevel: ''
})
const ckLevelOptions = computed(() => {
const rule = getElementRule('SIGNOFF')
const attrRule = rule?.attributes?.['CK-LEVEL']
if (attrRule && attrRule.enumValues) {
return attrRule.enumValues.map((val: string) => ({ label: `${val} 级`, value: val }))
}
return [
{ label: 'A 级', value: 'A' },
{ label: 'B 级', value: 'B' }
]
})
const open = () => {
if (ckLevelOptions.value.length > 0) {
form.ckLevel = ckLevelOptions.value[0].value
} else {
form.ckLevel = ''
}
show.value = true
}
const handleConfirm = async () => {
try {
await formRef.value?.validate()
emit('confirm', form.ckLevel)
show.value = false
} catch (err) {
// validation failed
}
}
return {
show,
formRef,
form,
rules: SIGN_FORM_RULES,
ckLevelOptions,
open,
handleConfirm
}
}
<template>
<CommonModal v-model="show" title="插入签字点" :width="380" @confirm="handleConfirm">
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<div class="flex flex-col gap-4 py-2">
<n-form-item label="签字等级 (CK-LEVEL)" path="ckLevel">
<CommonSelect v-model:value="form.ckLevel" :options="ckLevelOptions" />
</n-form-item>
</div>
</n-form>
</CommonModal>
</template>
<script setup lang="ts">
import { useCreateSignoffModal } from './functionals'
const emit = defineEmits<{
confirm: [ckLevel: string]
}>()
const { show, formRef, form, rules, ckLevelOptions, open, handleConfirm } = useCreateSignoffModal(emit)
defineExpose({ open })
</script>
...@@ -30,11 +30,7 @@ ...@@ -30,11 +30,7 @@
</div> </div>
<n-form-item label="单元格初始化段落节点" path="cellChildTags"> <n-form-item label="单元格初始化段落节点" path="cellChildTags">
<div class="w-full bg-fill-2 p-3 rounded border border-divider"> <div class="w-full bg-fill-2 p-3 rounded border border-divider">
<CommonCheckbox <CommonCheckbox v-model:value="form.cellChildTags" :options="cellChildOptions" :space-size="24" />
v-model:value="form.cellChildTags"
:options="cellChildOptions"
:space-size="24"
/>
</div> </div>
</n-form-item> </n-form-item>
</div> </div>
......
...@@ -38,26 +38,12 @@ ...@@ -38,26 +38,12 @@
</div> </div>
<div class="flex flex-col gap-2 max-h-[200px] overflow-y-auto pr-1"> <div class="flex flex-col gap-2 max-h-[200px] overflow-y-auto pr-1">
<div <div v-for="(pair, index) in form.tag_pairs" :key="index" class="flex items-center gap-2">
v-for="(pair, index) in form.tag_pairs" <n-input v-model:value="pair.en_tag" placeholder="源英文标签 (例: PARA)" size="small" class="flex-1" />
:key="index"
class="flex items-center gap-2"
>
<n-input
v-model:value="pair.en_tag"
placeholder="源英文标签 (例: PARA)"
size="small"
class="flex-1"
/>
<n-icon class="text-color3"> <n-icon class="text-color3">
<arrow-forward-outline /> <arrow-forward-outline />
</n-icon> </n-icon>
<n-input <n-input v-model:value="pair.cn_tag" placeholder="目标中文标签 (例: PARAC)" size="small" class="flex-1" />
v-model:value="pair.cn_tag"
placeholder="目标中文标签 (例: PARAC)"
size="small"
class="flex-1"
/>
<CommonButton <CommonButton
size="small" size="small"
type="error" type="error"
...@@ -89,9 +75,13 @@ ...@@ -89,9 +75,13 @@
<div> <div>
<div class="text-base font-bold text-color1">对照对提取处理成功!</div> <div class="text-base font-bold text-color1">对照对提取处理成功!</div>
<div class="text-xs text-color3"> <div class="text-xs text-color3">
共提取中英双语对:<span class="text-primary font-bold text-sm">{{ totalCount }}</span> 共提取中英双语对:
<span class="text-primary font-bold text-sm">{{ totalCount }}</span>
<span v-if="form.import_to_db" class="ml-2"> <span v-if="form.import_to_db" class="ml-2">
,导入数据库:<span class="text-success font-bold text-sm">{{ importCount }}</span> ,导入数据库:
<span class="text-success font-bold text-sm">{{ importCount }}</span>
</span> </span>
</div> </div>
</div> </div>
...@@ -99,16 +89,8 @@ ...@@ -99,16 +89,8 @@
<!-- 提取数据预览列表 --> <!-- 提取数据预览列表 -->
<div class="border border-divider rounded-lg overflow-hidden"> <div class="border border-divider rounded-lg overflow-hidden">
<div class="bg-fill-3 px-3 py-2 text-xs font-bold text-color2 border-b border-divider"> <div class="bg-fill-3 px-3 py-2 text-xs font-bold text-color2 border-b border-divider">数据预览 (展示前 10 条)</div>
数据预览 (展示前 10 条) <n-data-table :columns="previewColumns" :data="previewList" :max-height="250" size="small" :bordered="false" />
</div>
<n-data-table
:columns="previewColumns"
:data="previewList"
:max-height="250"
size="small"
:bordered="false"
/>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -38,11 +38,7 @@ export function useInsertFragmentModal() { ...@@ -38,11 +38,7 @@ export function useInsertFragmentModal() {
isSaving.value = true isSaving.value = true
try { try {
const count = editorStore.insertXmlFragment( const count = editorStore.insertXmlFragment(xmlContent.value.trim(), insertModeSetting.value, targetNodeIdSetting.value)
xmlContent.value.trim(),
insertModeSetting.value,
targetNodeIdSetting.value
)
window.$message?.success(`成功插入 ${count} 个 XML 节点`) window.$message?.success(`成功插入 ${count} 个 XML 节点`)
visible.value = false visible.value = false
} catch (err: any) { } catch (err: any) {
......
<template> <template>
<CommonModal <CommonModal v-model="visible" title="插入 XML 片段" :width="600" :loading="isSaving" confirm-text="插入" @confirm="handleConfirm">
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="flex flex-col space-y-3 p-1">
<div class="text-xs text-color3"> <div class="text-xs text-color3">
请输入从其他地方复制的 XML 节点片段,系统将自动解析节点结构,并根据当前选中节点及 DTD 架构规则进行兼容性校验: 请输入从其他地方复制的 XML 节点片段,系统将自动解析节点结构,并根据当前选中节点及 DTD 架构规则进行兼容性校验:
</div> </div>
<n-input <n-input v-model:value="xmlContent" type="textarea" rows="10" placeholder="例如:<PARAC>测试记录行</PARAC>" />
v-model:value="xmlContent"
type="textarea"
rows="10"
placeholder="例如:<PARAC>测试记录行</PARAC>"
/>
</div> </div>
</CommonModal> </CommonModal>
</template> </template>
...@@ -24,13 +12,7 @@ ...@@ -24,13 +12,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useInsertFragmentModal } from './functionals' import { useInsertFragmentModal } from './functionals'
const { const { visible, xmlContent, isSaving, open, handleConfirm } = useInsertFragmentModal()
visible,
xmlContent,
isSaving,
open,
handleConfirm
} = useInsertFragmentModal()
defineExpose({ defineExpose({
open, open,
......
...@@ -57,7 +57,6 @@ export function useSearchTranslate() { ...@@ -57,7 +57,6 @@ export function useSearchTranslate() {
{ label: '中译英 (ZH -> EN)', value: 'zh_to_en' } { label: '中译英 (ZH -> EN)', value: 'zh_to_en' }
] ]
const open = () => { const open = () => {
showModal.value = true showModal.value = true
activeTab.value = 'search' activeTab.value = 'search'
...@@ -264,7 +263,6 @@ export function useSearchTranslate() { ...@@ -264,7 +263,6 @@ export function useSearchTranslate() {
} }
} }
const copyText = async (text: string) => { const copyText = async (text: string) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
......
...@@ -136,21 +136,11 @@ ...@@ -136,21 +136,11 @@
<n-form label-placement="left" label-width="80" size="medium"> <n-form label-placement="left" label-width="80" size="medium">
<n-form-item label="英文原文"> <n-form-item label="英文原文">
<n-input <n-input v-model:value="addForm.text" type="textarea" :rows="3" placeholder="请输入需要保存的英文术语或原文段落..." />
v-model:value="addForm.text"
type="textarea"
:rows="3"
placeholder="请输入需要保存的英文术语或原文段落..."
/>
</n-form-item> </n-form-item>
<n-form-item label="中文翻译"> <n-form-item label="中文翻译">
<n-input <n-input v-model:value="addForm.translation" type="textarea" :rows="3" placeholder="请输入对应的中文标准翻译..." />
v-model:value="addForm.translation"
type="textarea"
:rows="3"
placeholder="请输入对应的中文标准翻译..."
/>
</n-form-item> </n-form-item>
<div class="flex justify-end mt-2"> <div class="flex justify-end mt-2">
...@@ -250,7 +240,12 @@ ...@@ -250,7 +240,12 @@
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-xs text-color3">选择时间段</span> <span class="text-xs text-color3">选择时间段</span>
<CommonDatePicker v-model:start="deleteForm.startTime" v-model:end="deleteForm.endTime" type="datetimerange" size="small" /> <CommonDatePicker
v-model:start="deleteForm.startTime"
v-model:end="deleteForm.endTime"
type="datetimerange"
size="small"
/>
</div> </div>
</div> </div>
</div> </div>
...@@ -265,7 +260,9 @@ ...@@ -265,7 +260,9 @@
</div> </div>
<div class="flex justify-end mt-2"> <div class="flex justify-end mt-2">
<CommonButton type="warning" size="medium" :loading="loading" @click="handleDeleteByFilter">执行批量删除</CommonButton> <CommonButton type="warning" size="medium" :loading="loading" @click="handleDeleteByFilter">
执行批量删除
</CommonButton>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -7,7 +7,7 @@ export const TOOLBAR_TITLE = 'XML 编辑工具栏' ...@@ -7,7 +7,7 @@ export const TOOLBAR_TITLE = 'XML 编辑工具栏'
export const GREEN_BUTTONS: any[] = [ export const GREEN_BUTTONS: any[] = [
// { label: '插入图片', tag: 'GRAPHIC', icon: ImageOutline }, // { label: '插入图片', tag: 'GRAPHIC', icon: ImageOutline },
{ label: '插入表格', tag: 'TABLE', icon: GridOutline } { label: '插入表格', tag: 'TABLE', icon: GridOutline },
// { label: '插入模板', tag: 'PRETOPIC', icon: DocumentTextOutline }, // { label: '插入模板', tag: 'PRETOPIC', icon: DocumentTextOutline },
// { label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline } { label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline }
] ]
import { useEditorStore, createTableStructure } from '@/store/editor' import { useEditorStore, createTableStructure } from '@/store/editor'
import { useAppStore } from '@/store/app/index' import { useAppStore } from '@/store/app/index'
import { canAddChild } from '@/utils/dtdManager' import { canAddChild } from '@/utils/dtdManager'
import type { XmlNode } from '@/types/xmlNode'
/** /**
* EditorToolbar 组件级业务逻辑 Hook * EditorToolbar 组件级业务逻辑 Hook
...@@ -13,6 +14,7 @@ export function useEditorToolbar(emit: any) { ...@@ -13,6 +14,7 @@ export function useEditorToolbar(emit: any) {
const fileInputRef = ref<HTMLInputElement | null>(null) const fileInputRef = ref<HTMLInputElement | null>(null)
const isUploading = ref(false) const isUploading = ref(false)
const createTableModalRef = ref<any>(null) const createTableModalRef = ref<any>(null)
const createSignoffModalRef = ref<any>(null)
const canUndo = computed(() => editorStore.undoStack.length > 0) const canUndo = computed(() => editorStore.undoStack.length > 0)
const canRedo = computed(() => editorStore.redoStack.length > 0) const canRedo = computed(() => editorStore.redoStack.length > 0)
...@@ -46,11 +48,28 @@ export function useEditorToolbar(emit: any) { ...@@ -46,11 +48,28 @@ export function useEditorToolbar(emit: any) {
if (tag === 'TABLE') { if (tag === 'TABLE') {
createTableModalRef.value?.open() createTableModalRef.value?.open()
} else if (tag === 'SIGNOFF') {
createSignoffModalRef.value?.open()
} else { } else {
editorStore.insertNode(tag, insertBelow.value) editorStore.insertNode(tag, insertBelow.value)
} }
} }
const handleCreateSignoffConfirm = (ckLevel: string) => {
const signoffNode: XmlNode = {
id: crypto.randomUUID(),
tagName: 'SIGNOFF',
attributes: {
'CK-LEVEL': ckLevel
},
children: [],
textContent: '',
mixedContent: [],
parentId: undefined as any
}
editorStore.insertNode('SIGNOFF', insertBelow.value, signoffNode)
}
const handleCreateTableConfirm = (rows: number, cols: number, cellChildTags: string[]) => { const handleCreateTableConfirm = (rows: number, cols: number, cellChildTags: string[]) => {
const tableNode = createTableStructure(rows, cols, cellChildTags) const tableNode = createTableStructure(rows, cols, cellChildTags)
editorStore.insertNode('TABLE', insertBelow.value, tableNode) editorStore.insertNode('TABLE', insertBelow.value, tableNode)
...@@ -116,6 +135,8 @@ export function useEditorToolbar(emit: any) { ...@@ -116,6 +135,8 @@ export function useEditorToolbar(emit: any) {
triggerUpload, triggerUpload,
createTableModalRef, createTableModalRef,
handleCreateTableConfirm, handleCreateTableConfirm,
createSignoffModalRef,
handleCreateSignoffConfirm,
batchTranslateModalRef, batchTranslateModalRef,
extractTranslateModalRef, extractTranslateModalRef,
searchTranslateModalRef, searchTranslateModalRef,
......
...@@ -156,6 +156,9 @@ ...@@ -156,6 +156,9 @@
<!-- 插入表格弹窗 --> <!-- 插入表格弹窗 -->
<CreateTableModal ref="createTableModalRef" @confirm="handleCreateTableConfirm" /> <CreateTableModal ref="createTableModalRef" @confirm="handleCreateTableConfirm" />
<!-- 插入签字点弹窗 -->
<CreateSignoffModal ref="createSignoffModalRef" @confirm="handleCreateSignoffConfirm" />
<!-- 批量翻译弹窗 --> <!-- 批量翻译弹窗 -->
<BatchTranslateModal ref="batchTranslateModalRef" /> <BatchTranslateModal ref="batchTranslateModalRef" />
...@@ -188,6 +191,7 @@ import { GREEN_BUTTONS } from './constants' ...@@ -188,6 +191,7 @@ import { GREEN_BUTTONS } from './constants'
import SettingsDrawer from '@/layouts/components/SettingsDrawer.vue' import SettingsDrawer from '@/layouts/components/SettingsDrawer.vue'
import InsertFragmentModal from './components/InsertFragmentModal/index.vue' import InsertFragmentModal from './components/InsertFragmentModal/index.vue'
import CreateTableModal from './components/CreateTableModal/index.vue' import CreateTableModal from './components/CreateTableModal/index.vue'
import CreateSignoffModal from './components/CreateSignoffModal/index.vue'
import BatchTranslateModal from './components/BatchTranslateModal/index.vue' import BatchTranslateModal from './components/BatchTranslateModal/index.vue'
import ExtractTranslateModal from './components/ExtractTranslateModal/index.vue' import ExtractTranslateModal from './components/ExtractTranslateModal/index.vue'
import SearchTranslateModal from './components/SearchTranslateModal/index.vue' import SearchTranslateModal from './components/SearchTranslateModal/index.vue'
...@@ -207,6 +211,8 @@ const { ...@@ -207,6 +211,8 @@ const {
triggerUpload, triggerUpload,
createTableModalRef, createTableModalRef,
handleCreateTableConfirm, handleCreateTableConfirm,
createSignoffModalRef,
handleCreateSignoffConfirm,
batchTranslateModalRef, batchTranslateModalRef,
extractTranslateModalRef, extractTranslateModalRef,
searchTranslateModalRef searchTranslateModalRef
......
...@@ -19,11 +19,16 @@ export function useListEditor() { ...@@ -19,11 +19,16 @@ export function useListEditor() {
const getItemTagName = (containerTag: string): string => { const getItemTagName = (containerTag: string): string => {
switch (containerTag) { switch (containerTag) {
case 'LIST1': return 'L1ITEM' case 'LIST1':
case 'LIST2': return 'L2ITEM' return 'L1ITEM'
case 'LIST3': return 'L3ITEM' case 'LIST2':
case 'UNLIST': return 'UNLITEM' return 'L2ITEM'
default: return 'L1ITEM' case 'LIST3':
return 'L3ITEM'
case 'UNLIST':
return 'UNLITEM'
default:
return 'L1ITEM'
} }
} }
...@@ -32,10 +37,10 @@ export function useListEditor() { ...@@ -32,10 +37,10 @@ export function useListEditor() {
const itemTagName = getItemTagName(node.tagName) const itemTagName = getItemTagName(node.tagName)
return node.children return node.children
.filter(c => c.tagName === itemTagName) .filter((c) => c.tagName === itemTagName)
.map(itemNode => { .map((itemNode) => {
let text = itemNode.textContent || '' let text = itemNode.textContent || ''
const firstPara = itemNode.children.find(c => PARA_TAGS.includes(c.tagName)) const firstPara = itemNode.children.find((c) => PARA_TAGS.includes(c.tagName))
if (firstPara) { if (firstPara) {
text = firstPara.textContent || '' text = firstPara.textContent || ''
} }
...@@ -49,10 +54,10 @@ export function useListEditor() { ...@@ -49,10 +54,10 @@ export function useListEditor() {
} }
const updateItemText = (node: XmlNode, itemId: string, text: string): void => { const updateItemText = (node: XmlNode, itemId: string, text: string): void => {
const itemNode = node.children.find(c => c.id === itemId) const itemNode = node.children.find((c) => c.id === itemId)
if (!itemNode) return if (!itemNode) return
const para = itemNode.children.find(c => PARA_TAGS.includes(c.tagName)) const para = itemNode.children.find((c) => PARA_TAGS.includes(c.tagName))
if (para) { if (para) {
para.textContent = text para.textContent = text
if (para.mixedContent.length > 0) { if (para.mixedContent.length > 0) {
...@@ -96,7 +101,7 @@ export function useListEditor() { ...@@ -96,7 +101,7 @@ export function useListEditor() {
} }
const deleteItem = (node: XmlNode, itemId: string): void => { const deleteItem = (node: XmlNode, itemId: string): void => {
const idx = node.children.findIndex(c => c.id === itemId) const idx = node.children.findIndex((c) => c.id === itemId)
if (idx !== -1) { if (idx !== -1) {
node.children.splice(idx, 1) node.children.splice(idx, 1)
store.triggerSync() store.triggerSync()
...@@ -104,7 +109,7 @@ export function useListEditor() { ...@@ -104,7 +109,7 @@ export function useListEditor() {
} }
const moveItem = (node: XmlNode, itemId: string, direction: 'up' | 'down'): void => { const moveItem = (node: XmlNode, itemId: string, direction: 'up' | 'down'): void => {
const idx = node.children.findIndex(c => c.id === itemId) const idx = node.children.findIndex((c) => c.id === itemId)
if (idx === -1) return if (idx === -1) return
const target = direction === 'up' ? idx - 1 : idx + 1 const target = direction === 'up' ? idx - 1 : idx + 1
......
...@@ -4,21 +4,21 @@ ...@@ -4,21 +4,21 @@
<div class="flex items-center justify-between pb-2 border-b border-divider"> <div class="flex items-center justify-between pb-2 border-b border-divider">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<CommonTag type="info" size="small">{{ node.tagName }}</CommonTag> <CommonTag type="info" size="small">{{ node.tagName }}</CommonTag>
<span class="text-xs text-color3"> <span class="text-xs text-color3">{{ isOrdered ? '有序' : '无序' }}列表编辑器 (子项共: {{ listItems.length }} 个)</span>
{{ isOrdered ? '有序' : '无序' }}列表编辑器 (子项共: {{ listItems.length }} 个)
</span>
</div> </div>
<CommonButton type="primary" size="tiny" secondary @click="handleAddItem"> <CommonButton type="primary" size="tiny" secondary @click="handleAddItem">
<template #icon><n-icon><add-outline /></n-icon></template> <template #icon>
<n-icon><add-outline /></n-icon>
</template>
添加列表项 添加列表项
</CommonButton> </CommonButton>
</div> </div>
<!-- 列表项管理列表 --> <!-- 列表项管理列表 -->
<div v-if="listItems.length > 0" class="space-y-3 max-w-4xl"> <div v-if="listItems.length > 0" class="space-y-3 max-w-4xl">
<div <div
v-for="(item, index) in listItems" v-for="(item, index) in listItems"
:key="item.id" :key="item.id"
class="flex items-start space-x-3 p-2 rounded-lg border border-divider bg-fill-2 group hover:shadow-sm transition-all" class="flex items-start space-x-3 p-2 rounded-lg border border-divider bg-fill-2 group hover:shadow-sm transition-all"
> >
...@@ -41,13 +41,19 @@ ...@@ -41,13 +41,19 @@
<!-- 操作按钮组 --> <!-- 操作按钮组 -->
<div class="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div class="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<CommonButton size="tiny" quaternary circle @click="handleMove(item.id, 'up')" :disabled="index === 0"> <CommonButton size="tiny" quaternary circle @click="handleMove(item.id, 'up')" :disabled="index === 0">
<template #icon><n-icon><arrow-up-outline /></n-icon></template> <template #icon>
<n-icon><arrow-up-outline /></n-icon>
</template>
</CommonButton> </CommonButton>
<CommonButton size="tiny" quaternary circle @click="handleMove(item.id, 'down')" :disabled="index === listItems.length - 1"> <CommonButton size="tiny" quaternary circle @click="handleMove(item.id, 'down')" :disabled="index === listItems.length - 1">
<template #icon><n-icon><arrow-down-outline /></n-icon></template> <template #icon>
<n-icon><arrow-down-outline /></n-icon>
</template>
</CommonButton> </CommonButton>
<CommonButton size="tiny" quaternary circle type="error" @click="handleDelete(item.id)"> <CommonButton size="tiny" quaternary circle type="error" @click="handleDelete(item.id)">
<template #icon><n-icon><trash-outline /></n-icon></template> <template #icon>
<n-icon><trash-outline /></n-icon>
</template>
</CommonButton> </CommonButton>
</div> </div>
</div> </div>
...@@ -76,9 +82,13 @@ const isOrdered = computed(() => ORDERED_LIST_TAGS.includes(props.node.tagName)) ...@@ -76,9 +82,13 @@ const isOrdered = computed(() => ORDERED_LIST_TAGS.includes(props.node.tagName))
const listItems = ref(parseListItems(props.node)) const listItems = ref(parseListItems(props.node))
watch(() => props.node, (newVal) => { watch(
listItems.value = parseListItems(newVal) () => props.node,
}, { deep: true, immediate: true }) (newVal) => {
listItems.value = parseListItems(newVal)
},
{ deep: true, immediate: true }
)
function handleTextBlur(itemId: string, e: FocusEvent) { function handleTextBlur(itemId: string, e: FocusEvent) {
const el = e.target as HTMLElement const el = e.target as HTMLElement
...@@ -108,5 +118,4 @@ function handleMove(itemId: string, direction: 'up' | 'down') { ...@@ -108,5 +118,4 @@ function handleMove(itemId: string, direction: 'up' | 'down') {
} }
</script> </script>
<style scoped> <style scoped></style>
</style>
...@@ -28,7 +28,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -28,7 +28,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const showTextContentField = computed(() => { const showTextContentField = computed(() => {
if (!form.tagName) return false if (!form.tagName) return false
if (form.tagName === '#text') return true if (form.tagName === '#text') return true
const tagName = form.tagName const tagName = form.tagName
if (!tagName) return false if (!tagName) return false
...@@ -142,7 +142,17 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -142,7 +142,17 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const defs = getElementAttributes(node.tagName) const defs = getElementAttributes(node.tagName)
const attrs: Record<string, string | null> = {} 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] !== undefined ? node.attributes[name] : null const matchedKey = Object.keys(node.attributes).find((k) => k.toUpperCase() === name.toUpperCase())
let val = matchedKey ? node.attributes[matchedKey] : null
// 如果定义了枚举值,则进行不区分大小写匹配,归一化为 DTD 标准枚举值
if (val && defs[name].enumValues && defs[name].enumValues.length > 0) {
const matchedEnum = defs[name].enumValues.find((ev) => ev.toUpperCase() === val!.toUpperCase())
if (matchedEnum) {
val = matchedEnum
}
}
attrs[name] = val
} }
form.attrs = attrs form.attrs = attrs
} }
...@@ -169,7 +179,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -169,7 +179,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const isVirtualText = addNodeTargetId.value && addNodeTargetId.value.includes('-txt-') const isVirtualText = addNodeTargetId.value && addNodeTargetId.value.includes('-txt-')
let targetNode: XmlNode | null = null let targetNode: XmlNode | null = null
if (isVirtualText) { if (isVirtualText) {
const realParentId = addNodeTargetId.value.split('-txt-')[0] const realParentId = addNodeTargetId.value.split('-txt-')[0]
targetNode = store.nodeMap.get(realParentId)?.node ?? null targetNode = store.nodeMap.get(realParentId)?.node ?? null
...@@ -180,7 +190,15 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -180,7 +190,15 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
store.saveSnapshot() store.saveSnapshot()
const cleanAttrs: Record<string, string> = {} const cleanAttrs: Record<string, string> = { ...targetNode.attributes }
// 清理掉 DTD 声明的属性中本次重新编辑或设置为空的属性(不区分大小写匹配)
for (const name of Object.keys(form.attrs)) {
const matchedKey = Object.keys(cleanAttrs).find((k) => k.toUpperCase() === name.toUpperCase())
if (matchedKey) {
delete cleanAttrs[matchedKey]
}
}
// 写入本次编辑后的有效属性
for (const [k, v] of Object.entries(form.attrs)) { for (const [k, v] of Object.entries(form.attrs)) {
if (v !== null && v !== undefined && v !== '') { if (v !== null && v !== undefined && v !== '') {
cleanAttrs[k] = v cleanAttrs[k] = v
...@@ -194,10 +212,8 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -194,10 +212,8 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
targetNode.mixedContent[textIdx].text = form.textContent targetNode.mixedContent[textIdx].text = form.textContent
} }
// 同步更新父节点的 textContent // 同步更新父节点的 textContent
targetNode.textContent = targetNode.mixedContent targetNode.textContent = targetNode.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
store.rebuildNodeMap() store.rebuildNodeMap()
if (store.selectedNodeId === addNodeTargetId.value) { if (store.selectedNodeId === addNodeTargetId.value) {
store.setSelectedNodeId(addNodeTargetId.value) store.setSelectedNodeId(addNodeTargetId.value)
...@@ -221,9 +237,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -221,9 +237,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
targetNode.mixedContent.unshift({ type: 'text', text: form.textContent }) targetNode.mixedContent.unshift({ type: 'text', text: form.textContent })
} }
// 重新拼合 textContent,确保与 mixedContent 数据一致 // 重新拼合 textContent,确保与 mixedContent 数据一致
targetNode.textContent = targetNode.mixedContent targetNode.textContent = targetNode.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
} else { } else {
targetNode.textContent = form.textContent targetNode.textContent = form.textContent
} }
...@@ -243,9 +257,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -243,9 +257,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
targetNode.mixedContent.push({ type: 'text', text: targetNode.textContent }) targetNode.mixedContent.push({ type: 'text', text: targetNode.textContent })
} }
targetNode.mixedContent.push({ type: 'text', text: form.textContent }) targetNode.mixedContent.push({ type: 'text', text: form.textContent })
targetNode.textContent = targetNode.mixedContent targetNode.textContent = targetNode.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
store.rebuildNodeMap() store.rebuildNodeMap()
const newTextIdx = targetNode.mixedContent.length - 1 const newTextIdx = targetNode.mixedContent.length - 1
store.setSelectedNodeId(`${targetNode.id}-txt-${newTextIdx}`) store.setSelectedNodeId(`${targetNode.id}-txt-${newTextIdx}`)
...@@ -257,7 +269,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -257,7 +269,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
destParent = store.nodeMap.get(addNodeTargetId.value)?.parent ?? null destParent = store.nodeMap.get(addNodeTargetId.value)?.parent ?? null
} }
if (!destParent) return if (!destParent) return
destParent.mixedContent = destParent.mixedContent || [] destParent.mixedContent = destParent.mixedContent || []
if (isVirtualText) { if (isVirtualText) {
const textIdx = parseInt(addNodeTargetId.value.split('-txt-')[1], 10) const textIdx = parseInt(addNodeTargetId.value.split('-txt-')[1], 10)
...@@ -270,11 +282,9 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -270,11 +282,9 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
destParent.mixedContent.splice(insertIdx, 0, { type: 'text', text: form.textContent }) destParent.mixedContent.splice(insertIdx, 0, { type: 'text', text: form.textContent })
} }
} }
destParent.textContent = destParent.mixedContent destParent.textContent = destParent.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
store.rebuildNodeMap() store.rebuildNodeMap()
const newTextIdx = destParent.mixedContent.findIndex((item) => item.type === 'text' && item.text === form.textContent) const newTextIdx = destParent.mixedContent.findIndex((item) => item.type === 'text' && item.text === form.textContent)
if (newTextIdx !== -1) { if (newTextIdx !== -1) {
store.setSelectedNodeId(`${destParent.id}-txt-${newTextIdx}`) store.setSelectedNodeId(`${destParent.id}-txt-${newTextIdx}`)
...@@ -299,7 +309,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -299,7 +309,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
if (addNodeMode.value === 'child') { if (addNodeMode.value === 'child') {
newNode.parentId = targetNode.id newNode.parentId = targetNode.id
targetNode.children.push(newNode) targetNode.children.push(newNode)
// 若父级支持混合内容,需要同步更新 mixedContent // 若父级支持混合内容,需要同步更新 mixedContent
if (isMixedContentElement(targetNode.tagName)) { if (isMixedContentElement(targetNode.tagName)) {
targetNode.mixedContent = targetNode.mixedContent || [] targetNode.mixedContent = targetNode.mixedContent || []
...@@ -308,9 +318,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -308,9 +318,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
} }
targetNode.mixedContent.push({ type: 'element', nodeId: newNode.id }) targetNode.mixedContent.push({ type: 'element', nodeId: newNode.id })
// 重新拼接父节点的 textContent,以防混合渲染时直接被覆盖 // 重新拼接父节点的 textContent,以防混合渲染时直接被覆盖
targetNode.textContent = targetNode.mixedContent targetNode.textContent = targetNode.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
} }
} else { } else {
let destParent: XmlNode | null = null let destParent: XmlNode | null = null
...@@ -333,7 +341,10 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -333,7 +341,10 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
destParent.children.splice(insertIdx, 0, newNode) destParent.children.splice(insertIdx, 0, newNode)
const mixedIdx = destParent.mixedContent.findIndex((item) => item.nodeId === addNodeTargetId.value) const mixedIdx = destParent.mixedContent.findIndex((item) => item.nodeId === addNodeTargetId.value)
if (mixedIdx !== -1) { if (mixedIdx !== -1) {
destParent.mixedContent.splice(addNodeMode.value === 'before' ? mixedIdx : mixedIdx + 1, 0, { type: 'element', nodeId: newNode.id }) destParent.mixedContent.splice(addNodeMode.value === 'before' ? mixedIdx : mixedIdx + 1, 0, {
type: 'element',
nodeId: newNode.id
})
} }
} }
} }
......
...@@ -5,7 +5,7 @@ import { getMinChildCount } from '@/utils/dtdManager' ...@@ -5,7 +5,7 @@ import { getMinChildCount } from '@/utils/dtdManager'
export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) { export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const show = ref(false) const show = ref(false)
const blockedNodes = ref<BlockedNodeDetail[]>([]) const blockedNodes = ref<BlockedNodeDetail[]>([])
const safeNodes = ref<SafeNodeItem[]>([]) const safeNodes = ref<SafeNodeItem[]>([])
...@@ -14,16 +14,16 @@ export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) { ...@@ -14,16 +14,16 @@ export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
const open = (selectedNodeIds: string[]) => { const open = (selectedNodeIds: string[]) => {
const nodeMap = editorStore.nodeMap const nodeMap = editorStore.nodeMap
const { safeIds, blocked } = partitionBatchDelete(selectedNodeIds, nodeMap) const { safeIds, blocked } = partitionBatchDelete(selectedNodeIds, nodeMap)
blockedNodes.value = blocked blockedNodes.value = blocked
safeNodes.value = safeIds.map(id => { safeNodes.value = safeIds.map((id) => {
const item = nodeMap.get(id) const item = nodeMap.get(id)
const node = item?.node const node = item?.node
const tagName = node?.tagName || '未知' const tagName = node?.tagName || '未知'
const displayName = node ? getNodeDisplayName(node) : '未知节点' const displayName = node ? getNodeDisplayName(node) : '未知节点'
const parentPath = item?.parent ? getNodeParentPath(item.parent.id, nodeMap) : '' const parentPath = item?.parent ? getNodeParentPath(item.parent.id, nodeMap) : ''
return { return {
id, id,
tagName, tagName,
...@@ -32,35 +32,35 @@ export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) { ...@@ -32,35 +32,35 @@ export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
checked: true // 默认全部勾选 checked: true // 默认全部勾选
} }
}) })
show.value = true show.value = true
} }
// 计算全选状态 // 计算全选状态
const isAllChecked = computed(() => { const isAllChecked = computed(() => {
if (safeNodes.value.length === 0) return false if (safeNodes.value.length === 0) return false
return safeNodes.value.every(item => item.checked) return safeNodes.value.every((item) => item.checked)
}) })
const isIndeterminate = computed(() => { const isIndeterminate = computed(() => {
if (safeNodes.value.length === 0) return false if (safeNodes.value.length === 0) return false
const checkedCount = safeNodes.value.filter(item => item.checked).length const checkedCount = safeNodes.value.filter((item) => item.checked).length
return checkedCount > 0 && checkedCount < safeNodes.value.length return checkedCount > 0 && checkedCount < safeNodes.value.length
}) })
const checkedCount = computed(() => { const checkedCount = computed(() => {
return safeNodes.value.filter(item => item.checked).length return safeNodes.value.filter((item) => item.checked).length
}) })
const toggleCheckAll = (checked: boolean) => { const toggleCheckAll = (checked: boolean) => {
safeNodes.value.forEach(item => { safeNodes.value.forEach((item) => {
item.checked = checked item.checked = checked
}) })
} }
// 确认删除选中节点 // 确认删除选中节点
const handleConfirm = () => { const handleConfirm = () => {
const idsToDelete = safeNodes.value.filter(item => item.checked).map(item => item.id) const idsToDelete = safeNodes.value.filter((item) => item.checked).map((item) => item.id)
if (idsToDelete.length > 0) { if (idsToDelete.length > 0) {
editorStore.batchDeleteMultipleNodes(idsToDelete) editorStore.batchDeleteMultipleNodes(idsToDelete)
window.$message?.success('批量选择性删除成功') window.$message?.success('批量选择性删除成功')
...@@ -100,10 +100,7 @@ const getNodeDisplayName = (node: XmlNode): string => { ...@@ -100,10 +100,7 @@ const getNodeDisplayName = (node: XmlNode): string => {
return `<${node.tagName}>${suffix}` return `<${node.tagName}>${suffix}`
} }
const getNodeParentPath = ( const getNodeParentPath = (nodeId: string, nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>): string => {
nodeId: string,
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
): string => {
const path: string[] = [] const path: string[] = []
let currentId: string | null = nodeId let currentId: string | null = nodeId
while (currentId) { while (currentId) {
...@@ -118,11 +115,7 @@ const getNodeParentPath = ( ...@@ -118,11 +115,7 @@ const getNodeParentPath = (
return path.join(' > ') return path.join(' > ')
} }
const isAncestorSelected = ( const isAncestorSelected = (nodeId: string, selectedSet: Set<string>, nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>): boolean => {
nodeId: string,
selectedSet: Set<string>,
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
): boolean => {
let currentId: string | null = nodeId let currentId: string | null = nodeId
while (currentId) { while (currentId) {
if (selectedSet.has(currentId)) { if (selectedSet.has(currentId)) {
...@@ -134,10 +127,7 @@ const isAncestorSelected = ( ...@@ -134,10 +127,7 @@ const isAncestorSelected = (
return false return false
} }
const partitionBatchDelete = ( const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>) => {
selectedNodeIds: string[],
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
) => {
const safeIds: string[] = [] const safeIds: string[] = []
const blocked: BlockedNodeDetail[] = [] const blocked: BlockedNodeDetail[] = []
const selectedSet = new Set(selectedNodeIds) const selectedSet = new Set(selectedNodeIds)
......
...@@ -8,9 +8,7 @@ ...@@ -8,9 +8,7 @@
@confirm="handleConfirm" @confirm="handleConfirm"
> >
<div class="flex flex-col gap-4 py-1 text-xs pr-1"> <div class="flex flex-col gap-4 py-1 text-xs pr-1">
<span class="text-color2 text-sm"> <span class="text-color2 text-sm">系统检测到您选中的节点中,部分节点由于 DTD 规则要求无法删除。您可以选择勾选并删除其余合法节点:</span>
系统检测到您选中的节点中,部分节点由于 DTD 规则要求无法删除。您可以选择勾选并删除其余合法节点:
</span>
<!-- 1. 无法删除的节点列表 (Blocked Nodes) --> <!-- 1. 无法删除的节点列表 (Blocked Nodes) -->
<div v-if="blockedNodes.length > 0" class="flex flex-col gap-2"> <div v-if="blockedNodes.length > 0" class="flex flex-col gap-2">
...@@ -33,13 +31,7 @@ ...@@ -33,13 +31,7 @@
<n-icon><checkmark-circle-outline /></n-icon> <n-icon><checkmark-circle-outline /></n-icon>
可安全删除的节点 ({{ safeNodes.length }} 个): 可安全删除的节点 ({{ safeNodes.length }} 个):
</span> </span>
<n-checkbox <n-checkbox :checked="isAllChecked" :indeterminate="isIndeterminate" @update:checked="toggleCheckAll">全选</n-checkbox>
:checked="isAllChecked"
:indeterminate="isIndeterminate"
@update:checked="toggleCheckAll"
>
全选
</n-checkbox>
</div> </div>
<CommonNodeDetailList <CommonNodeDetailList
:items="formattedSafeNodes" :items="formattedSafeNodes"
...@@ -49,15 +41,20 @@ ...@@ -49,15 +41,20 @@
@change="handleSafeNodeChange" @change="handleSafeNodeChange"
/> />
</div> </div>
<!-- 3. 空提示(例如没有可选删除节点) --> <!-- 3. 空提示(例如没有可选删除节点) -->
<div v-if="safeNodes.length === 0" class="p-3 style-error-alert rounded-lg text-danger flex items-center justify-center font-bold text-sm"> <div
v-if="safeNodes.length === 0"
class="p-3 style-error-alert rounded-lg text-danger flex items-center justify-center font-bold text-sm"
>
当前所选节点由于规则约束均不可删除!请取消并重新选择。 当前所选节点由于规则约束均不可删除!请取消并重新选择。
</div> </div>
<!-- 4. 底部小结 --> <!-- 4. 底部小结 -->
<div v-if="safeNodes.length > 0" class="text-color3 text-right mt-1 border-t border-divider pt-2"> <div v-if="safeNodes.length > 0" class="text-color3 text-right mt-1 border-t border-divider pt-2">
已选中 <strong class="text-success text-sm">{{ checkedCount }}</strong> / {{ safeNodes.length }} 个可删除节点进行删除 已选中
<strong class="text-success text-sm">{{ checkedCount }}</strong>
/ {{ safeNodes.length }} 个可删除节点进行删除
</div> </div>
</div> </div>
</CommonModal> </CommonModal>
...@@ -74,17 +71,8 @@ const emit = defineEmits<{ ...@@ -74,17 +71,8 @@ const emit = defineEmits<{
const editorStore = useEditorStore() const editorStore = useEditorStore()
const { const { show, blockedNodes, safeNodes, isAllChecked, isIndeterminate, checkedCount, open, toggleCheckAll, handleConfirm } =
show, useBatchDeleteConfirmModal(emit)
blockedNodes,
safeNodes,
isAllChecked,
isIndeterminate,
checkedCount,
open,
toggleCheckAll,
handleConfirm
} = useBatchDeleteConfirmModal(emit)
const handleNodeClick = (item: any) => { const handleNodeClick = (item: any) => {
editorStore.setSelectedNodeId(item.id) editorStore.setSelectedNodeId(item.id)
......
...@@ -32,4 +32,3 @@ const open = (nodeName: string, rawModel: string, humanReadable: string, parsed: ...@@ -32,4 +32,3 @@ const open = (nodeName: string, rawModel: string, humanReadable: string, parsed:
defineExpose({ open }) defineExpose({ open })
</script> </script>
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<!-- XML 渲染面板,带优雅网格背景与代码字体 --> <!-- XML 渲染面板,带优雅网格背景与代码字体 -->
<div <div
class="rounded-xl overflow-hidden border border-divider shadow-lg relative bg-fill-4" 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;" 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="h-8 bg-fill-3 border-b border-divider flex items-center px-4 space-x-1.5 select-none shrink-0">
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
<div class="w-3 h-3 rounded-full bg-warning/80"></div> <div class="w-3 h-3 rounded-full bg-warning/80"></div>
<div class="w-3 h-3 rounded-full bg-success/80"></div> <div class="w-3 h-3 rounded-full bg-success/80"></div>
</div> </div>
<div class="p-6 font-mono text-sm leading-relaxed overflow-x-auto max-h-[500px] scrollbar-thin select-all"> <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> <pre class="text-color1"><code class="xml-content-pre">{{ viewXmlContent }}</code></pre>
</div> </div>
...@@ -54,7 +54,7 @@ defineExpose({ open }) ...@@ -54,7 +54,7 @@ defineExpose({ open })
<style scoped> <style scoped>
.xml-content-pre { .xml-content-pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
} }
......
...@@ -248,15 +248,17 @@ export function useNodeTree( ...@@ -248,15 +248,17 @@ export function useNodeTree(
} else if (node.tagName === 'CBLST') { } else if (node.tagName === 'CBLST') {
const action = node.attributes.ACTION === 'verif-close' ? '确认关闭' : node.attributes.ACTION === 'open' ? '断开' : '操作' const action = node.attributes.ACTION === 'verif-close' ? '确认关闭' : node.attributes.ACTION === 'open' ? '断开' : '操作'
subtitle = `行动: ${action}` subtitle = `行动: ${action}`
} else if (node.tagName === 'CEP') { } else if (node.tagName === 'CEP' || node.tagName === 'SUBTASK') {
const { CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes || {} const { CHAPPREF, CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes || {}
const parts = [CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean) const parts = [CHAPPREF, CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean)
subtitle = parts.join('-') subtitle = parts.join('-')
} else if (node.tagName === 'UNIT-RECORD') { } else if (node.tagName === 'UNIT-RECORD') {
const text = node.textContent ? node.textContent.trim() : '' const text = node.textContent ? node.textContent.trim() : ''
subtitle = `${text} (单位: ${node.attributes.UNIT || 'mm'})` subtitle = `${text} (单位: ${node.attributes.UNIT || 'mm'})`
} else if (node.tagName === 'SIGNOFF') { } else if (node.tagName === 'SIGNOFF') {
subtitle = `Tag: ${node.attributes.TAG || '签字'}` const tag = node.attributes.TAG || '签字'
const level = node.attributes['CK-LEVEL']
subtitle = level ? `Tag: ${tag} (${level}级)` : `Tag: ${tag}`
} else if (node.attributes.ID) { } else if (node.attributes.ID) {
subtitle = node.attributes.ID subtitle = node.attributes.ID
} else if (node.attributes.EFFRG) { } else if (node.attributes.EFFRG) {
......
...@@ -83,10 +83,7 @@ ...@@ -83,10 +83,7 @@
> >
<!-- 局部翻译加载状态 --> <!-- 局部翻译加载状态 -->
<Transition name="translate-loading"> <Transition name="translate-loading">
<div <div v-if="translatingNodeId === item.id" class="translate-loading-mask">
v-if="translatingNodeId === item.id"
class="translate-loading-mask"
>
<div class="translate-loading-inner"> <div class="translate-loading-inner">
<n-icon size="13" class="translate-loading-icon"> <n-icon size="13" class="translate-loading-icon">
<SyncOutline /> <SyncOutline />
...@@ -94,7 +91,9 @@ ...@@ -94,7 +91,9 @@
<span class="translate-loading-text"> <span class="translate-loading-text">
正在智能翻译 正在智能翻译
<span class="translate-dots"> <span class="translate-dots">
<span>.</span><span>.</span><span>.</span> <span>.</span>
<span>.</span>
<span>.</span>
</span> </span>
</span> </span>
</div> </div>
...@@ -180,13 +179,13 @@ ...@@ -180,13 +179,13 @@
/> />
<!-- 查看规则弹窗 --> <!-- 查看规则弹窗 -->
<CheckRuleModal :ref="(el) => checkRuleModalRef = el" /> <CheckRuleModal :ref="(el) => (checkRuleModalRef = el)" />
<!-- 查看XML片段弹窗 --> <!-- 查看XML片段弹窗 -->
<ViewXmlModal :ref="(el) => viewXmlModalRef = el" /> <ViewXmlModal :ref="(el) => (viewXmlModalRef = el)" />
<!-- 添加/插入节点弹窗 --> <!-- 添加/插入节点弹窗 -->
<AddNodeModal :ref="(el) => addNodeModalRef = el" /> <AddNodeModal :ref="(el) => (addNodeModalRef = el)" />
<!-- 插入 XML 片段弹窗 --> <!-- 插入 XML 片段弹窗 -->
<InsertFragmentModal ref="insertFragmentModalRef" /> <InsertFragmentModal ref="insertFragmentModalRef" />
...@@ -199,7 +198,16 @@ ...@@ -199,7 +198,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { SearchOutline, ListOutline, TrashOutline, CloseOutline, SyncOutline } from '@vicons/ionicons5' import { SearchOutline, ListOutline, TrashOutline, CloseOutline, SyncOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { useNodeTree, checkRuleVisible, viewXmlVisible, addNodeVisible, translatingNodeId, checkRuleModalRef, viewXmlModalRef, addNodeModalRef } from './functionals' import {
useNodeTree,
checkRuleVisible,
viewXmlVisible,
addNodeVisible,
translatingNodeId,
checkRuleModalRef,
viewXmlModalRef,
addNodeModalRef
} from './functionals'
import CheckRuleModal from './components/CheckRuleModal/index.vue' import CheckRuleModal from './components/CheckRuleModal/index.vue'
import AddNodeModal from './components/AddNodeModal/index.vue' import AddNodeModal from './components/AddNodeModal/index.vue'
import ViewXmlModal from './components/ViewXmlModal/index.vue' import ViewXmlModal from './components/ViewXmlModal/index.vue'
...@@ -396,14 +404,22 @@ const batchDeleteConfirmModalRef = ref<any>(null) ...@@ -396,14 +404,22 @@ const batchDeleteConfirmModalRef = ref<any>(null)
animation: translate-bounce 1.2s ease-in-out infinite; animation: translate-bounce 1.2s ease-in-out infinite;
font-weight: 900; font-weight: 900;
} }
.translate-dots span:nth-child(1) { animation-delay: 0s; } .translate-dots span:nth-child(1) {
.translate-dots span:nth-child(2) { animation-delay: 0.2s; } animation-delay: 0s;
.translate-dots span:nth-child(3) { animation-delay: 0.4s; } }
.translate-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.translate-dots span:nth-child(3) {
animation-delay: 0.4s;
}
/* 入场 / 离场过渡 */ /* 入场 / 离场过渡 */
.translate-loading-enter-active, .translate-loading-enter-active,
.translate-loading-leave-active { .translate-loading-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease; transition:
opacity 0.18s ease,
transform 0.18s ease;
} }
.translate-loading-enter-from, .translate-loading-enter-from,
.translate-loading-leave-to { .translate-loading-leave-to {
...@@ -413,12 +429,22 @@ const batchDeleteConfirmModalRef = ref<any>(null) ...@@ -413,12 +429,22 @@ const batchDeleteConfirmModalRef = ref<any>(null)
} }
@keyframes translate-spin { @keyframes translate-spin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
@keyframes translate-bounce { @keyframes translate-bounce {
0%, 80%, 100% { transform: translateY(0); } 0%,
40% { transform: translateY(-3px); } 80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-3px);
}
} }
</style> </style>
export interface SplitterProps { export interface SplitterProps {
width: number width: number
collapsed: boolean collapsed: boolean
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<!-- 折叠状态:展开箭头 --> <!-- 折叠状态:展开箭头 -->
<div v-if="collapsed" class="split-expand-btn"> <div v-if="collapsed" class="split-expand-btn">
<svg class="w-3 h-3" viewBox="0 0 12 12" fill="currentColor"> <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"/> <path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</div> </div>
<!-- 展开状态:可视指示线 + 手柄圆点 --> <!-- 展开状态:可视指示线 + 手柄圆点 -->
...@@ -27,29 +27,27 @@ ...@@ -27,29 +27,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { useSplitter } from './functionals' import { useSplitter } from './functionals'
const props = withDefaults(defineProps<{ const props = withDefaults(
width: number defineProps<{
collapsed: boolean width: number
collapseThreshold?: number collapsed: boolean
defaultWidth?: number collapseThreshold?: number
maxRatio?: number defaultWidth?: number
}>(), { maxRatio?: number
collapseThreshold: 150, }>(),
defaultWidth: 560, {
maxRatio: 0.6 collapseThreshold: 150,
}) defaultWidth: 560,
maxRatio: 0.6
}
)
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:width', val: number): void (e: 'update:width', val: number): void
(e: 'update:collapsed', val: boolean): void (e: 'update:collapsed', val: boolean): void
}>() }>()
const { const { dividerRef, isDragging, startDrag, handleDividerClick } = useSplitter(props, emit)
dividerRef,
isDragging,
startDrag,
handleDividerClick
} = useSplitter(props, emit)
</script> </script>
<style scoped> <style scoped>
...@@ -80,7 +78,9 @@ const { ...@@ -80,7 +78,9 @@ const {
width: 1px; width: 1px;
transform: translateX(-50%); transform: translateX(-50%);
background-color: var(--divider-color, rgba(0, 0, 0, 0.08)); background-color: var(--divider-color, rgba(0, 0, 0, 0.08));
transition: background-color 0.2s, width 0.2s; transition:
background-color 0.2s,
width 0.2s;
} }
.split-divider:hover .split-divider-line, .split-divider:hover .split-divider-line,
...@@ -102,7 +102,10 @@ const { ...@@ -102,7 +102,10 @@ const {
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08)); border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
opacity: 0; opacity: 0;
transform: scaleY(0.8); transform: scaleY(0.8);
transition: opacity 0.2s, transform 0.2s, background 0.2s; transition:
opacity 0.2s,
transform 0.2s,
background 0.2s;
} }
.split-divider:hover .split-divider-handle, .split-divider:hover .split-divider-handle,
......
...@@ -17,11 +17,7 @@ ...@@ -17,11 +17,7 @@
<template v-if="!isDelete"> <template v-if="!isDelete">
<n-form-item label="单元格初始化段落节点" path="cellChildTags"> <n-form-item label="单元格初始化段落节点" path="cellChildTags">
<div class="w-full bg-fill-2 p-3 rounded border border-divider"> <div class="w-full bg-fill-2 p-3 rounded border border-divider">
<CommonCheckbox <CommonCheckbox v-model:value="form.cellChildTags" :options="cellChildOptions" :space-size="24" />
v-model:value="form.cellChildTags"
:options="cellChildOptions"
:space-size="24"
/>
</div> </div>
</n-form-item> </n-form-item>
</template> </template>
......
...@@ -57,12 +57,12 @@ export interface BatchModalRef { ...@@ -57,12 +57,12 @@ export interface BatchModalRef {
/** 上下文菜单所有批量操作的元数据映射 */ /** 上下文菜单所有批量操作的元数据映射 */
export const ACTION_META: Record<string, BatchActionMeta> = { export const ACTION_META: Record<string, BatchActionMeta> = {
'row-above': { title: '在上方插入行', description: '在当前行上方插入多少行?', isDelete: false, count: 1 }, 'row-above': { title: '在上方插入行', description: '在当前行上方插入多少行?', isDelete: false, count: 1 },
'row-below': { title: '在下方插入行', description: '在当前行下方插入多少行?', isDelete: false, count: 1 }, 'row-below': { title: '在下方插入行', description: '在当前行下方插入多少行?', isDelete: false, count: 1 },
'row-append': { title: '在末尾追加行', description: '在表格末尾追加多少行?', isDelete: false, count: 1 }, 'row-append': { title: '在末尾追加行', description: '在表格末尾追加多少行?', isDelete: false, count: 1 },
'col-left': { title: '在左侧插入列', description: '在当前列左侧插入多少列?', isDelete: false, count: 1 }, 'col-left': { title: '在左侧插入列', description: '在当前列左侧插入多少列?', isDelete: false, count: 1 },
'col-right': { title: '在右侧插入列', description: '在当前列右侧插入多少列?', isDelete: false, count: 1 }, 'col-right': { title: '在右侧插入列', description: '在当前列右侧插入多少列?', isDelete: false, count: 1 },
'col-append': { title: '在末尾追加列', description: '在表格末尾追加多少列?', isDelete: false, count: 1 }, 'col-append': { title: '在末尾追加列', description: '在表格末尾追加多少列?', isDelete: false, count: 1 },
'row-delete': { title: '删除行', description: '从当前行开始向下删除多少行?', isDelete: true, count: 1 }, 'row-delete': { title: '删除行', description: '从当前行开始向下删除多少行?', isDelete: true, count: 1 },
'col-delete': { title: '删除列', description: '从当前列开始向右删除多少列?', isDelete: true, count: 1 }, 'col-delete': { title: '删除列', description: '从当前列开始向右删除多少列?', isDelete: true, count: 1 }
} }
import { ref, computed } from 'vue'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app' import { useAppStore } from '@/store/app'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
...@@ -356,7 +357,13 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -356,7 +357,13 @@ export function useTableEditor(props: { node: XmlNode }) {
store.rebuildNodeMap() store.rebuildNodeMap()
} }
const addRow = (node: XmlNode, section: 'THEAD' | 'TBODY' = 'TBODY', activeRowId?: string, insertBelow = true, cellChildTags?: string[] | null): void => { const addRow = (
node: XmlNode,
section: 'THEAD' | 'TBODY' = 'TBODY',
activeRowId?: string,
insertBelow = true,
cellChildTags?: string[] | null
): void => {
const tgroup = findTgroup(node) const tgroup = findTgroup(node)
if (!tgroup) return if (!tgroup) return
...@@ -397,7 +404,7 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -397,7 +404,7 @@ export function useTableEditor(props: { node: XmlNode }) {
if (idx !== -1) { if (idx !== -1) {
const insertIdx = insertBelow ? idx + 1 : idx const insertIdx = insertBelow ? idx + 1 : idx
sectionNode.children.splice(insertIdx, 0, newRow) sectionNode.children.splice(insertIdx, 0, newRow)
insertedRowIds.value.add(rowId) insertedRowIds.value.add(rowId)
setTimeout(() => { setTimeout(() => {
insertedRowIds.value.delete(rowId) insertedRowIds.value.delete(rowId)
...@@ -505,7 +512,7 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -505,7 +512,7 @@ export function useTableEditor(props: { node: XmlNode }) {
} }
} }
} }
// 将上方那个真正持有该物理节点的大单元格的 MOREROWS 扣减 1 // 将上方那个真正持有该物理节点的大单元格的 MOREROWS 扣减 1
if (foundSourceCellXml) { if (foundSourceCellXml) {
const currentMorerows = parseInt(foundSourceCellXml.attributes.MOREROWS || '0', 10) const currentMorerows = parseInt(foundSourceCellXml.attributes.MOREROWS || '0', 10)
...@@ -527,7 +534,6 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -527,7 +534,6 @@ export function useTableEditor(props: { node: XmlNode }) {
store.rebuildNodeMap() store.rebuildNodeMap()
} }
const addColumn = (node: XmlNode, activeColIdx?: number, insertRight = true, cellChildTags?: string[] | null): void => { const addColumn = (node: XmlNode, activeColIdx?: number, insertRight = true, cellChildTags?: string[] | null): void => {
const tgroup = findTgroup(node) const tgroup = findTgroup(node)
if (!tgroup) return if (!tgroup) return
...@@ -785,13 +791,15 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -785,13 +791,15 @@ export function useTableEditor(props: { node: XmlNode }) {
const deleteCellFromSection = (sec?: XmlNode) => { const deleteCellFromSection = (sec?: XmlNode) => {
if (!sec) return if (!sec) return
sec.children.filter((c) => c.tagName === 'ROW').forEach((row) => { sec.children
const entries = row.children.filter((c) => c.tagName === 'ENTRY') .filter((c) => c.tagName === 'ROW')
if (entries[colIdx]) { .forEach((row) => {
const entryIdx = row.children.findIndex((c) => c.id === entries[colIdx].id) const entries = row.children.filter((c) => c.tagName === 'ENTRY')
if (entryIdx !== -1) row.children.splice(entryIdx, 1) if (entries[colIdx]) {
} const entryIdx = row.children.findIndex((c) => c.id === entries[colIdx].id)
}) if (entryIdx !== -1) row.children.splice(entryIdx, 1)
}
})
} }
deleteCellFromSection(thead) deleteCellFromSection(thead)
...@@ -1177,7 +1185,6 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1177,7 +1185,6 @@ export function useTableEditor(props: { node: XmlNode }) {
mergeMultipleCells(props.node, allSelectedIds, minCol, maxCol, minRow, maxRow, isThead) mergeMultipleCells(props.node, allSelectedIds, minCol, maxCol, minRow, maxRow, isThead)
} }
const handleSplitSelected = () => { const handleSplitSelected = () => {
if (selectedCellIds.value.length !== 1) return if (selectedCellIds.value.length !== 1) return
const cellId = selectedCellIds.value[0] const cellId = selectedCellIds.value[0]
...@@ -1540,6 +1547,158 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1540,6 +1547,158 @@ export function useTableEditor(props: { node: XmlNode }) {
contextMenu.value.show = false contextMenu.value.show = false
} }
// === CALS 表格样式控制逻辑 ===
/** 计算各列的宽度样式 */
const colWidthStyles = computed(() => {
const specs = structure.value.colSpecs || []
const colsCount = structure.value.cols
const parsedSpecs = Array.from({ length: colsCount }, (_, idx) => {
const spec = specs[idx]
const colwidth = spec?.attributes?.COLWIDTH || spec?.attributes?.colwidth || ''
if (!colwidth) {
return { type: 'auto', value: 1 }
}
const cleanWidth = colwidth.trim()
if (cleanWidth.includes('*')) {
const starVal = cleanWidth.replace('*', '').trim()
const val = starVal ? parseFloat(starVal) : 1
return { type: 'prop', value: isNaN(val) ? 1 : val }
}
const numVal = parseFloat(cleanWidth)
if (!isNaN(numVal)) {
const unit = cleanWidth.replace(/[0-9.]/g, '').trim() || 'px'
return { type: 'abs', value: numVal, unit }
}
return { type: 'auto', value: 1 }
})
let propSum = 0
parsedSpecs.forEach((spec) => {
if (spec.type === 'prop' || spec.type === 'auto') {
propSum += spec.value
}
})
if (propSum <= 0) propSum = 1
const hasAbsolute = parsedSpecs.some((s) => s.type === 'abs')
if (!hasAbsolute) {
return parsedSpecs.map((spec) => {
const pct = (spec.value / propSum) * 100
return `${pct.toFixed(2)}%`
})
} else {
const absSpecs = parsedSpecs.filter((s) => s.type === 'abs')
const absSumStr = absSpecs.map((s) => `${s.value}${s.unit}`).join(' + ')
return parsedSpecs.map((spec) => {
if (spec.type === 'abs') {
return `${spec.value}${spec.unit}`
}
const ratio = spec.value / propSum
if (absSumStr) {
return `calc((100% - (${absSumStr})) * ${ratio.toFixed(4)})`
} else {
return `${(ratio * 100).toFixed(2)}%`
}
})
}
})
/** 计算表格的外边框 Frame 样式类 */
const tableFrameClass = computed(() => {
const frame = (props.node.attributes?.FRAME || props.node.attributes?.frame || 'ALL').toUpperCase()
return `frame-${frame.toLowerCase()}`
})
/** 解析并计算单元格的对齐(水平 ALIGN、垂直 VALIGN)样式 */
const getCellStyle = (cell: any) => {
const tgroup = props.node.children?.find((c) => c.tagName === 'TGROUP')
// 1. 解析 ALIGN 水平对齐方式
let align = cell.attributes?.ALIGN || cell.attributes?.align
if (!align) {
const spanname = cell.attributes?.SPANNAME || cell.attributes?.spanname
if (spanname && tgroup) {
const spanspec = tgroup.children?.find(
(c) =>
c.tagName === 'SPANSPEC' && (c.attributes?.SPANNAME || c.attributes?.spanname || '').toLowerCase() === spanname.toLowerCase()
)
if (spanspec) {
align = spanspec.attributes?.ALIGN || spanspec.attributes?.align
}
}
}
if (!align) {
const colname = cell.attributes?.COLNAME || cell.attributes?.colname
let colSpec = null
if (colname && tgroup) {
colSpec = tgroup.children?.find(
(c) => c.tagName === 'COLSPEC' && (c.attributes?.COLNAME || c.attributes?.colname || '').toLowerCase() === colname.toLowerCase()
)
} else if (cell.colIdx !== undefined && structure.value.colSpecs) {
colSpec = structure.value.colSpecs[cell.colIdx]
}
if (colSpec) {
align = colSpec.attributes?.ALIGN || colSpec.attributes?.align
}
}
if (!align && tgroup) {
align = tgroup.attributes?.ALIGN || tgroup.attributes?.align
}
// 2. 解析 VALIGN 垂直对齐方式
let valign = cell.attributes?.VALIGN || cell.attributes?.valign
if (!valign) {
const entryNodeId = cell.rawNode?.id || cell.id
const rowEntry = store.nodeMap.get(entryNodeId)
const rowNode = rowEntry?.parent
if (rowNode) {
valign = rowNode.attributes?.VALIGN || rowNode.attributes?.valign
if (!valign) {
const sectionEntry = store.nodeMap.get(rowNode.id)
const sectionNode = sectionEntry?.parent
if (sectionNode) {
valign = sectionNode.attributes?.VALIGN || sectionNode.attributes?.valign
}
}
}
}
const styles: Record<string, string> = {}
if (align) {
const a = align.toLowerCase()
if (a === 'left' || a === 'right' || a === 'center' || a === 'justify') {
styles['text-align'] = a
styles['--cell-align'] = a === 'center' ? 'center' : a === 'right' ? 'flex-end' : 'flex-start'
} else if (a === 'char') {
styles['text-align'] = 'left'
styles['--cell-align'] = 'flex-start'
}
}
if (valign) {
const v = valign.toLowerCase()
if (v === 'top' || v === 'middle' || v === 'bottom') {
styles['vertical-align'] = v
}
}
return styles
}
return { return {
structure, structure,
selectedCellIds, selectedCellIds,
...@@ -1579,7 +1738,11 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1579,7 +1738,11 @@ export function useTableEditor(props: { node: XmlNode }) {
mergeMultipleCells, mergeMultipleCells,
splitCell, splitCell,
insertedRowIds, insertedRowIds,
insertedCellIds insertedCellIds,
// 样式计算
colWidthStyles,
tableFrameClass,
getCellStyle
} }
} }
......
...@@ -12,11 +12,11 @@ ...@@ -12,11 +12,11 @@
<table <table
class="w-full border-collapse text-sm table-fixed min-w-[600px] transition-all" class="w-full border-collapse text-sm table-fixed min-w-[600px] transition-all"
:data-node-id="structure.tgroupId" :data-node-id="structure.tgroupId"
:class="[isNodeSelected(structure.tgroupId) ? 'ring-2 ring-primary ring-offset-2 rounded' : '']" :class="[isNodeSelected(structure.tgroupId) ? 'ring-2 ring-primary ring-offset-2 rounded' : '', tableFrameClass]"
> >
<colgroup> <colgroup>
<col class="w-12" /> <col class="w-12" />
<col v-for="(_, idx) in structure.cols" :key="idx" /> <col v-for="(_, idx) in structure.cols" :key="idx" :style="{ width: colWidthStyles[idx] }" />
<col class="w-12" /> <col class="w-12" />
</colgroup> </colgroup>
...@@ -60,6 +60,7 @@ ...@@ -60,6 +60,7 @@
isNodeSelected(structure.theadId) ? 'bg-primary/10 border-primary/40' : '', isNodeSelected(structure.theadId) ? 'bg-primary/10 border-primary/40' : '',
insertedCellIds.has(cell.id) ? 'inserted-cell-highlight' : '' insertedCellIds.has(cell.id) ? 'inserted-cell-highlight' : ''
]" ]"
:style="getCellStyle(cell)"
@click.stop="handleCellClick(cell, $event)" @click.stop="handleCellClick(cell, $event)"
@contextmenu.prevent="handleCellContextMenu(cell, row.id, $event)" @contextmenu.prevent="handleCellContextMenu(cell, row.id, $event)"
> >
...@@ -130,6 +131,7 @@ ...@@ -130,6 +131,7 @@
isNodeSelected(structure.tbodyId) ? 'bg-primary/5 border-primary/30' : '', isNodeSelected(structure.tbodyId) ? 'bg-primary/5 border-primary/30' : '',
insertedCellIds.has(cell.id) ? 'inserted-cell-highlight' : '' insertedCellIds.has(cell.id) ? 'inserted-cell-highlight' : ''
]" ]"
:style="getCellStyle(cell)"
@click.stop="handleCellClick(cell, $event)" @click.stop="handleCellClick(cell, $event)"
@contextmenu.prevent="handleCellContextMenu(cell, row.id, $event)" @contextmenu.prevent="handleCellContextMenu(cell, row.id, $event)"
> >
...@@ -237,7 +239,10 @@ const { ...@@ -237,7 +239,10 @@ const {
batchDeleteRows, batchDeleteRows,
batchDeleteColumns, batchDeleteColumns,
insertedRowIds, insertedRowIds,
insertedCellIds insertedCellIds,
colWidthStyles,
tableFrameClass,
getCellStyle
} = useTableEditor(props) } = useTableEditor(props)
/** TableBatchModal 组件实例引用 */ /** TableBatchModal 组件实例引用 */
...@@ -282,4 +287,39 @@ const { onContextMenuSelect, executeBatchAction } = useTableBatchActions({ ...@@ -282,4 +287,39 @@ const { onContextMenuSelect, executeBatchAction } = useTableBatchActions({
outline: 1px solid transparent; outline: 1px solid transparent;
} }
} }
/* === CALS 表格边框 Frame 样式控制 === */
table.frame-none {
border: none !important;
}
table.frame-none tr > *:first-child,
table.frame-top tr > *:first-child,
table.frame-bottom tr > *:first-child,
table.frame-topbot tr > *:first-child {
border-left: none !important;
}
table.frame-none tr > *:last-child,
table.frame-top tr > *:last-child,
table.frame-bottom tr > *:last-child,
table.frame-topbot tr > *:last-child {
border-right: none !important;
}
/* 当存在 thead 时,去除 thead 首行的上边框 */
table.frame-none thead tr:first-child > *,
table.frame-bottom thead tr:first-child > *,
table.frame-sides thead tr:first-child > * {
border-top: none !important;
}
/* 当不存在 thead 时,去除 tbody 首行的上边框 */
table.frame-none colgroup + tbody tr:first-child > *,
table.frame-bottom colgroup + tbody tr:first-child > *,
table.frame-sides colgroup + tbody tr:first-child > * {
border-top: none !important;
}
/* 去除 tbody 最后一行数据行的下边框(倒数第二行,因为倒数第一行是列删除按钮行) */
table.frame-none tbody tr:nth-last-child(2) > *,
table.frame-top tbody tr:nth-last-child(2) > *,
table.frame-sides tbody tr:nth-last-child(2) > * {
border-bottom: none !important;
}
</style> </style>
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { import {
isMixedContentElement, isMixedContentElement,
getElementAttributes, getElementAttributes,
createDefaultAttributes, createDefaultAttributes,
getElementRule, getElementRule,
...@@ -69,7 +69,7 @@ export function useTextBlockEditor(getNode: () => XmlNode) { ...@@ -69,7 +69,7 @@ export function useTextBlockEditor(getNode: () => XmlNode) {
attributes: {} attributes: {}
}) })
} else if (item.type === 'element' && item.nodeId) { } else if (item.type === 'element' && item.nodeId) {
const child = nodeVal.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',
......
...@@ -83,13 +83,7 @@ ...@@ -83,13 +83,7 @@
class="flex-1" class="flex-1"
@update:value="syncMixedContent" @update:value="syncMixedContent"
/> />
<n-input <n-input v-else v-model:value="item.attributes[attrName]" size="tiny" class="flex-1" @input="syncMixedContent" />
v-else
v-model:value="item.attributes[attrName]"
size="tiny"
class="flex-1"
@input="syncMixedContent"
/>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -25,8 +25,7 @@ interface XmlNode { ...@@ -25,8 +25,7 @@ interface XmlNode {
// ── 工具函数 ────────────────────────────────────────────────────────────────── // ── 工具函数 ──────────────────────────────────────────────────────────────────
function generateId(): string { function generateId(): string {
return (self as any).crypto?.randomUUID?.() return (self as any).crypto?.randomUUID?.() ?? `node_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
?? `node_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
} }
// ── DOM → XmlNode 递归转换 ──────────────────────────────────────────────────── // ── DOM → XmlNode 递归转换 ────────────────────────────────────────────────────
...@@ -44,8 +43,8 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode ...@@ -44,8 +43,8 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
let textContent = '' let textContent = ''
const childNodes = Array.from(element.childNodes) const childNodes = Array.from(element.childNodes)
const hasElementChildren = childNodes.some(n => n.nodeType === 1 /* ELEMENT_NODE */) const hasElementChildren = childNodes.some((n) => n.nodeType === 1 /* ELEMENT_NODE */)
const hasTextChildren = childNodes.some(n => n.nodeType === 3 /* TEXT_NODE */ && n.textContent?.trim()) const hasTextChildren = childNodes.some((n) => n.nodeType === 3 /* TEXT_NODE */ && n.textContent?.trim())
if (hasElementChildren && hasTextChildren) { if (hasElementChildren && hasTextChildren) {
for (const child of childNodes) { for (const child of childNodes) {
......
{
"elements": {
"SIGNOFF": {
"attributes": {
"CK-LEVEL": {
"typeDefinition": "(A | B | C)",
"enumValues": ["A", "B", "C"]
}
}
}
}
}
<template>
<div class="my-3 flex overflow-x-auto select-none" :class="containerClass">
<table class="border-collapse border border-black text-xs bg-white text-black">
<tbody>
<!-- 第一行:表头与签字人 -->
<tr class="h-10">
<!-- 工作者 Per.By -->
<td class="px-2 border border-black font-bold text-center w-24">
工作者
<br />
Per.By
</td>
<td class="px-2 border border-black text-center w-32 font-mono">
<span v-if="node.attributes.ACTION === 'NA'" style="color: red" class="font-bold">N/A</span>
<span v-else>{{ node.attributes.mech ? `${node.attributes.mech} ${node.attributes.mechName || ''}` : '' }}</span>
</td>
<!-- 检查者 Insp.By (CK-LEVEL B, 且支持 D/E 兼容) -->
<template v-if="['B', 'D', 'E'].includes(node.attributes['CK-LEVEL'])">
<td class="px-2 border border-black font-bold text-center w-24">
检查者
<br />
Insp.By
</td>
<td class="px-2 border border-black text-center w-32 font-mono">
<span v-if="node.attributes.ACTION === 'NA'" style="color: red" class="font-bold">N/A</span>
<span v-else>{{ node.attributes.insp ? `${node.attributes.insp} ${node.attributes.inspName || ''}` : '' }}</span>
</td>
</template>
<!-- 必检 RII.By (CK-LEVEL C) -->
<template v-else-if="node.attributes['CK-LEVEL'] === 'C'">
<td class="px-2 border border-black font-bold text-center w-24">
必检
<br />
RII.By
</td>
<td class="px-2 border border-black text-center w-32 font-mono">
<span v-if="node.attributes.ACTION === 'NA'" style="color: red" class="font-bold">N/A</span>
<span v-else>
{{
node.attributes.verf
? `${node.attributes.verf} ${node.attributes.verfName || ''}`
: node.attributes.insp
? `${node.attributes.insp} ${node.attributes.inspName || ''}`
: ''
}}
</span>
</td>
</template>
</tr>
<!-- 第二行:签字时间 (如果 disStime 有值时渲染) -->
<tr v-if="node.attributes.disStime" class="h-5 text-[10px] text-center font-mono">
<!-- A 级签字时间 -->
<td v-if="!['B', 'D', 'E', 'C'].includes(node.attributes['CK-LEVEL'])" colspan="2" class="border border-black px-1">
{{ node.attributes.disStime }}
</td>
<!-- B/C 级签字时间 -->
<template v-else>
<td colspan="2" class="border border-black px-1">
{{ node.attributes.disStime }}
</td>
<td colspan="2" class="border border-black px-1">
{{ node.attributes.SUP_DIS_STIME || node.attributes.disStime }}
</td>
</template>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import type { XmlNode } from '@/types/xmlNode'
const props = defineProps<{
node: XmlNode
}>()
// 签字表靠左或靠右显示样式,由 LOCATION 属性控制;
// 在 .env.xm 环境中,默认是要靠右 (RIGHT) 显示
const containerClass = computed(() => {
const location = props.node.attributes.LOCATION || 'RIGHT'
return location === 'LEFT' ? 'justify-start' : 'justify-end'
})
</script>
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