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 @@
---
## 1. 思考与语言规范
- AI 助手在分析问题、编写规划、输出日志及用户沟通的整个生命周期中,**必须**统一使用**中文**
- 自动生成的 git Commit Message 或文件注释也必须使用中文。
---
## 2. 目录规范与模块解耦 (三层标准结构)
为了确保项目结构的绝对统一,所有业务页面 (Page) 及其下属子组件 (Component) 必须采用以下目录树结构,严格执行代码逻辑、类型与常量的拆分:
### 页面级模块目录结构
```text
[ParentDir]/ # 父级目录 (若有多个模块并列)
├── [module_name]/ # 该模块的逻辑隔离子目录 (强制)
......@@ -41,7 +44,9 @@
---
## 3. Vue 单文件结构规范
所有 `.vue` 文件的内部结构必须严格遵循以下顺序:
1. **`<template>`**:视图模板放在最上方。
2. **`<script>`**:逻辑脚本紧随其后。
3. **`<style>`**:CSS 样式(如果有)放在最下方。
......@@ -49,18 +54,20 @@
---
## 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. 异步编程规范
- 全项目所有异步操作(如 API 请求、弹窗确认、文件上传等)**必须**统一使用 `async/await` 语法。
- **严禁**使用 `.then()``.catch()``.finally()` 链式调用,以确保业务代码逻辑呈线性流。
---
## 6. Tailwind CSS 优先与颜色值使用限制
- **基础样式**:间距、对齐、扁平布局等应优先使用 Tailwind CSS 类名。
- **严禁硬编码颜色值**:严禁硬编码任何 Hex、RGB 或原生 CSS 颜色(如 `#a8a297``red`)。
- **必须使用系统主题色**:可通过 `useThemeVars()` 获取并绑定(如 `themeVars.textColorDisabled``themeVars.primaryColor` 等),或利用 CSS 变量传递给 scoped style。
......@@ -72,10 +79,11 @@
- **特定技术手册/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) |
|---|---|---|
| ---------------- | ------------------------ | --------------------------- |
| **primaryColor** | `#165DFF` | `#3c7eff` |
| **successColor** | `#00B42A` | `#3c7eff` |
| **warningColor** | `#FF7D00` | `#f2c97d` |
......@@ -89,6 +97,7 @@
---
## 7. Dialog 与 UI 反馈规范
- **消息提示规范 (强制)**:全项目**禁止**在任何业务组件、Hook 或 Store 中从 `naive-ui` 导入并使用 `useMessage()`,也**不要**使用 `@/hooks/useMessage.ts`**强制要求统一使用全局挂载的 `window.$message`**(如 `window.$message.success``window.$message.error``window.$message.warning` 等),以避免多余的 Provider 上下文依赖。
- **Promise 风格确认弹窗 (强制)**:删除、禁用、重置等敏感确认操作**必须**使用 `await window.$dialog.warning({ ... })` 进行阻塞调用。
- **禁止 Callback 模式**:严禁使用 `onPositiveClick``onNegativeClick` 等回调函数形式。
......@@ -109,6 +118,7 @@
---
## 8. 按钮组件与检索对齐规范
- **禁止使用 `n-button`**:在所有业务页面中,禁止使用 Naive UI 原生的 `n-button`**强制要求**统一使用项目封装的 `CommonButton`
- **搜索区域对齐与查询重置 (强制)**
- 检索区域内,操作按钮组**必须**直接使用 `<CommonQueryButtons />``<CommonSearchForm />` 组件,**禁止**在业务页面手动包裹 `n-form-item``div` 来排版查询和重置按钮。
......@@ -120,6 +130,7 @@
---
## 9. 表格与操作栏规范
- **操作栏按钮间距**:全局样式中已定义 `.n-button:not(:last-child) { margin-right: 10px; }`,自动生效。
- **禁止包装操作列**:在 `CommonTable``columns` 定义中,操作列的 `render` 函数**禁止**使用 `NSpace` 包装按钮,直接返回按钮数组:`[ h(CommonButton, ...), h(CommonButton, ...) ]`
- **禁用居中对齐**:表格列定义中**禁止**使用 `align: 'center'`(包括操作列,全项目表格统一不再居中对齐),禁止手动添加额外的居中样式。
......@@ -128,6 +139,7 @@
---
## 10. 日期组件与格式化规范
- **禁用 `n-date-picker`**:强制统一使用封装好的 `CommonDatePicker` 组件。
- **强制使用全局 `formatDate`**:在日期回显、表格列 `render`、或表单详情初始化中,将日期格式化或截取为 `yyyy-MM-dd` 时,**禁止**使用原生 `.split(' ')[0]``.substring(0, 10)` 等截取写法,**强制要求统一使用全局工具方法 `formatDate(value)`**
- **合理使用日期格式**:必须根据业务场景选用 `formatDate``formatDateTime`。若该字段需要包含时分秒(如创建时间、修改时间),应直接回显或使用 `formatDateTime`,禁止盲目使用 `formatDate` 导致数据丢失。
......@@ -135,11 +147,13 @@
---
## 11. Naive UI 全局组件属性
- **Clearable 默认开启**:已全局配置 `clearable``true`,在 `.vue` 模板中编写 `n-input``n-select` 等组件时,**禁止**手动添加 `clearable` 属性(特殊要求关闭的除外)。
---
## 12. 全局 Modal 封装与交互规范
- **统一组件**:所有业务弹窗必须使用封装的 `CommonModal` 组件。
- **宽度配置**:弹窗宽度定义**必须**使用 `:width="数字"` 形式(如 `:width="600"`),禁止使用非绑定的 `width="600"` 或手写样式。
- **双向绑定**:在业务逻辑中,**禁止**使用 `v-model:show="xxx"` 语法,必须统一简写为 **`v-model="xxx"`**
......@@ -161,6 +175,7 @@
---
## 13. API 请求与异常处理
- **非弹窗场景加载蒙层**:在列表、详情页面等非 Modal 环境中,点击操作直接发起网络请求,**必须**在请求配置中开启 `showLoading: true` 或指定具体加载文案。
- **禁止冗余报错**:拦截器已实现错误探测与底层自动弹窗,业务层**禁止**手动调用 `window.$message.error(res.msg)`
- **判定成功**:全项目统一规定,**仅且只有** `res.code === 200` 判定为请求成功,禁止包含 `res.code === 0` 等其他冗余判断。
......@@ -168,6 +183,7 @@
---
## 14. Pinia 仓库目录规范
- **禁止单文件声明 Store**:所有的 Pinia 仓库(Store)必须拆分为独立文件夹,严禁使用单文件声明(例如 `store/editorStore.ts`)。
- **目录结构约束**:每个 Pinia 仓库必须遵循以下两文件结构:
```text
......@@ -180,6 +196,7 @@
---
## 15. XML 编辑器树与编辑区联动定位规范
- **最近块定位 (最近渲染块容器)**
- 定位编辑区时,虚拟滚动的块位置定位**严禁**使用顶层大块(如 `TOPIC` 等远祖先容器)进行直接命中,否则会导致视图跳到整个章节开头。
- **必须**使用自底向上的祖先链遍历方法(如 `findNearestBlockIdx`),查找最近(即最深层)的已注册块容器(如 `SUBTASK`, `PRETOPIC` 等),以确保精确定位到目标节点所在的子块。
......@@ -190,6 +207,7 @@
---
## 16. 全局常用表单、选择与状态组件规范 (强制)
- **统一使用全局组件**:在所有业务界面中,对于选择框、多选框、数字输入框与状态标签,**禁止**直接使用 Naive UI 原生的组件,**强制要求**统一使用项目封装好的全局组件进行替代:
- **下拉选择框**:使用 **`CommonSelect`** 替代 `n-select`。对 `CommonSelect` 监听值变更回调时,**必须**绑定组件导出的 **`@change`** 事件,**禁止**使用 `@update:value`
- **数字输入框**:使用 **`CommonInputNumber`** 替代 `n-input-number`。若绑定业务数据需要强制保留 number 类型,应显式配置属性 **`:strict="true"`**
......@@ -202,6 +220,7 @@
---
## 17. XML 节点与标签使用规范 (一元化管理)
- **禁止直接在业务代码或常量中硬编码 XML 节点标签数组或映射**(如 `['PARA', 'PARAC', 'TITLE']` 等元素集合)。
- **强制要求统一收拢管理**:所有关于 DTD 定义的各类 XML 标签、节点分类(如文档型标签、列表条目标签、复杂容器标签、头部元数据标签等)必须统一在 [xmlTags.ts](file:///e:/refactor-Editor/Ifar-Xml-Editor/src/configs/xmlTags.ts) 文件中进行定义和导出。
- **使用规范**:在任何业务组件、逻辑 Hook 或常量模块中需要判断或使用节点标签集合时,必须先在 `xmlTags.ts` 中维护,随后在对应的代码文件中通过 `import` 引用,严禁私自在局部硬编码私有数组,以确保 DTD 分类规则的一致性与后期维护的便捷度。
......@@ -209,8 +228,16 @@
---
## 18. XML 编辑器节点操作与偏好设置联动规范
- **偏好设置与行为控制**:节点操作(如新增节点、粘贴 XML 片段等)和表格操作(如新增行、新增列等)应当尊重用户的全局偏好设置。
- **插入节点自动展开 (autoExpandOnInsert) 规范**
- 如果开启了“插入节点自动展开”偏好设置,在树上新增/粘贴节点或在表格新增行列时,应当自动展开新增节点自身(以及包含的子节点结构),并展开对应的父节点。
- 如果关闭了该设置,在新增/粘贴节点或新增表格行列时,**必须跳过**对新增节点、其子节点以及其所有父/祖先节点的展开操作,保持原有的折叠状态。
- **实现要求**:为防止选中节点更新(`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
. "$(dirname -- "$0")/_/husky.sh"
npm run format
echo '***************************************************'
echo '********************注意提交格式*******************'
echo '***************************************************'
......
......@@ -9,13 +9,16 @@
为了保证编辑器的高内聚、低耦合与易维护性,所有开发人员(及 AI 助手)在新增、修改 XML 节点渲染时,**必须严格遵守以下规则**
### 1. 配置集中化原则
* **禁止硬编码**:所有 XML 标签集合、分类常量、特殊标签匹配,必须定义在 `src/configs/xmlTags.ts` 文件中。
* **禁止局部私有定义**:所有业务组件需要进行标签判定时,必须从该配置文件中导入对应的常量,严禁在业务代码中出现局部私有的硬编码判断。
- **禁止硬编码**:所有 XML 标签集合、分类常量、特殊标签匹配,必须定义在 `src/configs/xmlTags.ts` 文件中。
- **禁止局部私有定义**:所有业务组件需要进行标签判定时,必须从该配置文件中导入对应的常量,严禁在业务代码中出现局部私有的硬编码判断。
### 2. 复合结构特殊容器规则 (`COMPOSITE_CONTAINER_MAP`)
当某些节点不是普通的块级节点,而是需要自定义布局、拼装表格或组合行内方式渲染其子节点(但其子节点又必须支持独立被选中、就地编辑和精确定位)时,它们被称为**复合结构特殊容器**
这些映射关系定义在 `src/configs/xmlTags.ts``COMPOSITE_CONTAINER_MAP` 对象中:
```typescript
export const COMPOSITE_CONTAINER_MAP: Record<string, string[]> = {
CBDATA: ['PAN', 'CBNAME', 'CB', 'CBLOC'], // 电路断路器行与四列子元素
......@@ -27,9 +30,10 @@ export const COMPOSITE_CONTAINER_MAP: Record<string, string[]> = {
```
#### 📌 渲染与解析规范:
1. **容器排除**:在判断一个节点是否为普通列表容器(`isContainer`)时,必须通过 `!COMPOSITE_CONTAINER_MAP[tagName]` 将这些复合容器排除,防止它们被错误渲染为普通块列表。
2. **动态渲染(禁止写死)**
在渲染这些容器下的子节点时,**必须使用 `v-for` 遍历 `COMPOSITE_CONTAINER_MAP[node.tagName]` 进行动态渲染**,严禁使用 `v-if="node.children.find(c => c.tagName === 'SPECIFIC_TAG')"` 这种死代码。
3. **高亮与定位联动**
* 必须在每一个可被编辑或选中的子节点 DOM 元素上挂载 `:data-node-id="child.id"`,以确保搜索及左侧树点击时能精确定位到该节点。
* 父级包装器(如表格的 `<tr>`)如需在子节点被选中时联动高亮,必须通过遍历 `COMPOSITE_CONTAINER_MAP[parentTag]` 并判断 `child.id === selectedNodeId` 来动态激活高亮样式,确保高亮状态同步。
- 必须在每一个可被编辑或选中的子节点 DOM 元素上挂载 `:data-node-id="child.id"`,以确保搜索及左侧树点击时能精确定位到该节点。
- 父级包装器(如表格的 `<tr>`)如需在子节点被选中时联动高亮,必须通过遍历 `COMPOSITE_CONTAINER_MAP[parentTag]` 并判断 `child.id === selectedNodeId` 来动态激活高亮样式,确保高亮状态同步。
......@@ -7,8 +7,10 @@
"dev": "vite",
"dev:prod": "vite --mode prod",
"dev:test": "vite --mode test",
"dev:xm": "vite --mode xm",
"build": "vue-tsc -b && vite build",
"build:prod": "vue-tsc -b && vite build --mode prod",
"build:xm": "vue-tsc -b && vite build --mode xm",
"preview": "vite preview",
"prepare": "husky install",
"commitlint": "commitlint --config commitlint.config.cjs -e -V",
......
......@@ -50,11 +50,7 @@ const createService = (baseURL: string) => {
if (isJson) {
json = (await response.json()) as ResponseData
if (json) {
if (
json.code === 200 ||
json.code === '200' ||
(json.code === undefined && json.success === undefined)
) {
if (json.code === 200 || json.code === '200' || (json.code === undefined && json.success === undefined)) {
json.code = 200
return json
}
......@@ -80,11 +76,7 @@ const createService = (baseURL: string) => {
}
if (json) {
if (
json.code === 200 ||
json.code === '200' ||
(json.code === undefined && json.success === undefined)
) {
if (json.code === 200 || json.code === '200' || (json.code === undefined && json.success === undefined)) {
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 @@
content-style="padding: 0; background: transparent;"
class="download-progress-modal"
>
<div
class="relative overflow-hidden rounded-2xl bg-fill-1/80 backdrop-blur-xl p-4 border border-color1/20"
>
<div 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 -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 @@
</template>
<script setup lang="ts">
const show = ref(false)
const loading = ref(false)
const exportProgress = ref(0)
......
......@@ -55,7 +55,6 @@
import { CloudUploadOutline, DownloadOutline } from '@vicons/ionicons5'
import type { UploadFileInfo } from 'naive-ui'
interface ImportOptions {
title?: string
api: string
......
......@@ -86,7 +86,6 @@
</template>
<script setup lang="ts">
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
......
......@@ -36,8 +36,7 @@
:style="{ backgroundColor: themeVars.actionColor, color: themeVars.textColor2 }"
>
<!-- 左侧:占位 -->
<div class="mr-auto flex items-center">
</div>
<div class="mr-auto flex items-center"></div>
<div v-if="!compact" class="flex items-center space-x-3">
<span>显示 {{ pageStart }}{{ pageEnd }}, 共 {{ displayTotal }} 记录</span>
......@@ -128,7 +127,6 @@
</template>
<script setup lang="ts">
import { useThemeVars } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { PlayBackOutline, ChevronBackOutline, ChevronForwardOutline, PlayForwardOutline, RefreshOutline } from '@vicons/ionicons5'
......
......@@ -16,7 +16,6 @@
</template>
<script setup lang="ts">
import type { PropType, ComponentPublicInstance } from 'vue'
const props = defineProps({
......
......@@ -39,7 +39,6 @@
import { CloudUploadOutline } from '@vicons/ionicons5'
import type { UploadFileInfo } from 'naive-ui'
export interface UploadOptions {
/** 弹窗标题 */
title?: string
......
......@@ -10,9 +10,16 @@
* 包含 SMJC/LMJC/NRCJC 等各类工卡头部节点。
*/
export const HEADER_TAGS = [
'SMJC-HEADER', 'LMJC-HEADER', 'NRCJC-HEADER',
'TCJC-HEADER', 'QECJC-HEADER', 'EOTK-HEADER',
'DRJC-HEADER', 'CMJC-HEADER', 'EOJC-HEADER', 'MAOJC-HEADER'
'SMJC-HEADER',
'LMJC-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 = [
*/
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',
// 适用性标记
'EFFECT', 'CONEFFECT',
'EFFECT',
'CONEFFECT',
// CB 类(工具箱)
'CB', 'CBNAME', 'CBLOC',
'CB',
'CBNAME',
'CBLOC',
// 上下标
'SUPER', 'SUPERSCRIPT', 'SUB', 'SUBSCRIPT',
'SUPER',
'SUPERSCRIPT',
'SUB',
'SUBSCRIPT',
// 其他常见行内
'SSI', 'ACRO', 'SP', 'KWD', 'CSN', 'CON', 'NCON',
'REVST', 'REVEND'
'SSI',
'ACRO',
'SP',
'KWD',
'CSN',
'CON',
'NCON',
'REVST',
'REVEND'
]
// 段落类节点标签(PARA 为英文段落,PARAC 为中文段落)
......@@ -81,6 +110,25 @@ export const CB_COMPONENT_TAGS = ['CB', 'CBNAME', 'CBLOC']
// 上下标标签
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[]> = {
CBDATA: ['PAN', 'CBNAME', 'CB', 'CBLOC'],
......@@ -105,37 +153,40 @@ export const ROMAN_LOOKUP: Array<[string, number]> = [
// ─── 派生组合标签集(由以上原子集合组合而成)────────────────────────────────
// 文档型叶子节点(用于在左侧树提取文本副标题展示)
export const DOCUMENT_LIKE_TAGS = [
'PARA', 'PARAC', 'TITLE', 'TITLEC', 'REGULATION',
'REFBLOCK', 'GRPHCREF', 'REFINT', 'EQU'
]
export const DOCUMENT_LIKE_TAGS = ['PARA', 'PARAC', 'TITLE', 'TITLEC', 'REGULATION', 'REFBLOCK', 'GRPHCREF', 'REFINT', 'EQU']
// 所有列表容器标签(有序 LIST1-7 + 无序 UNLIST/NUMLIST)
export const ALL_LIST_TAGS = [...ORDERED_LIST_TAGS, 'UNLIST', 'NUMLIST']
// 警示及适用性关联标签(WARNING_LIKE + CAUTION + NOTE,在列表拆分等场景用作前置拦截)
export const ALERT_AND_EFF_TAGS = [...WARNING_LIKE_TAGS, 'CAUTION', 'NOTE']
// 警示及适用性关联标签(WARNING_LIKE + 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)
export const COLORED_TAGS = [...WARNING_LIKE_TAGS, 'CAUTION', ...NOTE_AND_REF_TAGS]
// 表格单元格下的复杂内容容器标签(当包含这些标签时使用 DocNodeRenderer 渲染)
export const COMPLEX_ENTRY_TAGS = [
...ORDERED_LIST_TAGS, 'UNLIST', 'NUMLIST',
'WARNING', 'CAUTION', 'NOTE',
'GRAPHIC', 'TABLE'
]
export const COMPLEX_ENTRY_TAGS = [...ORDERED_LIST_TAGS, 'UNLIST', 'NUMLIST', 'WARNING', 'CAUTION', 'NOTE', 'GRAPHIC', 'TABLE']
// 树结构容器节点集合(在获取文本预览等场景中跳过这些节点的文本提取)
export const STRUCTURAL_TAGS = [
...TRANSPARENT_TAGS,
'PRETOPIC', 'TOPIC', 'SUBTASK', 'STEP',
'PRETOPIC',
'TOPIC',
'SUBTASK',
'STEP',
...ORDERED_LIST_TAGS,
...LIST_ITEM_TAGS,
'UNLIST', 'NUMLIST',
'UNLIST',
'NUMLIST',
...CALS_TABLE_ALL_TAGS,
'WARNING', 'CAUTION', 'NOTE', 'HNANOTE',
'CBSUBLST', 'GRAPHIC', 'FTNOTE', 'APPEND'
'WARNING',
'CAUTION',
'NOTE',
'HNANOTE',
'CBSUBLST',
'GRAPHIC',
'FTNOTE',
'APPEND'
]
// 中文段落优先的标签排序列表
......@@ -143,4 +194,3 @@ export const CHINESE_FIRST_PARA_TAGS = ['PARAC', 'PARA']
// 默认翻译目标标签列表
export const TRANSLATE_TARGET_TAGS = ['PARAC', 'TITLEC']
import mitt from 'mitt'
type Fn = (...args: any[]) => void
interface Option {
......
......@@ -7,16 +7,15 @@ interface ShortcutOptions {
onEscape?: () => void
}
export function useKeyboardShortcuts(
options: ShortcutOptions = {},
enableGlobal = false
) {
export function useKeyboardShortcuts(options: ShortcutOptions = {}, enableGlobal = false) {
const appStore = useAppStore()
const editorStore = useEditorStore()
// 1. 全局快捷键(使用 useEventListener 并开启 capture: true 捕获事件)
if (enableGlobal) {
useEventListener('keydown', (e: KeyboardEvent) => {
useEventListener(
'keydown',
(e: KeyboardEvent) => {
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
......@@ -57,7 +56,9 @@ export function useKeyboardShortcuts(
}, 0)
}
}
}, { capture: true })
},
{ capture: true }
)
}
// 2. 局部/上下文快捷键(使用 onKeyStroke,在冒泡阶段响应)
......
import type { RouteRecordRaw } from 'vue-router'
import MainLayout from '@/layouts/MainLayout.vue'
// 静态路由
const constantRoutes: Array<RouteRecordRaw> = [
{
......@@ -41,8 +40,6 @@ const router = createRouter({
routes: constantRoutes
})
router.beforeEach(() => {
window.$loadingBar?.start()
})
......@@ -61,6 +58,4 @@ router.onError(() => {
window.$loadingBar?.error()
})
export default router
......@@ -741,15 +741,11 @@ export const useEditorStore = defineStore('editor', {
// 退化校验:如果子元素为空,直接收归为纯文本并清空混合数组
if (parent.children.length === 0) {
const mergedText = parent.mixedContent
.map((item: any) => (item.type === 'text' ? item.text || '' : ''))
.join('')
const mergedText = parent.mixedContent.map((item: any) => (item.type === 'text' ? item.text || '' : '')).join('')
parent.textContent = mergedText
parent.mixedContent = []
} else {
parent.textContent = parent.mixedContent
.map((item: any) => (item.type === 'text' ? item.text || '' : ''))
.join('')
parent.textContent = parent.mixedContent.map((item: any) => (item.type === 'text' ? item.text || '' : '')).join('')
}
// 选中父节点
......@@ -758,7 +754,6 @@ export const useEditorStore = defineStore('editor', {
this.rebuildNodeMap()
},
/**
* 批量删除指定的多个节点(支持跨层级跨父节点)
*/
......
......@@ -17,9 +17,6 @@ declare global {
[key: string]: any
}
/**
* 业务封装后的 Dialog API (Promise 风格)
*/
......
/**
* 通用列表数据请求工具
* 逻辑来源:抽离自 CommonTable 和 CommonSelect 的数据获取逻辑
......
......@@ -11,7 +11,45 @@ let _schema: DtdSchema | null = null
* 加载 DTD Schema
*/
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 CommonButton from '@/components/CommonButton.vue'
......
......@@ -66,8 +66,8 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
let textContent = ''
const childNodes = Array.from(element.childNodes)
const hasElementChildren = childNodes.some(n => n.nodeType === Node.ELEMENT_NODE)
const hasTextChildren = childNodes.some(n => n.nodeType === Node.TEXT_NODE && n.textContent?.trim())
const hasElementChildren = childNodes.some((n) => n.nodeType === Node.ELEMENT_NODE)
const hasTextChildren = childNodes.some((n) => n.nodeType === Node.TEXT_NODE && n.textContent?.trim())
if (hasElementChildren && hasTextChildren) {
// 混合内容节点(如 PARA, PARAC 中嵌有 REFBLOCK 等行内元素)
......@@ -132,7 +132,7 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b
if (item.type === 'text') {
content += escapeXmlText(item.text || '')
} 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) {
content += serializeTreeToXml(child, 0, compact)
}
......@@ -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}>`
}
......@@ -216,9 +216,9 @@ export function cloneNode(node: XmlNode, newParentId: string | null = null): Xml
id: newId,
tagName: node.tagName,
attributes: { ...node.attributes },
children: node.children.map(c => cloneNode(c, newId)),
children: node.children.map((c) => cloneNode(c, newId)),
textContent: node.textContent,
mixedContent: node.mixedContent.map(item => {
mixedContent: node.mixedContent.map((item) => {
if (item.type === 'text') return { ...item }
// 元素引用需要更新 nodeId,但这里只做浅复制标记
return { ...item }
......@@ -234,7 +234,7 @@ export function getNodeDisplayName(node: XmlNode): string {
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) {
const title = titleChild.textContent || getTextFromMixedContent(titleChild)
if (title) return `${tag}: ${title.slice(0, 40)}${title.length > 40 ? '...' : ''}`
......@@ -270,7 +270,7 @@ export function getNodeDisplayName(node: XmlNode): string {
function getTextFromMixedContent(node: XmlNode): string {
if (node.textContent) return node.textContent
return node.mixedContent
.filter(item => item.type === 'text')
.map(item => item.text || '')
.filter((item) => item.type === 'text')
.map((item) => item.text || '')
.join('')
}
......@@ -135,6 +135,42 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
return `${idx + 1}.`
}
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
if (!parent) return 'A.'
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
return '•'
}
// 获取 TOPIC / PRETOPIC 序号
// 获取 TOPIC / PRETOPIC 序号 (与 PDF 端的 XSL 解析逻辑对齐)
const getTopicSeqNum = (node: XmlNode): string => {
const parent = props.parent
if (!parent) return ''
if (parent.tagName === 'CEP' || parent.tagName === 'TASK') {
const topicSiblings = parent.children.filter((c) => c.tagName === 'TOPIC' || c.tagName === 'PRETOPIC')
const idx = topicSiblings.findIndex((c) => c.id === node.id)
let rootNode: XmlNode | null = null
let isAlphaFormat = false
let curr = editorStore.nodeMap.get(node.id)
while (curr) {
if (curr.node.tagName === 'CEP' || curr.node.tagName === 'TASK') {
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}. `
}
}
......@@ -329,8 +407,8 @@ export const getSplitListChildren = (children?: XmlNode[]) => {
}
export const getCepTaskNumber = (node: XmlNode): string => {
const { CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes || {}
const parts = [CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean)
const { CHAPPREF, CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes || {}
const parts = [CHAPPREF, CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean)
return parts.join('-')
}
......
......@@ -94,12 +94,31 @@
<!-- 6. PARA / PARAC 段落文本处理 -->
<template v-else-if="PARA_SET.has(node.tagName)">
<!-- 如果有子节点,说明属于容器段落,就不在 PARA 中展示或编辑文本内容,只展示其子元素节点 -->
<div
v-if="node.children && node.children.length > 0"
class="text-sm leading-relaxed py-1 flex flex-wrap items-center"
:class="[isInsideAlert ? 'text-inherit' : 'text-color2']"
:style="{ justifyContent: 'var(--cell-align, flex-start)' }"
>
<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="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"
......@@ -108,22 +127,41 @@
is-inline
:inside-ref-block="insideRefBlock"
/>
</div>
</template>
<!-- 否则,如果是一个纯文本的段落,直接展示和编辑文本 -->
<template v-else>
<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']"
class="w-full focus:outline-none focus:bg-fill-3 px-1 rounded transition-colors"
@blur="handleTextBlur"
v-text="node.textContent"
></div>
</template>
</div>
</template>
<!-- 7. REFBLOCK 特殊处理 -->
<template v-else-if="node.tagName === 'REFBLOCK'">
<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" />
</template>
<template v-else>
......@@ -246,17 +284,16 @@
<!-- 13. TED / 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-if="node.children.find((c) => c.tagName === childTag)">
<span v-if="childTag.endsWith('NBR')" class="text-color3 select-none mx-0.5">
{{ childTag === 'CONNBR' ? '(Material Ref. ' : '(' }}
</span>
<DocNodeRenderer
:node="node.children.find((c) => c.tagName === childTag)!"
:parent="node"
is-inline
/>
<DocNodeRenderer :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>
</template>
</template>
......@@ -319,7 +356,9 @@
class="h-8 hover:bg-fill-3 transition-colors cursor-pointer"
:class="[
editorStore.selectedNodeId === cbData.id ||
cbData.children.some(c => c.id === editorStore.selectedNodeId && COMPOSITE_CONTAINER_MAP['CBDATA']?.includes(c.tagName))
cbData.children.some(
(c) => c.id === editorStore.selectedNodeId && COMPOSITE_CONTAINER_MAP['CBDATA']?.includes(c.tagName)
)
? 'bg-primary/5 font-bold'
: ''
]"
......@@ -495,7 +534,8 @@
<!-- 23. SIGNOFF (签字点) 处理 (仿照 PDF 签字表) -->
<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">
<tbody>
<tr class="h-8">
......@@ -554,6 +594,7 @@
<!-- 24.5 SUBTASK 特殊处理 (加上左侧虚线框和段落缩进) -->
<template v-else-if="node.tagName === 'SUBTASK'">
<div class="space-y-1 pl-4 border-l border-dashed border-divider/60 my-2">
<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" />
</div>
</template>
......@@ -569,10 +610,13 @@
<template v-else-if="node.tagName === 'EINLST'">
<div class="my-2 py-1 select-text">
<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>
<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
class="font-mono cursor-pointer hover:underline transition-all"
:data-node-id="child.id"
......@@ -602,7 +646,10 @@
:parent="eindata"
/>
<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
class="font-mono cursor-pointer hover:underline transition-all"
:data-node-id="child.id"
......@@ -660,6 +707,45 @@
</span>
</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. 兜底未匹配的普通文本标签 -->
<template v-else>
<span
......@@ -686,14 +772,28 @@ import { ImageOutline, GridOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode'
import TableEditor from '../TableEditor/index.vue'
import { useDocNodeRenderer, getSplitListChildren, getCepTaskNumber, isAllEinDataSameEffect } from './functionals'
import { 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 判断使用
const ALERT_BLOCK_SET = new Set(ALERT_BLOCK_TAGS)
const PARA_SET = new Set(PARA_TAGS)
const CB_COMPONENT_SET = new Set(CB_COMPONENT_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(
defineProps<{
......@@ -722,15 +822,12 @@ const {
getTopicSeqNum,
getTopicTitleNodes,
getTopicContentNodes,
getChildNode,
handleTextBlur,
handleChildTextBlur,
getGraphicTitle,
handleGraphicTitleBlur,
getTedText,
getConText,
handleMixedTextBlur,
hasNonCbDataChildren,
getNonCbDataChildren,
getCbValue
getNonCbDataChildren
} = useDocNodeRenderer(props)
</script>
......
......@@ -19,9 +19,12 @@ export function useFindReplace(
const matchItemRefs = ref<any[]>([])
// 每次匹配列表变化时重置 ref 数组
watch(() => matches.value, () => {
watch(
() => matches.value,
() => {
matchItemRefs.value = []
})
}
)
const scrollToActiveMatch = () => {
nextTick(() => {
......
......@@ -5,9 +5,12 @@
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"
:style="{
width: isExpanded ? '800px' : '500px',
width: computedWidth,
height: computedHeight,
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 }"
>
......@@ -116,14 +119,15 @@
</div>
<!-- 匹配文本摘要预览(引入 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
v-model:selected-id="activeMatchNodeId"
:node-ids="matchNodeIds"
:flat="true"
:dense="!isExpanded"
mode="radio"
:max-height="isExpanded ? 480 : 240"
:max-height="'100%'"
class="h-full"
:active-match-index="currentMatchIndex"
:node-match-stats="nodeMatchStats"
:highlight="findQuery"
......@@ -136,6 +140,7 @@
<script setup lang="ts">
import { SearchOutline, CloseOutline, ChevronUpOutline, ChevronDownOutline, CodeOutline } from '@vicons/ionicons5'
import { useWindowSize } from '@vueuse/core'
import { useFindReplace, useDraggable } from './functionals'
const props = defineProps<{
......@@ -172,6 +177,33 @@ const {
closePanel
} = 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 ids = matches.value.map((m) => m.nodeId)
return Array.from(new Set(ids))
......
......@@ -14,21 +14,32 @@ export const ESTIMATED_HEIGHT = 200
/** 按节点类型获取预估高度(用于虚拟列表初始高度计算) */
export const getEstimatedHeight = (tagName: string): number => {
switch (tagName) {
case 'TABLE': return 400 // 表格通常较高
case 'GRAPHIC': return 300 // 图片/图纸
case 'WARNING': return 180 // 警告块
case 'CAUTION': return 180
case 'NOTE': return 150
case 'PRETOPIC': return 160 // 模板段落
case 'TABLE':
return 400 // 表格通常较高
case 'GRAPHIC':
return 300 // 图片/图纸
case 'WARNING':
return 180 // 警告块
case 'CAUTION':
return 180
case 'NOTE':
return 150
case 'PRETOPIC':
return 160 // 模板段落
case 'UNLIST':
case 'LIST1':
case 'LIST2':
case 'LIST3': return 200 // 列表
case 'LIST3':
return 200 // 列表
case 'PARA':
case 'PARAC': return 80 // 普通段落
case 'SMUC-HEADER': return 120
case 'FINLIST': return 100
default: return ESTIMATED_HEIGHT
case 'PARAC':
return 80 // 普通段落
case 'SMUC-HEADER':
return 120
case 'FINLIST':
return 100
default:
return ESTIMATED_HEIGHT
}
}
......
......@@ -41,7 +41,12 @@
</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' }">
<!-- 仅渲染可视区块,通过 translateY 定位 -->
......
......@@ -99,7 +99,6 @@
</template>
<script setup lang="ts">
import { CheckmarkCircleOutline } from '@vicons/ionicons5'
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 @@
</div>
<n-form-item label="单元格初始化段落节点" path="cellChildTags">
<div class="w-full bg-fill-2 p-3 rounded border border-divider">
<CommonCheckbox
v-model:value="form.cellChildTags"
:options="cellChildOptions"
:space-size="24"
/>
<CommonCheckbox v-model:value="form.cellChildTags" :options="cellChildOptions" :space-size="24" />
</div>
</n-form-item>
</div>
......
......@@ -38,26 +38,12 @@
</div>
<div class="flex flex-col gap-2 max-h-[200px] overflow-y-auto pr-1">
<div
v-for="(pair, index) in form.tag_pairs"
:key="index"
class="flex items-center gap-2"
>
<n-input
v-model:value="pair.en_tag"
placeholder="源英文标签 (例: PARA)"
size="small"
class="flex-1"
/>
<div v-for="(pair, index) in form.tag_pairs" :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">
<arrow-forward-outline />
</n-icon>
<n-input
v-model:value="pair.cn_tag"
placeholder="目标中文标签 (例: PARAC)"
size="small"
class="flex-1"
/>
<n-input v-model:value="pair.cn_tag" placeholder="目标中文标签 (例: PARAC)" size="small" class="flex-1" />
<CommonButton
size="small"
type="error"
......@@ -89,9 +75,13 @@
<div>
<div class="text-base font-bold text-color1">对照对提取处理成功!</div>
<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 class="text-success font-bold text-sm">{{ importCount }}</span>
,导入数据库:
<span class="text-success font-bold text-sm">{{ importCount }}</span>
</span>
</div>
</div>
......@@ -99,16 +89,8 @@
<!-- 提取数据预览列表 -->
<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">
数据预览 (展示前 10 条)
</div>
<n-data-table
:columns="previewColumns"
:data="previewList"
:max-height="250"
size="small"
:bordered="false"
/>
<div class="bg-fill-3 px-3 py-2 text-xs font-bold text-color2 border-b border-divider">数据预览 (展示前 10 条)</div>
<n-data-table :columns="previewColumns" :data="previewList" :max-height="250" size="small" :bordered="false" />
</div>
</div>
</div>
......
......@@ -38,11 +38,7 @@ export function useInsertFragmentModal() {
isSaving.value = true
try {
const count = editorStore.insertXmlFragment(
xmlContent.value.trim(),
insertModeSetting.value,
targetNodeIdSetting.value
)
const count = editorStore.insertXmlFragment(xmlContent.value.trim(), insertModeSetting.value, targetNodeIdSetting.value)
window.$message?.success(`成功插入 ${count} 个 XML 节点`)
visible.value = false
} catch (err: any) {
......
<template>
<CommonModal
v-model="visible"
title="插入 XML 片段"
:width="600"
:loading="isSaving"
confirm-text="插入"
@confirm="handleConfirm"
>
<CommonModal v-model="visible" title="插入 XML 片段" :width="600" :loading="isSaving" confirm-text="插入" @confirm="handleConfirm">
<div class="flex flex-col space-y-3 p-1">
<div class="text-xs text-color3">
请输入从其他地方复制的 XML 节点片段,系统将自动解析节点结构,并根据当前选中节点及 DTD 架构规则进行兼容性校验:
</div>
<n-input
v-model:value="xmlContent"
type="textarea"
rows="10"
placeholder="例如:<PARAC>测试记录行</PARAC>"
/>
<n-input v-model:value="xmlContent" type="textarea" rows="10" placeholder="例如:<PARAC>测试记录行</PARAC>" />
</div>
</CommonModal>
</template>
......@@ -24,13 +12,7 @@
<script setup lang="ts">
import { useInsertFragmentModal } from './functionals'
const {
visible,
xmlContent,
isSaving,
open,
handleConfirm
} = useInsertFragmentModal()
const { visible, xmlContent, isSaving, open, handleConfirm } = useInsertFragmentModal()
defineExpose({
open,
......
......@@ -57,7 +57,6 @@ export function useSearchTranslate() {
{ label: '中译英 (ZH -> EN)', value: 'zh_to_en' }
]
const open = () => {
showModal.value = true
activeTab.value = 'search'
......@@ -264,7 +263,6 @@ export function useSearchTranslate() {
}
}
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
......
......@@ -136,21 +136,11 @@
<n-form label-placement="left" label-width="80" size="medium">
<n-form-item label="英文原文">
<n-input
v-model:value="addForm.text"
type="textarea"
:rows="3"
placeholder="请输入需要保存的英文术语或原文段落..."
/>
<n-input v-model:value="addForm.text" type="textarea" :rows="3" placeholder="请输入需要保存的英文术语或原文段落..." />
</n-form-item>
<n-form-item label="中文翻译">
<n-input
v-model:value="addForm.translation"
type="textarea"
:rows="3"
placeholder="请输入对应的中文标准翻译..."
/>
<n-input v-model:value="addForm.translation" type="textarea" :rows="3" placeholder="请输入对应的中文标准翻译..." />
</n-form-item>
<div class="flex justify-end mt-2">
......@@ -250,7 +240,12 @@
</div>
<div class="flex flex-col gap-1">
<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>
......@@ -265,7 +260,9 @@
</div>
<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>
......
......@@ -7,7 +7,7 @@ export const TOOLBAR_TITLE = 'XML 编辑工具栏'
export const GREEN_BUTTONS: any[] = [
// { label: '插入图片', tag: 'GRAPHIC', icon: ImageOutline },
{ label: '插入表格', tag: 'TABLE', icon: GridOutline }
{ label: '插入表格', tag: 'TABLE', icon: GridOutline },
// { label: '插入模板', tag: 'PRETOPIC', icon: DocumentTextOutline },
// { label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline }
{ label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline }
]
import { useEditorStore, createTableStructure } from '@/store/editor'
import { useAppStore } from '@/store/app/index'
import { canAddChild } from '@/utils/dtdManager'
import type { XmlNode } from '@/types/xmlNode'
/**
* EditorToolbar 组件级业务逻辑 Hook
......@@ -13,6 +14,7 @@ export function useEditorToolbar(emit: any) {
const fileInputRef = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
const createTableModalRef = ref<any>(null)
const createSignoffModalRef = ref<any>(null)
const canUndo = computed(() => editorStore.undoStack.length > 0)
const canRedo = computed(() => editorStore.redoStack.length > 0)
......@@ -46,11 +48,28 @@ export function useEditorToolbar(emit: any) {
if (tag === 'TABLE') {
createTableModalRef.value?.open()
} else if (tag === 'SIGNOFF') {
createSignoffModalRef.value?.open()
} else {
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 tableNode = createTableStructure(rows, cols, cellChildTags)
editorStore.insertNode('TABLE', insertBelow.value, tableNode)
......@@ -116,6 +135,8 @@ export function useEditorToolbar(emit: any) {
triggerUpload,
createTableModalRef,
handleCreateTableConfirm,
createSignoffModalRef,
handleCreateSignoffConfirm,
batchTranslateModalRef,
extractTranslateModalRef,
searchTranslateModalRef,
......
......@@ -156,6 +156,9 @@
<!-- 插入表格弹窗 -->
<CreateTableModal ref="createTableModalRef" @confirm="handleCreateTableConfirm" />
<!-- 插入签字点弹窗 -->
<CreateSignoffModal ref="createSignoffModalRef" @confirm="handleCreateSignoffConfirm" />
<!-- 批量翻译弹窗 -->
<BatchTranslateModal ref="batchTranslateModalRef" />
......@@ -188,6 +191,7 @@ import { GREEN_BUTTONS } from './constants'
import SettingsDrawer from '@/layouts/components/SettingsDrawer.vue'
import InsertFragmentModal from './components/InsertFragmentModal/index.vue'
import CreateTableModal from './components/CreateTableModal/index.vue'
import CreateSignoffModal from './components/CreateSignoffModal/index.vue'
import BatchTranslateModal from './components/BatchTranslateModal/index.vue'
import ExtractTranslateModal from './components/ExtractTranslateModal/index.vue'
import SearchTranslateModal from './components/SearchTranslateModal/index.vue'
......@@ -207,6 +211,8 @@ const {
triggerUpload,
createTableModalRef,
handleCreateTableConfirm,
createSignoffModalRef,
handleCreateSignoffConfirm,
batchTranslateModalRef,
extractTranslateModalRef,
searchTranslateModalRef
......
......@@ -19,11 +19,16 @@ export function useListEditor() {
const getItemTagName = (containerTag: string): string => {
switch (containerTag) {
case 'LIST1': return 'L1ITEM'
case 'LIST2': return 'L2ITEM'
case 'LIST3': return 'L3ITEM'
case 'UNLIST': return 'UNLITEM'
default: return 'L1ITEM'
case 'LIST1':
return 'L1ITEM'
case 'LIST2':
return 'L2ITEM'
case 'LIST3':
return 'L3ITEM'
case 'UNLIST':
return 'UNLITEM'
default:
return 'L1ITEM'
}
}
......@@ -32,10 +37,10 @@ export function useListEditor() {
const itemTagName = getItemTagName(node.tagName)
return node.children
.filter(c => c.tagName === itemTagName)
.map(itemNode => {
.filter((c) => c.tagName === itemTagName)
.map((itemNode) => {
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) {
text = firstPara.textContent || ''
}
......@@ -49,10 +54,10 @@ export function useListEditor() {
}
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
const para = itemNode.children.find(c => PARA_TAGS.includes(c.tagName))
const para = itemNode.children.find((c) => PARA_TAGS.includes(c.tagName))
if (para) {
para.textContent = text
if (para.mixedContent.length > 0) {
......@@ -96,7 +101,7 @@ export function useListEditor() {
}
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) {
node.children.splice(idx, 1)
store.triggerSync()
......@@ -104,7 +109,7 @@ export function useListEditor() {
}
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
const target = direction === 'up' ? idx - 1 : idx + 1
......
......@@ -4,13 +4,13 @@
<div class="flex items-center justify-between pb-2 border-b border-divider">
<div class="flex items-center space-x-2">
<CommonTag type="info" size="small">{{ node.tagName }}</CommonTag>
<span class="text-xs text-color3">
{{ isOrdered ? '有序' : '无序' }}列表编辑器 (子项共: {{ listItems.length }} 个)
</span>
<span class="text-xs text-color3">{{ isOrdered ? '有序' : '无序' }}列表编辑器 (子项共: {{ listItems.length }} 个)</span>
</div>
<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>
</div>
......@@ -41,13 +41,19 @@
<!-- 操作按钮组 -->
<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">
<template #icon><n-icon><arrow-up-outline /></n-icon></template>
<template #icon>
<n-icon><arrow-up-outline /></n-icon>
</template>
</CommonButton>
<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 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>
</div>
</div>
......@@ -76,9 +82,13 @@ const isOrdered = computed(() => ORDERED_LIST_TAGS.includes(props.node.tagName))
const listItems = ref(parseListItems(props.node))
watch(() => props.node, (newVal) => {
watch(
() => props.node,
(newVal) => {
listItems.value = parseListItems(newVal)
}, { deep: true, immediate: true })
},
{ deep: true, immediate: true }
)
function handleTextBlur(itemId: string, e: FocusEvent) {
const el = e.target as HTMLElement
......@@ -108,5 +118,4 @@ function handleMove(itemId: string, direction: 'up' | 'down') {
}
</script>
<style scoped>
</style>
<style scoped></style>
......@@ -142,7 +142,17 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const defs = getElementAttributes(node.tagName)
const attrs: Record<string, string | null> = {}
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
}
......@@ -180,7 +190,15 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
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)) {
if (v !== null && v !== undefined && v !== '') {
cleanAttrs[k] = v
......@@ -194,9 +212,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
targetNode.mixedContent[textIdx].text = form.textContent
}
// 同步更新父节点的 textContent
targetNode.textContent = targetNode.mixedContent
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
targetNode.textContent = targetNode.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
store.rebuildNodeMap()
if (store.selectedNodeId === addNodeTargetId.value) {
......@@ -221,9 +237,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
targetNode.mixedContent.unshift({ type: 'text', text: form.textContent })
}
// 重新拼合 textContent,确保与 mixedContent 数据一致
targetNode.textContent = targetNode.mixedContent
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
targetNode.textContent = targetNode.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
} else {
targetNode.textContent = form.textContent
}
......@@ -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: form.textContent })
targetNode.textContent = targetNode.mixedContent
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
targetNode.textContent = targetNode.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
store.rebuildNodeMap()
const newTextIdx = targetNode.mixedContent.length - 1
store.setSelectedNodeId(`${targetNode.id}-txt-${newTextIdx}`)
......@@ -270,9 +282,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
destParent.mixedContent.splice(insertIdx, 0, { type: 'text', text: form.textContent })
}
}
destParent.textContent = destParent.mixedContent
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
destParent.textContent = destParent.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
store.rebuildNodeMap()
const newTextIdx = destParent.mixedContent.findIndex((item) => item.type === 'text' && item.text === form.textContent)
......@@ -308,9 +318,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
}
targetNode.mixedContent.push({ type: 'element', nodeId: newNode.id })
// 重新拼接父节点的 textContent,以防混合渲染时直接被覆盖
targetNode.textContent = targetNode.mixedContent
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
targetNode.textContent = targetNode.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
}
} else {
let destParent: XmlNode | null = null
......@@ -333,7 +341,10 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
destParent.children.splice(insertIdx, 0, newNode)
const mixedIdx = destParent.mixedContent.findIndex((item) => item.nodeId === addNodeTargetId.value)
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
})
}
}
}
......
......@@ -17,7 +17,7 @@ export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
blockedNodes.value = blocked
safeNodes.value = safeIds.map(id => {
safeNodes.value = safeIds.map((id) => {
const item = nodeMap.get(id)
const node = item?.node
const tagName = node?.tagName || '未知'
......@@ -39,28 +39,28 @@ export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
// 计算全选状态
const isAllChecked = computed(() => {
if (safeNodes.value.length === 0) return false
return safeNodes.value.every(item => item.checked)
return safeNodes.value.every((item) => item.checked)
})
const isIndeterminate = computed(() => {
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
})
const checkedCount = computed(() => {
return safeNodes.value.filter(item => item.checked).length
return safeNodes.value.filter((item) => item.checked).length
})
const toggleCheckAll = (checked: boolean) => {
safeNodes.value.forEach(item => {
safeNodes.value.forEach((item) => {
item.checked = checked
})
}
// 确认删除选中节点
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) {
editorStore.batchDeleteMultipleNodes(idsToDelete)
window.$message?.success('批量选择性删除成功')
......@@ -100,10 +100,7 @@ const getNodeDisplayName = (node: XmlNode): string => {
return `<${node.tagName}>${suffix}`
}
const getNodeParentPath = (
nodeId: string,
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
): string => {
const getNodeParentPath = (nodeId: string, nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>): string => {
const path: string[] = []
let currentId: string | null = nodeId
while (currentId) {
......@@ -118,11 +115,7 @@ const getNodeParentPath = (
return path.join(' > ')
}
const isAncestorSelected = (
nodeId: string,
selectedSet: Set<string>,
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
): boolean => {
const isAncestorSelected = (nodeId: string, selectedSet: Set<string>, nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>): boolean => {
let currentId: string | null = nodeId
while (currentId) {
if (selectedSet.has(currentId)) {
......@@ -134,10 +127,7 @@ const isAncestorSelected = (
return false
}
const partitionBatchDelete = (
selectedNodeIds: string[],
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
) => {
const partitionBatchDelete = (selectedNodeIds: string[], nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>) => {
const safeIds: string[] = []
const blocked: BlockedNodeDetail[] = []
const selectedSet = new Set(selectedNodeIds)
......
......@@ -8,9 +8,7 @@
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4 py-1 text-xs pr-1">
<span class="text-color2 text-sm">
系统检测到您选中的节点中,部分节点由于 DTD 规则要求无法删除。您可以选择勾选并删除其余合法节点:
</span>
<span class="text-color2 text-sm">系统检测到您选中的节点中,部分节点由于 DTD 规则要求无法删除。您可以选择勾选并删除其余合法节点:</span>
<!-- 1. 无法删除的节点列表 (Blocked Nodes) -->
<div v-if="blockedNodes.length > 0" class="flex flex-col gap-2">
......@@ -33,13 +31,7 @@
<n-icon><checkmark-circle-outline /></n-icon>
可安全删除的节点 ({{ safeNodes.length }} 个):
</span>
<n-checkbox
:checked="isAllChecked"
:indeterminate="isIndeterminate"
@update:checked="toggleCheckAll"
>
全选
</n-checkbox>
<n-checkbox :checked="isAllChecked" :indeterminate="isIndeterminate" @update:checked="toggleCheckAll">全选</n-checkbox>
</div>
<CommonNodeDetailList
:items="formattedSafeNodes"
......@@ -51,13 +43,18 @@
</div>
<!-- 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>
<!-- 4. 底部小结 -->
<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>
</CommonModal>
......@@ -74,17 +71,8 @@ const emit = defineEmits<{
const editorStore = useEditorStore()
const {
show,
blockedNodes,
safeNodes,
isAllChecked,
isIndeterminate,
checkedCount,
open,
toggleCheckAll,
handleConfirm
} = useBatchDeleteConfirmModal(emit)
const { show, blockedNodes, safeNodes, isAllChecked, isIndeterminate, checkedCount, open, toggleCheckAll, handleConfirm } =
useBatchDeleteConfirmModal(emit)
const handleNodeClick = (item: any) => {
editorStore.setSelectedNodeId(item.id)
......
......@@ -32,4 +32,3 @@ const open = (nodeName: string, rawModel: string, humanReadable: string, parsed:
defineExpose({ open })
</script>
......@@ -4,7 +4,7 @@
<!-- XML 渲染面板,带优雅网格背景与代码字体 -->
<div
class="rounded-xl overflow-hidden border border-divider shadow-lg relative bg-fill-4"
style="background-image: radial-gradient(circle, rgba(0,0,0,0.02) 1px, transparent 1px); background-size: 16px 16px;"
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">
......@@ -54,7 +54,7 @@ defineExpose({ open })
<style scoped>
.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;
word-break: break-all;
}
......
......@@ -248,15 +248,17 @@ export function useNodeTree(
} else if (node.tagName === 'CBLST') {
const action = node.attributes.ACTION === 'verif-close' ? '确认关闭' : node.attributes.ACTION === 'open' ? '断开' : '操作'
subtitle = `行动: ${action}`
} else if (node.tagName === 'CEP') {
const { CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes || {}
const parts = [CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean)
} else if (node.tagName === 'CEP' || node.tagName === 'SUBTASK') {
const { CHAPPREF, CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes || {}
const parts = [CHAPPREF, CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean)
subtitle = parts.join('-')
} else if (node.tagName === 'UNIT-RECORD') {
const text = node.textContent ? node.textContent.trim() : ''
subtitle = `${text} (单位: ${node.attributes.UNIT || 'mm'})`
} else if (node.tagName === 'SIGNOFF') {
subtitle = `Tag: ${node.attributes.TAG || '签字'}`
const tag = node.attributes.TAG || '签字'
const level = node.attributes['CK-LEVEL']
subtitle = level ? `Tag: ${tag} (${level}级)` : `Tag: ${tag}`
} else if (node.attributes.ID) {
subtitle = node.attributes.ID
} else if (node.attributes.EFFRG) {
......
......@@ -83,10 +83,7 @@
>
<!-- 局部翻译加载状态 -->
<Transition name="translate-loading">
<div
v-if="translatingNodeId === item.id"
class="translate-loading-mask"
>
<div v-if="translatingNodeId === item.id" class="translate-loading-mask">
<div class="translate-loading-inner">
<n-icon size="13" class="translate-loading-icon">
<SyncOutline />
......@@ -94,7 +91,9 @@
<span class="translate-loading-text">
正在智能翻译
<span class="translate-dots">
<span>.</span><span>.</span><span>.</span>
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</span>
</div>
......@@ -180,13 +179,13 @@
/>
<!-- 查看规则弹窗 -->
<CheckRuleModal :ref="(el) => checkRuleModalRef = el" />
<CheckRuleModal :ref="(el) => (checkRuleModalRef = el)" />
<!-- 查看XML片段弹窗 -->
<ViewXmlModal :ref="(el) => viewXmlModalRef = el" />
<ViewXmlModal :ref="(el) => (viewXmlModalRef = el)" />
<!-- 添加/插入节点弹窗 -->
<AddNodeModal :ref="(el) => addNodeModalRef = el" />
<AddNodeModal :ref="(el) => (addNodeModalRef = el)" />
<!-- 插入 XML 片段弹窗 -->
<InsertFragmentModal ref="insertFragmentModalRef" />
......@@ -199,7 +198,16 @@
<script setup lang="ts">
import { SearchOutline, ListOutline, TrashOutline, CloseOutline, SyncOutline } from '@vicons/ionicons5'
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 AddNodeModal from './components/AddNodeModal/index.vue'
import ViewXmlModal from './components/ViewXmlModal/index.vue'
......@@ -396,14 +404,22 @@ const batchDeleteConfirmModalRef = ref<any>(null)
animation: translate-bounce 1.2s ease-in-out infinite;
font-weight: 900;
}
.translate-dots span:nth-child(1) { animation-delay: 0s; }
.translate-dots span:nth-child(2) { animation-delay: 0.2s; }
.translate-dots span:nth-child(3) { animation-delay: 0.4s; }
.translate-dots span:nth-child(1) {
animation-delay: 0s;
}
.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-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-leave-to {
......@@ -413,12 +429,22 @@ const batchDeleteConfirmModalRef = ref<any>(null)
}
@keyframes translate-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes translate-bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-3px); }
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-3px);
}
}
</style>
export interface SplitterProps {
width: number
collapsed: boolean
......
......@@ -9,7 +9,7 @@
<!-- 折叠状态:展开箭头 -->
<div v-if="collapsed" class="split-expand-btn">
<svg class="w-3 h-3" viewBox="0 0 12 12" fill="currentColor">
<path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<!-- 展开状态:可视指示线 + 手柄圆点 -->
......@@ -27,29 +27,27 @@
<script setup lang="ts">
import { useSplitter } from './functionals'
const props = withDefaults(defineProps<{
const props = withDefaults(
defineProps<{
width: number
collapsed: boolean
collapseThreshold?: number
defaultWidth?: number
maxRatio?: number
}>(), {
}>(),
{
collapseThreshold: 150,
defaultWidth: 560,
maxRatio: 0.6
})
}
)
const emit = defineEmits<{
(e: 'update:width', val: number): void
(e: 'update:collapsed', val: boolean): void
}>()
const {
dividerRef,
isDragging,
startDrag,
handleDividerClick
} = useSplitter(props, emit)
const { dividerRef, isDragging, startDrag, handleDividerClick } = useSplitter(props, emit)
</script>
<style scoped>
......@@ -80,7 +78,9 @@ const {
width: 1px;
transform: translateX(-50%);
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,
......@@ -102,7 +102,10 @@ const {
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
opacity: 0;
transform: scaleY(0.8);
transition: opacity 0.2s, transform 0.2s, background 0.2s;
transition:
opacity 0.2s,
transform 0.2s,
background 0.2s;
}
.split-divider:hover .split-divider-handle,
......
......@@ -17,11 +17,7 @@
<template v-if="!isDelete">
<n-form-item label="单元格初始化段落节点" path="cellChildTags">
<div class="w-full bg-fill-2 p-3 rounded border border-divider">
<CommonCheckbox
v-model:value="form.cellChildTags"
:options="cellChildOptions"
:space-size="24"
/>
<CommonCheckbox v-model:value="form.cellChildTags" :options="cellChildOptions" :space-size="24" />
</div>
</n-form-item>
</template>
......
......@@ -64,5 +64,5 @@ export const ACTION_META: Record<string, BatchActionMeta> = {
'col-right': { title: '在右侧插入列', description: '在当前列右侧插入多少列?', isDelete: false, count: 1 },
'col-append': { title: '在末尾追加列', description: '在表格末尾追加多少列?', isDelete: false, 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 { useAppStore } from '@/store/app'
import type { XmlNode } from '@/types/xmlNode'
......@@ -356,7 +357,13 @@ export function useTableEditor(props: { node: XmlNode }) {
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)
if (!tgroup) return
......@@ -527,7 +534,6 @@ export function useTableEditor(props: { node: XmlNode }) {
store.rebuildNodeMap()
}
const addColumn = (node: XmlNode, activeColIdx?: number, insertRight = true, cellChildTags?: string[] | null): void => {
const tgroup = findTgroup(node)
if (!tgroup) return
......@@ -785,7 +791,9 @@ export function useTableEditor(props: { node: XmlNode }) {
const deleteCellFromSection = (sec?: XmlNode) => {
if (!sec) return
sec.children.filter((c) => c.tagName === 'ROW').forEach((row) => {
sec.children
.filter((c) => c.tagName === 'ROW')
.forEach((row) => {
const entries = row.children.filter((c) => c.tagName === 'ENTRY')
if (entries[colIdx]) {
const entryIdx = row.children.findIndex((c) => c.id === entries[colIdx].id)
......@@ -1177,7 +1185,6 @@ export function useTableEditor(props: { node: XmlNode }) {
mergeMultipleCells(props.node, allSelectedIds, minCol, maxCol, minRow, maxRow, isThead)
}
const handleSplitSelected = () => {
if (selectedCellIds.value.length !== 1) return
const cellId = selectedCellIds.value[0]
......@@ -1540,6 +1547,158 @@ export function useTableEditor(props: { node: XmlNode }) {
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 {
structure,
selectedCellIds,
......@@ -1579,7 +1738,11 @@ export function useTableEditor(props: { node: XmlNode }) {
mergeMultipleCells,
splitCell,
insertedRowIds,
insertedCellIds
insertedCellIds,
// 样式计算
colWidthStyles,
tableFrameClass,
getCellStyle
}
}
......
......@@ -12,11 +12,11 @@
<table
class="w-full border-collapse text-sm table-fixed min-w-[600px] transition-all"
: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>
<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" />
</colgroup>
......@@ -60,6 +60,7 @@
isNodeSelected(structure.theadId) ? 'bg-primary/10 border-primary/40' : '',
insertedCellIds.has(cell.id) ? 'inserted-cell-highlight' : ''
]"
:style="getCellStyle(cell)"
@click.stop="handleCellClick(cell, $event)"
@contextmenu.prevent="handleCellContextMenu(cell, row.id, $event)"
>
......@@ -130,6 +131,7 @@
isNodeSelected(structure.tbodyId) ? 'bg-primary/5 border-primary/30' : '',
insertedCellIds.has(cell.id) ? 'inserted-cell-highlight' : ''
]"
:style="getCellStyle(cell)"
@click.stop="handleCellClick(cell, $event)"
@contextmenu.prevent="handleCellContextMenu(cell, row.id, $event)"
>
......@@ -237,7 +239,10 @@ const {
batchDeleteRows,
batchDeleteColumns,
insertedRowIds,
insertedCellIds
insertedCellIds,
colWidthStyles,
tableFrameClass,
getCellStyle
} = useTableEditor(props)
/** TableBatchModal 组件实例引用 */
......@@ -282,4 +287,39 @@ const { onContextMenuSelect, executeBatchAction } = useTableBatchActions({
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>
......@@ -69,7 +69,7 @@ export function useTextBlockEditor(getNode: () => XmlNode) {
attributes: {}
})
} 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) {
list.push({
type: 'element',
......
......@@ -83,13 +83,7 @@
class="flex-1"
@update:value="syncMixedContent"
/>
<n-input
v-else
v-model:value="item.attributes[attrName]"
size="tiny"
class="flex-1"
@input="syncMixedContent"
/>
<n-input v-else v-model:value="item.attributes[attrName]" size="tiny" class="flex-1" @input="syncMixedContent" />
</div>
</div>
</div>
......
......@@ -25,8 +25,7 @@ interface XmlNode {
// ── 工具函数 ──────────────────────────────────────────────────────────────────
function generateId(): string {
return (self as any).crypto?.randomUUID?.()
?? `node_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
return (self as any).crypto?.randomUUID?.() ?? `node_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
// ── DOM → XmlNode 递归转换 ────────────────────────────────────────────────────
......@@ -44,8 +43,8 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
let textContent = ''
const childNodes = Array.from(element.childNodes)
const hasElementChildren = childNodes.some(n => n.nodeType === 1 /* ELEMENT_NODE */)
const hasTextChildren = childNodes.some(n => n.nodeType === 3 /* TEXT_NODE */ && n.textContent?.trim())
const hasElementChildren = childNodes.some((n) => n.nodeType === 1 /* ELEMENT_NODE */)
const hasTextChildren = childNodes.some((n) => n.nodeType === 3 /* TEXT_NODE */ && n.textContent?.trim())
if (hasElementChildren && hasTextChildren) {
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