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'
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run format
echo '***************************************************'
echo '********************注意提交格式*******************'
echo '***************************************************'
......
......@@ -9,27 +9,31 @@
为了保证编辑器的高内聚、低耦合与易维护性,所有开发人员(及 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'], // 电路断路器行与四列子元素
TED: ['TOOLNAME', 'TOOLNBR'], // 工具名称与工具件号
CON: ['CONNAME', 'CONNBR'], // 消耗品名称与消耗品件号
GRAPHIC: ['TITLE'], // 附图与附图标题
EINDATA: ['EIN'] // 适用性组与具体功能号
TED: ['TOOLNAME', 'TOOLNBR'], // 工具名称与工具件号
CON: ['CONNAME', 'CONNBR'], // 消耗品名称与消耗品件号
GRAPHIC: ['TITLE'], // 附图与附图标题
EINDATA: ['EIN'] // 适用性组与具体功能号
}
```
#### 📌 渲染与解析规范:
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,57 +7,58 @@ 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) => {
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
useEventListener(
'keydown',
(e: KeyboardEvent) => {
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
// Ctrl+Shift+D: 切换深色/浅色主题
if (ctrl && shift && e.key.toLowerCase() === 'd') {
e.preventDefault()
appStore.isDark = !appStore.isDark
return
}
// Ctrl+Shift+D: 切换深色/浅色主题
if (ctrl && shift && e.key.toLowerCase() === 'd') {
e.preventDefault()
appStore.isDark = !appStore.isDark
return
}
const target = e.target as HTMLElement | null
const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
if (isInput) return
const target = e.target as HTMLElement | null
const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
if (isInput) return
if (ctrl) {
const key = e.key.toLowerCase()
if (key === 'z') {
e.preventDefault()
const activeEl = document.activeElement as HTMLElement | null
if (activeEl && activeEl.isContentEditable) {
activeEl.blur()
}
setTimeout(() => {
if (shift) {
editorStore.redo()
} else {
editorStore.undo()
if (ctrl) {
const key = e.key.toLowerCase()
if (key === 'z') {
e.preventDefault()
const activeEl = document.activeElement as HTMLElement | null
if (activeEl && activeEl.isContentEditable) {
activeEl.blur()
}
}, 0)
} else if (key === 'y') {
e.preventDefault()
const activeEl = document.activeElement as HTMLElement | null
if (activeEl && activeEl.isContentEditable) {
activeEl.blur()
setTimeout(() => {
if (shift) {
editorStore.redo()
} else {
editorStore.undo()
}
}, 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,在冒泡阶段响应)
......
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
......@@ -561,7 +561,7 @@ export const useEditorStore = defineStore('editor', {
if (id.includes('-txt-')) {
realId = id.split('-txt-')[0]
}
let curr = this.nodeMap.get(realId)
while (curr) {
if (nodeSelectedRefs.has(curr.node.id)) {
......@@ -570,7 +570,7 @@ export const useEditorStore = defineStore('editor', {
}
curr = curr.parent ? this.nodeMap.get(curr.parent.id) : undefined
}
if (!renderedId) {
renderedId = realId
}
......@@ -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)
if (idx !== -1) {
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('-')
}
......
......@@ -19,9 +19,12 @@ export function useFindReplace(
const matchItemRefs = ref<any[]>([])
// 每次匹配列表变化时重置 ref 数组
watch(() => matches.value, () => {
matchItemRefs.value = []
})
watch(
() => matches.value,
() => {
matchItemRefs.value = []
}
)
const scrollToActiveMatch = () => {
nextTick(() => {
......@@ -30,7 +33,7 @@ export function useFindReplace(
if (activeEl && containerEl) {
const activeRect = activeEl.getBoundingClientRect()
const containerRect = containerEl.getBoundingClientRect()
// 若高亮项已超出可视区域的上边缘或下边缘,则将其滚动入屏
if (activeRect.top < containerRect.top || activeRect.bottom > containerRect.bottom) {
activeEl.scrollIntoView({
......@@ -100,7 +103,7 @@ export function useFindReplace(
if (index < 0 || index >= matches.value.length) return
currentMatchIndex.value = index
const match = matches.value[index]
// 选中该节点并调用父级导出的平滑滚动方法
editorStore.setSelectedNodeId(match.nodeId)
props.syncEditorScroll(match.nodeId, true)
......@@ -129,14 +132,14 @@ export function useFindReplace(
const replaceCurrent = () => {
if (matches.value.length === 0 || currentMatchIndex.value === -1) return
const match = matches.value[currentMatchIndex.value]
const success = editorStore.replaceMatch(match, replaceQuery.value)
if (success) {
window.$message?.success('替换成功')
// 重新查找以保持精准的位置和剩余结果统计
const prevIndex = currentMatchIndex.value
doSearch()
// 如果仍然有匹配,定位到下一个或上一个
if (matches.value.length > 0) {
const nextIdx = Math.min(prevIndex, matches.value.length - 1)
......@@ -154,7 +157,7 @@ export function useFindReplace(
matchCase: matchCase.value,
regExp: regExp.value
})
if (count > 0) {
window.$message?.success(`成功替换了 ${count} 处匹配`)
matches.value = []
......@@ -173,7 +176,7 @@ export function useFindReplace(
const padding = isExpanded.value ? 35 : 15
const startOffset = Math.max(0, start - padding)
const endOffset = Math.min(text.length, start + len + padding)
let context = text.substring(startOffset, endOffset)
if (startOffset > 0) context = '...' + context
if (endOffset < text.length) context = context + '...'
......
......@@ -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,21 +4,21 @@
<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>
<!-- 列表项管理列表 -->
<div v-if="listItems.length > 0" class="space-y-3 max-w-4xl">
<div
v-for="(item, index) in listItems"
<div
v-for="(item, index) in listItems"
: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"
>
......@@ -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) => {
listItems.value = parseListItems(newVal)
}, { deep: true, immediate: true })
watch(
() => props.node,
(newVal) => {
listItems.value = parseListItems(newVal)
},
{ 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>
......@@ -28,7 +28,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const showTextContentField = computed(() => {
if (!form.tagName) return false
if (form.tagName === '#text') return true
const tagName = form.tagName
if (!tagName) return false
......@@ -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
}
......@@ -169,7 +179,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const isVirtualText = addNodeTargetId.value && addNodeTargetId.value.includes('-txt-')
let targetNode: XmlNode | null = null
if (isVirtualText) {
const realParentId = addNodeTargetId.value.split('-txt-')[0]
targetNode = store.nodeMap.get(realParentId)?.node ?? null
......@@ -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,10 +212,8 @@ 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) {
store.setSelectedNodeId(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}`)
......@@ -257,7 +269,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
destParent = store.nodeMap.get(addNodeTargetId.value)?.parent ?? null
}
if (!destParent) return
destParent.mixedContent = destParent.mixedContent || []
if (isVirtualText) {
const textIdx = parseInt(addNodeTargetId.value.split('-txt-')[1], 10)
......@@ -270,11 +282,9 @@ 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)
if (newTextIdx !== -1) {
store.setSelectedNodeId(`${destParent.id}-txt-${newTextIdx}`)
......@@ -299,7 +309,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
if (addNodeMode.value === 'child') {
newNode.parentId = targetNode.id
targetNode.children.push(newNode)
// 若父级支持混合内容,需要同步更新 mixedContent
if (isMixedContentElement(targetNode.tagName)) {
targetNode.mixedContent = targetNode.mixedContent || []
......@@ -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
})
}
}
}
......
......@@ -5,7 +5,7 @@ import { getMinChildCount } from '@/utils/dtdManager'
export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
const editorStore = useEditorStore()
const show = ref(false)
const blockedNodes = ref<BlockedNodeDetail[]>([])
const safeNodes = ref<SafeNodeItem[]>([])
......@@ -14,16 +14,16 @@ export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
const open = (selectedNodeIds: string[]) => {
const nodeMap = editorStore.nodeMap
const { safeIds, blocked } = partitionBatchDelete(selectedNodeIds, nodeMap)
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 || '未知'
const displayName = node ? getNodeDisplayName(node) : '未知节点'
const parentPath = item?.parent ? getNodeParentPath(item.parent.id, nodeMap) : ''
return {
id,
tagName,
......@@ -32,35 +32,35 @@ export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
checked: true // 默认全部勾选
}
})
show.value = true
}
// 计算全选状态
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"
......@@ -49,15 +41,20 @@
@change="handleSafeNodeChange"
/>
</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">
......@@ -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-success/80"></div>
</div>
<div class="p-6 font-mono text-sm leading-relaxed overflow-x-auto max-h-[500px] scrollbar-thin select-all">
<pre class="text-color1"><code class="xml-content-pre">{{ viewXmlContent }}</code></pre>
</div>
......@@ -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<{
width: number
collapsed: boolean
collapseThreshold?: number
defaultWidth?: number
maxRatio?: number
}>(), {
collapseThreshold: 150,
defaultWidth: 560,
maxRatio: 0.6
})
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>
......
......@@ -57,12 +57,12 @@ export interface BatchModalRef {
/** 上下文菜单所有批量操作的元数据映射 */
export const ACTION_META: Record<string, BatchActionMeta> = {
'row-above': { title: '在上方插入行', description: '在当前行上方插入多少行?', isDelete: false, count: 1 },
'row-below': { title: '在下方插入行', description: '在当前行下方插入多少行?', isDelete: false, count: 1 },
'row-append': { title: '在末尾追加行', description: '在表格末尾追加多少行?', isDelete: false, count: 1 },
'col-left': { title: '在左侧插入列', description: '在当前列左侧插入多少列?', isDelete: false, count: 1 },
'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 },
'row-above': { title: '在上方插入行', description: '在当前行上方插入多少行?', isDelete: false, count: 1 },
'row-below': { title: '在下方插入行', description: '在当前行下方插入多少行?', isDelete: false, count: 1 },
'row-append': { title: '在末尾追加行', description: '在表格末尾追加多少行?', isDelete: false, count: 1 },
'col-left': { title: '在左侧插入列', description: '在当前列左侧插入多少列?', isDelete: false, count: 1 },
'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 }
}
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
......@@ -397,7 +404,7 @@ export function useTableEditor(props: { node: XmlNode }) {
if (idx !== -1) {
const insertIdx = insertBelow ? idx + 1 : idx
sectionNode.children.splice(insertIdx, 0, newRow)
insertedRowIds.value.add(rowId)
setTimeout(() => {
insertedRowIds.value.delete(rowId)
......@@ -505,7 +512,7 @@ export function useTableEditor(props: { node: XmlNode }) {
}
}
}
// 将上方那个真正持有该物理节点的大单元格的 MOREROWS 扣减 1
if (foundSourceCellXml) {
const currentMorerows = parseInt(foundSourceCellXml.attributes.MOREROWS || '0', 10)
......@@ -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,13 +791,15 @@ export function useTableEditor(props: { node: XmlNode }) {
const deleteCellFromSection = (sec?: XmlNode) => {
if (!sec) return
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)
if (entryIdx !== -1) row.children.splice(entryIdx, 1)
}
})
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)
if (entryIdx !== -1) row.children.splice(entryIdx, 1)
}
})
}
deleteCellFromSection(thead)
......@@ -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>
import { useThemeVars } from 'naive-ui'
import { useEditorStore } from '@/store/editor'
import {
isMixedContentElement,
import {
isMixedContentElement,
getElementAttributes,
createDefaultAttributes,
getElementRule,
......@@ -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