Commit fa73e172 by pangchong

feat(editor): 新增选项组功能支持

- 在编辑器中新增“选项组(SELECTION)”及其子项节点结构支持
- 添加插入选项组弹窗组件(CreateSelectionModal)及功能实现
- 实现选项组多选/单选、排列方式及必填配置选项
- 新增 CommonRadioSingle 组件,用于单个单选框显示及绑定
- 优化 DocNodeRenderer,支持渲染选项组及其子项和标签
- 允许列表子项配置单个时隐藏序号,支持个性化定制
- 将 API 校验逻辑支持按定制目录动态加载覆盖
- 添加 bx 定制目录及相关接口配置和工具函数
- 删除无用全量工卡 HTML 资源,清理旧文件
- 优化图片组件 img 标签宽度样式,避免样式失效
- 修改查找替换功能匹配去重逻辑,改为保留全部匹配
- 丰富签字渲染组件,增加权限许可显示及多种角色支持
parent 1e4a02d2
# 自定义定制代码目录
VITE_CUSTOM_CODE = bx
# 后端 API 目标服务基准地址
VITE_API_URL = https://amro-vue.anyremote.cn
# 接口前缀路径
VITE_API_PREFIX_URL = /api/v1
# 开发环境下默认启用本地开发桥接服务 (true 使用本地接口直接请求/模拟,false 降级使用父项目传递的 promise)
VITE_IF_LOCAL_BRIDGE = true
\ No newline at end of file
# 自定义定制代码目录 (例如 'xm')
# 自定义定制代码目录
VITE_CUSTOM_CODE = xm
# 后端 API 目标服务基准地址
VITE_API_URL = https://amro-vue.anyremote.cn
......
# 自定义定制代码目录 (例如 'xm')
# 自定义定制代码目录
VITE_CUSTOM_CODE = xm
# 后端 API 目标服务基准地址
VITE_API_URL = https://amro-vue.anyremote.cn
......
......@@ -215,6 +215,9 @@
- **多选与单选复选框**:在所有业务页面中,**禁用** Naive UI 原生的 `<n-checkbox>` `<n-checkbox-group>`。根据不同场景选用以下封装组件:
1. **多选复选框组**:使用 **`CommonCheckbox`** 替代 `n-checkbox-group` 及原生的 `v-for` 循环列表。
2. **单体复选框(布尔值/独立开关)**:使用 **`CommonCheckboxSingle`** 替代单个 `<n-checkbox>`,可通过 `checked-value``unchecked-value` 属性直接映射所需的任意选中值类型(如字符 `'Y'`/`'N'` 等),并支持 `strict` 属性进行非严格模式下的类型自动转换比对。
- **单选框与单选框组**:在所有业务页面中,**禁用** Naive UI 原生的 `<n-radio>` `<n-radio-group>`。根据不同场景选用以下封装组件:
1. **单选框组**:使用 **`CommonRadio`** 替代 `n-radio-group` 及原生的 `v-for` 循环列表。
2. **单体单选框(布尔值/独立开关/选项)**:使用 **`CommonRadioSingle`** 替代单个 `<n-radio>`,使用方法与 `CommonCheckboxSingle` 类似,支持 `checked-value``unchecked-value` 属性和 `strict` 属性。
- **状态标签**:使用 **`CommonTag`** 替代 `n-tag`。支持插槽、字典配置及 autoColor 自适应着色。
- **禁用冗余的 placeholder 占位符**
- 在编写 `CommonSelect` `n-input` 组件时,对于普通属性、枚举选择或内容输入,**禁止**手动添加 `placeholder="请选择"``placeholder="选择"` `placeholder="请输入"` 等冗余的占位字符。
......
......@@ -6,8 +6,10 @@
"scripts": {
"dev": "vite --mode dev",
"dev:xm": "vite --mode xm",
"dev:bx": "vite --mode bx",
"build": "vue-tsc -b && vite build --mode dev",
"build:xm": "vue-tsc -b && vite build --mode xm",
"build:bx": "vue-tsc -b && vite build --mode bx",
"preview": "vite preview",
"prepare": "husky install",
"commitlint": "commitlint --config commitlint.config.cjs -e -V",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"elements": {
"SIGNOFF": {
"attributes": {
"CK-LEVEL": {
"typeDefinition": "(A | B | C | M | M(N/A) | T | R | SP | SP(AM))",
"enumValues": ["A", "B", "C", "M", "M(N/A)", "T", "R", "SP", "SP(AM)"]
}
}
}
}
}
<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">
<template v-for="(col, index) in columns" :key="index">
<!-- 角色标签栏 -->
<td class="px-2 border border-black font-bold text-center w-24">
{{ col.label }}
<br />
{{ col.subLabel }}
</td>
<!-- 签名/状态栏 -->
<td class="px-2 border border-black text-center w-32 font-mono">
<span v-if="col.isNA" style="color: red" class="font-bold">N/A</span>
<span v-else-if="col.badge" class="font-bold text-sm bg-gray-100 px-2 py-0.5 rounded border border-gray-300">{{ col.badge }}</span>
<span v-else>{{ col.value }}</span>
</td>
</template>
</tr>
<!-- 第二行:签字时间 (如果任何一栏有时间,就渲染时间行) -->
<tr v-if="hasTime" class="h-5 text-[10px] text-center font-mono">
<template v-for="(col, index) in columns" :key="'time-' + index">
<td colspan="2" class="border border-black px-1">
{{ col.time || '' }}
</td>
</template>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { XmlNode } from '@/types/xmlNode'
const props = defineProps<{
node: XmlNode
}>()
// 签字栏靠左或靠右显示样式,由 LOCATION 属性控制
const containerClass = computed(() => {
const location = props.node.attributes.LOCATION || 'RIGHT'
return location === 'LEFT' ? 'justify-start' : 'justify-end'
})
const ckLevel = computed(() => props.node.attributes['CK-LEVEL'] || 'A')
// 判定是否为“权限许可”类型的定制签字级别 (排除 A, B, C, D, E 等常规 1 或 2 栏签字)
const isPermissionLevel = computed(() => {
return !['A', 'B', 'C', 'D', 'E'].includes(ckLevel.value)
})
// 正则动态提取权限缩写代码,如从 "M(N/A)" 提取为 "M","SP(AM)" 提取为 "SP"
const permissionCode = computed(() => {
return ckLevel.value.replace(/[((].*?[))]/g, '').trim()
})
const columns = computed(() => {
const list = []
const levelVal = ckLevel.value
// 1. 检查是否需要“权限许可”栏
if (isPermissionLevel.value) {
list.push({
label: '权限许可',
subLabel: 'Permission',
badge: permissionCode.value,
value: '',
time: ''
})
}
// 2. 工作者栏 (常规存在)
list.push({
label: '工作者',
subLabel: 'Mechanic',
value: props.node.attributes.mech ? `${props.node.attributes.mech} ${props.node.attributes.mechName || ''}` : '',
time: props.node.attributes.disStime || '',
isNA: props.node.attributes.ACTION === 'NA'
})
// 3. 检查者/必检/特定检验员栏
if (levelVal === 'B') {
list.push({
label: '检验员',
subLabel: 'Inspector',
value: props.node.attributes.insp ? `${props.node.attributes.insp} ${props.node.attributes.inspName || ''}` : '',
time: props.node.attributes.SUP_DIS_STIME || props.node.attributes.disStime || '',
isNA: props.node.attributes.ACTION === 'NA'
})
} else if (levelVal === 'C') {
list.push({
label: '必检',
subLabel: 'Inspected',
value: props.node.attributes.verf
? `${props.node.attributes.verf} ${props.node.attributes.verfName || ''}`
: props.node.attributes.insp
? `${props.node.attributes.insp} ${props.node.attributes.inspName || ''}`
: '',
time: props.node.attributes.SUP_DIS_STIME || props.node.attributes.disStime || '',
isNA: props.node.attributes.ACTION === 'NA'
})
} else if (isPermissionLevel.value) {
let label = '检验员'
let subLabel = 'Inspector'
// 支持动态识别级别名称中带 N/A 或 na 字符代表免检
const isNA = levelVal.toUpperCase().includes('N/A') || props.node.attributes.ACTION === 'NA'
const code = permissionCode.value
if (code === 'SP') {
if (levelVal.toUpperCase().includes('AM')) {
label = '航材检验员'
subLabel = 'AM Inspector'
} else {
label = 'SP检验员'
subLabel = 'SP Inspector'
}
}
list.push({
label,
subLabel,
value: isNA && levelVal.toUpperCase().includes('N/A') ? '' : (props.node.attributes.insp ? `${props.node.attributes.insp} ${props.node.attributes.inspName || ''}` : ''),
time: props.node.attributes.SUP_DIS_STIME || props.node.attributes.disStime || '',
isNA: isNA
})
}
return list
})
// 判断是否存在任何签字时间
const hasTime = computed(() => {
return columns.value.some(col => col.time)
})
</script>
export const apiConfig = {
// bx 环境校验接口响应是否成功,成功 code 为 0
isSuccess: (res: any) => {
return res && (res.code === 0 || res.code === '0')
}
}
/**
* bx 环境下的个性化定制配置
*/
// 列表中仅有一个子项时,不显示序号
export const SHOW_SINGLE_CHILD_INDEX = false
export interface TemplateItem {
id: number
name: string
creator: string
createTime: string
content: string
}
export interface TemplateResponse {
code: number | string
msg?: string
total: number
data: TemplateItem[]
}
// 本地接口的具体实现
const localBridge = {
getTemplateList: async (page: number, rows: number): Promise<any> => {
return await apiService.post<any>('/plugins/EM_JOBCARD_GX_TEMPLATE_SELECT_PAGE', {
page,
rows
})
},
saveTemplate: async (name: string, content: string): Promise<any> => {
return await apiService.post<any>('/plugins/EM_JOBCARD_GX_TEMPLATE_SAVE', {
name,
content
})
},
uploadImage: async (file: File): Promise<any> => {
return await apiService.postForm<any>('/plugins/TD_JC_CEP_UPLOAD_IMG', {
file,
jcpkid: '13054174'
})
},
getEditorData: async (): Promise<string> => {
const res = await apiService.postForm<any>('/plugins/GET_JCCEPFILE_TO_ONLINE_EDITOR', {
jcpkid: '13054174'
})
if (typeof res === 'string') {
return res
}
if (res && typeof res === 'object') {
if (typeof res.data === 'string') {
return res.data
}
if (res.data && typeof res.data.content === 'string') {
return res.data.content
}
}
return ''
}
}
/**
* 获取模板列表
*/
export const getTemplateList = execute('GET_TEMPLATE_LIST', localBridge.getTemplateList, (page, rows) => ({ page, rows }))
/**
* 保存模板
*/
export const saveTemplate = execute('SAVE_AS_TEMPLATE', localBridge.saveTemplate, (name, content) => ({ content }))
/**
* 上传工卡图片资源
*/
export const uploadImage = execute('UPLOAD_EDITOR_FILE', localBridge.uploadImage, (file) => ({ file }))
/**
* 获取工卡 XML 数据
*/
export const getEditorData = execute('GET_EDITOR_DATA', localBridge.getEditorData, () => ({}))
<template>
<n-radio :checked="computedChecked" :disabled="disabled" v-bind="$attrs" @update:checked="handleUpdateChecked">
<slot>{{ label }}</slot>
</n-radio>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
/** 绑定的值 (如 v-model) */
checked: {
type: [Boolean, String, Number],
default: undefined
},
value: {
type: [Boolean, String, Number],
default: undefined
},
modelValue: {
type: [Boolean, String, Number],
default: undefined
},
/** 选中状态对应的值,默认是 true */
checkedValue: {
type: [Boolean, String, Number],
default: true
},
/** 未选中状态对应的值,默认是 false */
uncheckedValue: {
type: [Boolean, String, Number],
default: false
},
/** 严格模式:默认不开启(不开启时自动转字符串比对,不区分 1 和 '1') */
strict: {
type: Boolean,
default: false
},
/** 禁用状态 */
disabled: {
type: Boolean,
default: false
},
/** 显示的文本标签 */
label: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:checked', 'update:value', 'update:modelValue', 'change'])
/**
* 值的标准化处理
*/
const normalizeValue = (v: any) => {
if (v === null || v === undefined || v === '') return null
return props.strict ? v : String(v)
}
/**
* 计算单选框是否被勾选
*/
const computedChecked = computed(() => {
const val = props.modelValue !== undefined ? props.modelValue : props.value !== undefined ? props.value : props.checked
if (val === undefined || val === null) {
return false
}
return normalizeValue(val) === normalizeValue(props.checkedValue)
})
/**
* 状态更新处理函数
*/
const handleUpdateChecked = (isCurrentlyChecked: boolean) => {
const newValue = isCurrentlyChecked ? props.checkedValue : props.uncheckedValue
emit('update:checked', isCurrentlyChecked)
emit('update:value', newValue)
emit('update:modelValue', newValue)
emit('change', newValue)
}
</script>
<style scoped></style>
// 动态加载所有定制目录下的 api.ts 配置文件
const customConfigs = import.meta.glob<any>('../**/config/api.ts', { eager: true })
const customCode = import.meta.env.VITE_CUSTOM_CODE
const customPath = customCode ? `../${customCode}/config/api.ts` : ''
const customConfig = customPath ? customConfigs[customPath] : null
export const apiConfig = {
// 校验接口响应是否成功
isSuccess: (res: any) => {
if (customConfig && customConfig.apiConfig && customConfig.apiConfig.isSuccess) {
return customConfig.apiConfig.isSuccess(res)
}
// 默认情况下 code 为 200 或 0 时均可认为成功
return res && (res.code === 200 || res.code === 0 || res.code === '200' || res.code === '0')
}
}
// 动态加载所有定制目录(如 bx、xm 等)下的 index.ts 配置文件
const customConfigs = import.meta.glob<any>('../**/config/index.ts', { eager: true })
const customCode = import.meta.env.VITE_CUSTOM_CODE
const customPath = customCode ? `../${customCode}/config/index.ts` : ''
const customConfig = customPath ? (customConfigs[customPath]?.default || customConfigs[customPath]) : null
/**
* 列表只有一个子项时是否显示序号。
* 默认是要显示的 (true)。各环境可以通过在其目录下的 config/index.ts 中重新定义此属性覆盖默认值。
*/
export const SHOW_SINGLE_CHILD_INDEX = customConfig && typeof customConfig.SHOW_SINGLE_CHILD_INDEX !== 'undefined'
? customConfig.SHOW_SINGLE_CHILD_INDEX
: true
......@@ -252,26 +252,66 @@ const createDefaultNodeStructure = (tagName: string): XmlNode => {
}
if (tagName === 'SELECTION') {
const item1Id = crypto.randomUUID()
const item2Id = crypto.randomUUID()
return {
id,
tagName: 'SELECTION',
attributes: { ...attributes },
children: [
{
id: crypto.randomUUID(),
tagName: 'SELECT-ITEM',
attributes: { VALUE: 'yes' },
children: [],
textContent: '是 (Yes)',
id: item1Id,
tagName: 'SELECTION-ITEM',
attributes: {},
children: [
{
id: crypto.randomUUID(),
tagName: 'SELECTION-LBL-CN',
attributes: {},
children: [],
textContent: '是',
mixedContent: [],
parentId: item1Id
},
{
id: crypto.randomUUID(),
tagName: 'SELECTION-LBL-EN',
attributes: {},
children: [],
textContent: 'Yes',
mixedContent: [],
parentId: item1Id
}
],
textContent: '',
mixedContent: [],
parentId: id
},
{
id: crypto.randomUUID(),
tagName: 'SELECT-ITEM',
attributes: { VALUE: 'no' },
children: [],
textContent: '否 (No)',
id: item2Id,
tagName: 'SELECTION-ITEM',
attributes: {},
children: [
{
id: crypto.randomUUID(),
tagName: 'SELECTION-LBL-CN',
attributes: {},
children: [],
textContent: '否',
mixedContent: [],
parentId: item2Id
},
{
id: crypto.randomUUID(),
tagName: 'SELECTION-LBL-EN',
attributes: {},
children: [],
textContent: 'No',
mixedContent: [],
parentId: item2Id
}
],
textContent: '',
mixedContent: [],
parentId: id
}
......@@ -310,8 +350,6 @@ const createDefaultNodeStructure = (tagName: string): XmlNode => {
}
let defaultText = ''
if (tagName === 'DATE') defaultText = new Date().toISOString().split('T')[0]
if (tagName === 'UNIT-RECORD') defaultText = '测量值:____ 毫米'
return {
id,
......
......@@ -3,6 +3,8 @@ import { useEditorStore, nodeSelectedRefs } from '@/store/editor'
import { LIST_ITEM_TAGS, HEADER_TAGS, ROMAN_LOOKUP } from '../constants'
import { ALERT_AND_EFF_TAGS, ALERT_BLOCK_TAGS, COMPOSITE_CONTAINER_MAP, INLINE_ELEMENTS } from '@/configs/xmlTags'
import { useI18n } from 'vue-i18n'
import { SHOW_SINGLE_CHILD_INDEX } from '@/configs'
// 警示块级节点集合(WARNING / CAUTION / NOTE),用于注入 insideAlert 上下文
const ALERT_ROOT_TAGS = new Set(ALERT_BLOCK_TAGS)
......@@ -58,6 +60,12 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
const renderInline = computed(() => {
if (props.node.tagName === 'TXTGRPHC') return false
if (props.node.tagName === 'SELECTION-ITEM') {
return !(props.parent && props.parent.tagName === 'SELECTION' && props.parent.attributes.TYPE === 'LIST')
}
if (props.node.tagName === 'SELECTION-LBL-CN' || props.node.tagName === 'SELECTION-LBL-EN') {
return true
}
return props.isInline || INLINE_ELEMENTS_SET.has(props.node.tagName)
})
......@@ -69,6 +77,7 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
props.node.children.length > 0 &&
props.node.tagName !== 'TABLE' &&
props.node.tagName !== 'SELECTION' &&
props.node.tagName !== 'SELECTION-ITEM' &&
props.node.tagName !== 'TOPIC' &&
props.node.tagName !== 'PRETOPIC' &&
props.node.tagName !== 'CBLST' &&
......@@ -174,6 +183,15 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
// 列表标号与数字格式化
const getListBullet = (node: XmlNode): string => {
// 若配置为单子项不显示序号,且父级容器下同类型子项数量仅为 1,则不显示序号
const isOrderedList = ['L1ITEM', 'L2ITEM', 'L3ITEM', 'L4ITEM', 'L5ITEM', 'L6ITEM', 'L7ITEM', 'NUMLITEM'].includes(node.tagName)
if (!SHOW_SINGLE_CHILD_INDEX && isOrderedList && props.parent) {
const siblings = props.parent.children.filter((c) => c.tagName === node.tagName)
if (siblings.length === 1) {
return ''
}
}
if (node.tagName === 'UNLITEM') {
const parent = props.parent
const bullType = parent?.attributes?.BULLTYPE
......
......@@ -700,7 +700,11 @@
v-if="!node.children || node.children.length === 0 || getSplitListChildren(node.children).normals.length > 0"
class="flex items-baseline space-x-2 my-1.5 pl-4"
>
<span class="text-sm font-bold select-none shrink-0 w-6 text-right" :class="[isInsideAlert ? 'text-inherit' : 'text-color1']">
<span
v-if="getListBullet(node)"
class="text-sm font-bold select-none shrink-0 w-6 text-right"
:class="[isInsideAlert ? 'text-inherit' : 'text-color1']"
>
{{ getListBullet(node) }}
</span>
<div class="flex-1 min-w-0">
......@@ -791,7 +795,13 @@
<!-- 如果是真实图片地址,则展示真实图片 -->
<template v-if="getImgSrc(node.attributes.GNBR || '')">
<div class="w-full rounded border border-divider bg-fill-3 flex items-center justify-center p-2 shadow-inner">
<n-image :src="getImgSrc(node.attributes.GNBR || '')" class="w-full rounded" style="width: 100%" alt="图纸页" />
<n-image
:src="getImgSrc(node.attributes.GNBR || '')"
class="w-full rounded"
style="width: 100%"
:img-props="{ style: 'width: 100%' }"
alt="图纸页"
/>
</div>
</template>
<!-- 否则显示拟物化卡片模拟设计图 -->
......@@ -935,27 +945,94 @@
<!-- 22. SELECTION (选项组) 处理 -->
<template v-else-if="node.tagName === 'SELECTION'">
<div class="my-3 p-3 bg-fill-3 rounded-lg border border-divider space-y-2">
<div class="text-xs font-bold text-color3 mb-1 select-none print-hide">选项组配置:</div>
<div class="flex flex-wrap gap-4">
<div
v-for="item in node.children"
:key="item.id"
class="flex items-center space-x-2 bg-fill-1 px-3 py-1.5 rounded border border-divider shadow-sm hover:border-primary transition-all"
>
<CommonCheckboxSingle :checked="false" disabled />
<span
contenteditable="true"
class="text-xs text-color2 focus:outline-none focus:bg-fill-3 px-1 rounded"
@blur="(e) => handleChildTextBlur(item.id, e)"
@keydown.enter.prevent
v-text="item.textContent"
></span>
</div>
<div class="my-2" :class="[node.attributes.TYPE === 'LIST' ? 'block space-y-2' : 'inline-flex flex-wrap items-center gap-4']">
<div v-if="node.attributes.TAG" class="text-xs font-bold text-color3 select-none print-hide mr-1">
{{ node.attributes.TAG.endsWith(':') || node.attributes.TAG.endsWith(':') ? node.attributes.TAG : node.attributes.TAG + ':' }}
</div>
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" />
<span
v-if="node.attributes.TYPE !== 'LIST' && (node.attributes.MANDATORY === 'Y' || !node.attributes.MANDATORY)"
class="text-red-500 font-bold ml-1"
>
*
</span>
</div>
</template>
<!-- 22.2 SELECTION-ITEM (选项) 处理 -->
<template v-else-if="node.tagName === 'SELECTION-ITEM'">
<div
v-if="parent && parent.attributes.TYPE === 'LIST'"
class="flex items-center space-x-2 bg-fill-1 px-3 py-1.5 rounded border border-divider shadow-sm hover:border-primary transition-all w-full"
>
<CommonCheckboxSingle
v-if="parent && (parent.attributes.MULTI === 'Y' || parent.attributes.MULTI === 'YES')"
:checked="false"
disabled
class="shrink-0"
/>
<CommonRadioSingle v-else :checked="false" disabled class="shrink-0" />
<div class="flex-1 flex items-center space-x-2">
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" />
</div>
<span
v-if="
parent &&
(parent.attributes.MANDATORY === 'Y' || !parent.attributes.MANDATORY) &&
parent.children[parent.children.length - 1].id === node.id
"
class="text-red-500 font-bold ml-1"
>
*
</span>
</div>
<div
v-else
class="inline-flex items-center space-x-2 bg-fill-1 px-3 py-1.5 rounded border border-divider shadow-sm hover:border-primary transition-all"
>
<template v-for="child in node.children" :key="child.id">
<DocNodeRenderer
v-if="child.tagName === 'SELECTION-LBL-CN' || child.tagName === 'SELECTION-LBL-EN'"
:node="child"
:parent="node"
/>
</template>
<CommonCheckboxSingle v-if="parent && parent.attributes.MULTI === 'Y'" :checked="false" disabled class="shrink-0" />
<CommonRadioSingle v-else :checked="false" disabled class="shrink-0" />
<template v-for="child in node.children" :key="child.id">
<DocNodeRenderer
v-if="child.tagName !== 'SELECTION-LBL-CN' && child.tagName !== 'SELECTION-LBL-EN'"
:node="child"
:parent="node"
/>
</template>
</div>
</template>
<!-- 22.5 SELECTION-LBL-CN (中文标签) 处理 -->
<template v-else-if="node.tagName === 'SELECTION-LBL-CN'">
<span
contenteditable="true"
class="text-xs text-color2 focus:outline-none focus:bg-fill-3 px-0.5 rounded transition-all font-semibold"
@blur="handleTextBlur"
@keydown.enter.prevent
v-text="node.textContent"
></span>
</template>
<!-- 22.6 SELECTION-LBL-EN (英文标签) 处理 -->
<template v-else-if="node.tagName === 'SELECTION-LBL-EN'">
<span
contenteditable="true"
class="text-xs text-color3 focus:outline-none focus:bg-fill-3 px-0.5 rounded transition-all italic font-semibold"
@blur="handleTextBlur"
@keydown.enter.prevent
v-text="node.textContent"
></span>
</template>
<!-- 23. SIGNOFF (签字点) 处理 (仿照 PDF 签字表) -->
<template v-else-if="node.tagName === 'SIGNOFF'">
<component v-if="CustomSignoff" :is="CustomSignoff" :node="node" />
......@@ -1350,4 +1427,7 @@ const {
font-family: 'NSimSun', '新宋体', 'SimSun', '宋体', monospace !important;
white-space: pre !important;
}
:deep(.n-image img) {
width: 100%;
}
</style>
......@@ -75,20 +75,10 @@ export function useFindReplace(
regExp: regExp.value
})
// 💡 过滤同一个节点内的重复匹配:仅保留每个节点的第一次匹配,以此作为查找替换的独立项
const uniqueResults: typeof results = []
const seenIds = new Set<string>()
results.forEach((res) => {
if (!seenIds.has(res.nodeId)) {
seenIds.add(res.nodeId)
uniqueResults.push(res)
}
})
matches.value = uniqueResults
if (uniqueResults.length > 0) {
matches.value = results
if (results.length > 0) {
// 如果旧的索引有效,保持接近 index,否则从 0 开始
if (currentMatchIndex.value < 0 || currentMatchIndex.value >= uniqueResults.length) {
if (currentMatchIndex.value < 0 || currentMatchIndex.value >= results.length) {
currentMatchIndex.value = 0
}
// 自动聚焦到首个匹配点
......
import type { FormRules } from 'naive-ui'
/**
* 插入选项组表单校验规则
*/
export const SELECTION_FORM_RULES: FormRules = {
tag: [{ required: false, trigger: 'blur' }]
}
import type { FormInst } from 'naive-ui'
import { SELECTION_FORM_RULES } from '../constants'
export function useCreateSelectionModal(emit: (event: 'confirm', tag: string, multi: string, type: string, mandatory: string) => void) {
const show = ref(false)
const formRef = ref<FormInst | null>(null)
const form = reactive({
tag: '',
multi: 'N',
type: 'INLINE',
mandatory: 'Y'
})
const open = () => {
form.tag = ''
form.multi = 'N'
form.type = 'INLINE'
form.mandatory = 'Y'
show.value = true
}
const handleConfirm = async () => {
try {
await formRef.value?.validate()
emit('confirm', form.tag, form.multi, form.type, form.mandatory)
show.value = false
} catch (err) {
// validation failed
}
}
return {
show,
formRef,
form,
rules: SELECTION_FORM_RULES,
open,
handleConfirm
}
}
<template>
<CommonModal v-model="show" title="插入选项组" :width="450" @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="标题 (TAG)" path="tag">
<n-input v-model:value="form.tag" />
</n-form-item>
<n-form-item label="单选/多选 (MULTI)" path="multi">
<CommonRadio
v-model:value="form.multi"
:options="[
{ label: '单选 (Radio)', value: 'N' },
{ label: '多选 (Checkbox)', value: 'Y' }
]"
/>
</n-form-item>
<n-form-item label="排列方式 (TYPE)" path="type">
<CommonRadio
v-model:value="form.type"
:options="[
{ label: '横向排列 (INLINE)', value: 'INLINE' },
{ label: '纵向列表 (LIST)', value: 'LIST' }
]"
/>
</n-form-item>
<n-form-item label="是否必填 (MANDATORY)" path="mandatory">
<CommonRadio
v-model:value="form.mandatory"
:options="[
{ label: '必填 (显示 * 号)', value: 'Y' },
{ label: '非必填', value: 'N' }
]"
/>
</n-form-item>
</div>
</n-form>
</CommonModal>
</template>
<script setup lang="ts">
import { useCreateSelectionModal } from './functionals'
const emit = defineEmits<{
confirm: [tag: string, multi: string, type: string, mandatory: string]
}>()
const { show, formRef, form, rules, open, handleConfirm } = useCreateSelectionModal(emit)
defineExpose({ open })
</script>
import { useEditorStore } from '@/store/editor'
import { getTemplateList } from '@/utils/bridge'
import type { TemplateItem } from '../constants'
import { apiConfig } from '@/xm/config/api'
import { apiConfig } from '@/configs/api'
export function useTemplateSelectModal() {
const editorStore = useEditorStore()
......
import { ImageOutline, GridOutline, DocumentTextOutline, CreateOutline, RemoveOutline } from '@vicons/ionicons5'
import { ImageOutline, GridOutline, DocumentTextOutline, CreateOutline, RemoveOutline, ListOutline } from '@vicons/ionicons5'
/**
* EditorToolbar 组件级静态常量
......@@ -10,5 +10,7 @@ export const GREEN_BUTTONS: any[] = [
{ label: '插入表格', tag: 'TABLE', icon: GridOutline },
{ label: '插入模板', tag: 'TEMPLATE', icon: DocumentTextOutline },
{ label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline },
{ label: '插入选项组', tag: 'SELECTION', icon: ListOutline },
{ label: '插入下划线', tag: 'RECORD-LINE', icon: RemoveOutline }
]
......@@ -15,6 +15,7 @@ export function useEditorToolbar(emit: any) {
const isUploading = ref(false)
const createTableModalRef = ref<any>(null)
const createSignoffModalRef = ref<any>(null)
const createSelectionModalRef = ref<any>(null)
const templateSelectModalRef = ref<any>(null)
const createGraphicModalRef = ref<any>(null)
......@@ -63,6 +64,8 @@ export function useEditorToolbar(emit: any) {
createTableModalRef.value?.open()
} else if (activeTag === 'SIGNOFF') {
createSignoffModalRef.value?.open()
} else if (activeTag === 'SELECTION') {
createSelectionModalRef.value?.open()
} else if (tag === 'GRAPHIC') {
// 如果 activeTag 为 SHEET,说明在 GRAPHIC 内部或 SHEET 兄弟级插入单个 SHEET;否则插入完整 GRAPHIC
createGraphicModalRef.value?.open(activeTag === 'SHEET')
......@@ -86,6 +89,85 @@ export function useEditorToolbar(emit: any) {
editorStore.insertNode('SIGNOFF', insertBelow.value, signoffNode)
}
const handleCreateSelectionConfirm = (tag: string, multi: string, type: string, mandatory: string) => {
const selectionId = crypto.randomUUID()
const item1Id = crypto.randomUUID()
const item2Id = crypto.randomUUID()
const selectionNode: XmlNode = {
id: selectionId,
tagName: 'SELECTION',
attributes: {
TAG: tag,
MULTI: multi,
TYPE: type,
MANDATORY: mandatory
},
children: [
{
id: item1Id,
tagName: 'SELECTION-ITEM',
attributes: {},
children: [
{
id: crypto.randomUUID(),
tagName: 'SELECTION-LBL-CN',
attributes: {},
children: [],
textContent: '选项1',
mixedContent: [],
parentId: item1Id
},
{
id: crypto.randomUUID(),
tagName: 'SELECTION-LBL-EN',
attributes: {},
children: [],
textContent: 'Option 1',
mixedContent: [],
parentId: item1Id
}
],
textContent: '',
mixedContent: [],
parentId: selectionId
},
{
id: item2Id,
tagName: 'SELECTION-ITEM',
attributes: {},
children: [
{
id: crypto.randomUUID(),
tagName: 'SELECTION-LBL-CN',
attributes: {},
children: [],
textContent: '选项2',
mixedContent: [],
parentId: item2Id
},
{
id: crypto.randomUUID(),
tagName: 'SELECTION-LBL-EN',
attributes: {},
children: [],
textContent: 'Option 2',
mixedContent: [],
parentId: item2Id
}
],
textContent: '',
mixedContent: [],
parentId: selectionId
}
],
textContent: '',
mixedContent: [],
parentId: undefined as any
}
editorStore.insertNode('SELECTION', insertBelow.value, selectionNode)
}
const handleCreateGraphicConfirm = (node: XmlNode) => {
editorStore.insertNode(node.tagName, insertBelow.value, node)
}
......@@ -171,6 +253,8 @@ export function useEditorToolbar(emit: any) {
handleCreateTableConfirm,
createSignoffModalRef,
handleCreateSignoffConfirm,
createSelectionModalRef,
handleCreateSelectionConfirm,
createGraphicModalRef,
handleCreateGraphicConfirm,
batchTranslateModalRef,
......
......@@ -227,6 +227,9 @@
<!-- 插入签字点弹窗 -->
<CreateSignoffModal ref="createSignoffModalRef" @confirm="handleCreateSignoffConfirm" />
<!-- 插入选项组弹窗 -->
<CreateSelectionModal ref="createSelectionModalRef" @confirm="handleCreateSelectionConfirm" />
<!-- 插入图片弹窗 -->
<CreateGraphicModal ref="createGraphicModalRef" @confirm="handleCreateGraphicConfirm" />
......@@ -271,6 +274,7 @@ 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 CreateSelectionModal from './components/CreateSelectionModal/index.vue'
import CreateGraphicModal from './components/CreateGraphicModal/index.vue'
import BatchTranslateModal from './components/BatchTranslateModal/index.vue'
import ExtractTranslateModal from './components/ExtractTranslateModal/index.vue'
......@@ -295,6 +299,8 @@ const {
handleCreateTableConfirm,
createSignoffModalRef,
handleCreateSignoffConfirm,
createSelectionModalRef,
handleCreateSelectionConfirm,
createGraphicModalRef,
handleCreateGraphicConfirm,
batchTranslateModalRef,
......
/**
* xm 环境下的个性化定制配置
*/
// 列表中仅有一个子项时,默认显示序号
export const SHOW_SINGLE_CHILD_INDEX = true
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