Commit 77427bde by pangchong

feat(table): 支持表格列批量操作与跨列(Spanspec)智能管理

- 扩展批量操作弹窗,支持一次性新增或删除多行多列
- 新增列支持子段落标签初始化,提升单元格内容定制能力
- 增加相邻跨列(Spanspec)自动检测与合并选项,用户可选择是否合并
- 删除列时严格级联更新Colspec、Spanspec及Entry,保证表格的一致性和结构完整
- 精准物理删除被删除列的单列单元格,跨列单元格保留并自动收缩跨度
- 新增列及删除列均支持Colname和Colnum的自动重命名和映射更新
- 在新增列功能中,智能感知跨列边界,提供候选跨列合并列表及描述信息
- 右键删除列时,弹出含跨列删除警告的二次确认对话框
- 修正跨列属性的大小写处理,避免属性识别错误
- 支持表格列插入位置的智能计算,兼容合并单元格起始与终止列索引准确调整
- 对表格虚拟节点删除权限做限制,防止删除导致表格结构混乱
- 表格列宽调整相关文案及关键词进行了补充完善
- TableBatchModal组件宽度调整至440,新增跨列合并多选功能UI及相关交互逻辑
- 优化表格数据结构构建逻辑,新增列覆盖空缺列单元格占位,确保渲染完整
- 流程中涉及的映射表和索引计算均增强健壮性,防止列操作出错
parent a46d6f9f
...@@ -523,16 +523,19 @@ export const FAQ_DATABASE: FaqItem[] = [ ...@@ -523,16 +523,19 @@ export const FAQ_DATABASE: FaqItem[] = [
category: 'table', category: 'table',
categoryName: '表格编辑', categoryName: '表格编辑',
title: '如何使用批量操作弹窗一次性增删多行 / 多列?', title: '如何使用批量操作弹窗一次性增删多行 / 多列?',
keywords: ['批量增删', '批量加行', '批量加列', '批量删除行', '批量操作', 'tablebatch', 'pl', 'piliang'], keywords: ['批量增删', '批量加行', '批量加列', '批量删除行', '批量操作', 'tablebatch', 'spanspec', '跨列合并', 'pl', 'piliang', 'kl', 'kualie'],
synonyms: ['怎么一次加5行', '批量插入多列', '一次删多行', '表格批量管理'], synonyms: ['怎么一次加5行', '批量插入多列', '一次删多行', '表格批量管理', '加列合并跨列'],
answer: '通过 TableBatchModal 弹窗可以极其高效地批量操作大型表格:', answer: '通过 TableBatchModal 弹窗可以极其高效地批量操作大型表格,并支持跨列扩展与子节点初始化:',
steps: [ steps: [
'在单元格右键菜单中选择【批量操作】 $\\rightarrow$ 【批量插入/删除】。', '在单元格右键菜单中选择对应的行列操作(如【在左侧/右侧插入列】、【在上方/下方插入行】等)。',
'在弹出的批量操作窗口中,输入想要插入或删除的数量(如“插入 10 行”)。', '在弹出的批量操作窗口中,输入想要插入或删除的数量(支持批量操作 1~50 行/列)。',
'点击确认,系统瞬间在当前位置完成连续多行/多列的生成或清理。' '若为新增操作,可配置新单元格初始化的子段落标签(如 PARAC、PARA)。',
'若新增列与已有跨列(SPANSPEC)相邻,弹窗会自动检测并提供【合并到相邻跨列】选项,默认勾选,可由用户自主决定是否将新列纳入跨列范围。',
'点击确认后,系统自动同步更新 COLSPEC、SPANSPEC 以及各 ENTRY 的列名映射,并保证新增单元格严格对齐。'
], ],
highlights: ['系统智能感知 CALS 表格的省略列与跨列属性,保证新增列在各行中均精准定位不发生错位。'],
relatedQuestions: ['faq_table_row_col_ops', 'faq_table_cell_selection'], relatedQuestions: ['faq_table_row_col_ops', 'faq_table_cell_selection'],
contextTags: ['TABLE', 'ROW', 'ENTRY', 'TGROUP'] contextTags: ['TABLE', 'ROW', 'ENTRY', 'TGROUP', 'SPANSPEC', 'COLSPEC']
}, },
{ {
id: 'faq_table_cell_alignment', id: 'faq_table_cell_alignment',
...@@ -558,17 +561,19 @@ export const FAQ_DATABASE: FaqItem[] = [ ...@@ -558,17 +561,19 @@ export const FAQ_DATABASE: FaqItem[] = [
id: 'faq_table_colspec', id: 'faq_table_colspec',
category: 'table', category: 'table',
categoryName: '表格编辑', categoryName: '表格编辑',
title: '表格列宽调整与 COLSPEC 规范是如何工作的?', title: '表格列规范(COLSPEC)与删除列时的级联更新是如何工作的?',
keywords: ['列宽', 'colspec', 'colwidth', '列宽调整', '比例分配', '表格宽度', 'lk', 'liewan', 'kb', 'kuanbi'], keywords: ['列宽', 'colspec', 'colwidth', '删除列', '删除colspec', 'spanspec联动', '列宽调整', '比例分配', '表格宽度', 'lk', 'liewan', 'kb', 'kuanbi', 'scl', 'shanchulie'],
synonyms: ['怎么调列宽', '列宽太窄怎么变宽', '表格各列宽度比例', 'colspec是什么'], synonyms: ['怎么调列宽', '大纲树删除colspec', '删除列节点', '删除列后跨列怎么变', '表格各列宽度比例', 'colspec是什么'],
answer: '航空 CALS 表格通过 `<COLSPEC>` 标签定义每列的名称(colname)与宽度比例(colwidth):', answer: '航空 CALS 表格通过 `<COLSPEC>` 标签定义每列的名称(colname)与宽度比例(colwidth),在调整或删除列时具备完善的级联保护:',
steps: [ steps: [
'系统在生成表格时,根据列数自动计算等宽或标准比例(如 `1*`, `2*` 或具体毫米/英寸)。', '【列宽配置】:系统在生成表格时自动计算等宽或标准比例(如 `1*`, `2*` 或具体数值)。选中 COLSPEC 节点可在属性抽屉直接微调 `colwidth`。',
'在右侧属性面板中选中 COLSPEC 节点,可直接修改其 `colwidth` 属性值。', '【大纲树与右键删除列】:无论在大纲树右键删除 `<COLSPEC>` 节点还是在表格内删除列,系统均会触发严密的级联更新。',
'渲染器会自动根据列宽权重进行弹性伸缩排版,在打印与 PDF 导出时保持完美比例。' '【跨列(SPANSPEC)自动收缩】:删除列时,系统智能识别被删列相对于各跨列定义的位置,自动收缩 `NAMEST` 与 `NAMEEND` 范围;若退化为单列则安全销毁并解绑单元格。',
], '【单元格精准物理删除】:各行仅物理删除覆盖该列的单列单元格,跨列单元格保留并自动收缩跨度,剩余单元格列名重新顺序编号。'
relatedQuestions: ['faq_table_row_col_ops', 'faq_table_alignment'], ],
contextTags: ['COLSPEC', 'TGROUP', 'TABLE'] highlights: ['表格必须保留至少一列,当只剩一列时大纲树右键菜单自动置灰禁用删除;删除时会弹出二次确认警示。'],
relatedQuestions: ['faq_table_row_col_ops', 'faq_table_batch_ops', 'faq_table_alignment'],
contextTags: ['COLSPEC', 'SPANSPEC', 'TGROUP', 'TABLE', 'ENTRY']
}, },
{ {
id: 'faq_table_complex_cells', id: 'faq_table_complex_cells',
......
...@@ -1115,6 +1115,10 @@ export function useNodeTree( ...@@ -1115,6 +1115,10 @@ export function useNodeTree(
isVirtual || VIRTUAL_LAYOUT_TAGS.includes(node.tagName) isVirtual || VIRTUAL_LAYOUT_TAGS.includes(node.tagName)
? true ? true
: (() => { : (() => {
if (node.tagName === 'COLSPEC' && parent.tagName === 'TGROUP') {
const cols = parent.children.filter((c) => c.tagName === 'COLSPEC')
if (cols.length <= 1) return false
}
const existingTags = parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName) const existingTags = parent.children.filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName)).map((c) => c.tagName)
// 位置特许规则:父级中最后一个子节点(按位置)允许删除 // 位置特许规则:父级中最后一个子节点(按位置)允许删除
if (existingTags.length > 0 && existingTags[existingTags.length - 1] === node.tagName) { if (existingTags.length > 0 && existingTags[existingTags.length - 1] === node.tagName) {
...@@ -1445,9 +1449,18 @@ export function useNodeTree( ...@@ -1445,9 +1449,18 @@ export function useNodeTree(
} }
try { try {
const desc = isVirtual ? '此文本节点' : `节点 <${node.tagName}>` const desc = isVirtual ? '此文本节点' : `节点 <${node.tagName}>`
let content = `确定要删除${desc}吗?该操作将连带删除其所有子节点,且不可撤销!`
if (node.tagName === 'COLSPEC') {
const colName = node.attributes.COLNAME || node.attributes.colname || ''
const colNum = node.attributes.COLNUM || node.attributes.colnum || ''
const colLabel = colNum ? `第 ${colNum} 列` : (colName ? `列 ${colName}` : '该列')
content = `确定要删除 ${colLabel} (<COLSPEC>) 吗?该操作将连带删除表格中该列的所有单元格,并自动更新跨列定义,且不可撤销!`
}
await window.$dialog.warning({ await window.$dialog.warning({
title: '确认删除', title: '确认删除',
content: `确定要删除${desc}吗?该操作将连带删除其所有子节点,且不可撤销!` content
}) })
editorStore.deleteNode(nodeId) editorStore.deleteNode(nodeId)
window.$message?.success('删除成功') window.$message?.success('删除成功')
......
/** 相邻可合并跨列选项数据结构 */
export interface SpanCandidateItem {
spanId: string
spanName: string
type: 'right' | 'left'
minOldIdx: number
maxOldIdx: number
rawNs: string
rawNe: string
currentRangeText?: string
mergedRangeText?: string
}
/** 批量操作弹框的属性类型 */ /** 批量操作弹框的属性类型 */
export interface TableBatchModalProps { export interface TableBatchModalProps {
/** 弹框标题 */ /** 弹框标题 */
...@@ -8,7 +21,21 @@ export interface TableBatchModalProps { ...@@ -8,7 +21,21 @@ export interface TableBatchModalProps {
count: number count: number
/** 是否为删除类操作(控制 max 上限) */ /** 是否为删除类操作(控制 max 上限) */
isDelete: boolean isDelete: boolean
/** 相邻可合并的跨列(SPANSPEC)候选列表 */
spanCandidates?: SpanCandidateItem[]
} }
/** 批量操作弹框确认事件类型 */ /** 批量操作弹框确认事件类型 */
export type ConfirmEmit = (event: 'confirm', count: number, action: string, cellChildTags: string[]) => void export type ConfirmEmit = (
event: 'confirm',
count: number,
action: string,
cellChildTags: string[],
selectedSpanIds?: string[]
) => void
/** 单元格初始化段落节点选项 */
export const CELL_CHILD_OPTIONS = [
{ label: 'PARAC (中文)', value: 'PARAC' },
{ label: 'PARA (英文)', value: 'PARA' }
]
import type { FormInst } from 'naive-ui' import type { FormInst } from 'naive-ui'
import type { TableBatchModalProps, ConfirmEmit } from '../constants' import type { TableBatchModalProps, ConfirmEmit, SpanCandidateItem } from '../constants'
import { CELL_CHILD_OPTIONS } from '../constants'
/** /**
* TableBatchModal 组件内部状态与逻辑 Hook * TableBatchModal 组件内部状态与逻辑 Hook
* 管理弹框的显示、数量输入以及确认事件的派发 * 管理弹框的显示、数量输入、跨列选择以及确认事件的派发
*/ */
export function useTableBatchModal(emit: ConfirmEmit, formRef: Ref<FormInst | null>) { export function useTableBatchModal(emit: ConfirmEmit, formRef?: Ref<FormInst | null>) {
const internalFormRef = formRef || ref<FormInst | null>(null)
const show = ref(false) const show = ref(false)
const title = ref('') const title = ref('')
const description = ref('') const description = ref('')
const isDelete = ref(false) const isDelete = ref(false)
const pendingAction = ref('') const pendingAction = ref('')
const spanCandidates = ref<SpanCandidateItem[]>([])
const form = reactive({ const form = reactive({
count: 1 as number | null, count: 1 as number | null,
cellChildTags: [] as string[] cellChildTags: [] as string[],
selectedSpanIds: [] as string[]
}) })
const rules = { const rules = {
...@@ -42,20 +46,72 @@ export function useTableBatchModal(emit: ConfirmEmit, formRef: Ref<FormInst | nu ...@@ -42,20 +46,72 @@ export function useTableBatchModal(emit: ConfirmEmit, formRef: Ref<FormInst | nu
form.count = meta.count form.count = meta.count
form.cellChildTags = [] form.cellChildTags = []
pendingAction.value = meta.action pendingAction.value = meta.action
// 初始化 SPANSPEC 候选列表,默认全部勾选合并
spanCandidates.value = meta.spanCandidates || []
form.selectedSpanIds = (meta.spanCandidates || []).map((s) => s.spanId)
show.value = true show.value = true
} }
/** 切换特定跨列选项的勾选状态 */
const toggleSpanSelection = (spanId: string, checked: boolean) => {
if (checked) {
if (!form.selectedSpanIds.includes(spanId)) {
form.selectedSpanIds.push(spanId)
}
} else {
form.selectedSpanIds = form.selectedSpanIds.filter((id) => id !== spanId)
}
}
/** 动态根据当前输入的数量计算合并后的跨列范围描述 */
const getMergedRangeDesc = (item: SpanCandidateItem) => {
const count = typeof form.count === 'number' && form.count > 0 ? form.count : 1
const startCol = item.minOldIdx + 1
const endCol = item.maxOldIdx + 1 + count
return `col${startCol} ~ col${endCol}`
}
/** 获取原跨列的基准范围显示描述 */
const getRangeDesc = (item: SpanCandidateItem) => {
const origStart = item.minOldIdx + 1
const origEnd = item.maxOldIdx + 1
return `col${origStart} ~ col${origEnd}`
}
const handleConfirm = async () => { const handleConfirm = async () => {
try { try {
await formRef.value?.validate() await internalFormRef.value?.validate()
} catch { } catch {
return return
} }
if (form.count !== null) { if (form.count !== null) {
emit('confirm', form.count, pendingAction.value, form.cellChildTags) emit(
'confirm',
form.count,
pendingAction.value,
form.cellChildTags,
form.selectedSpanIds
)
show.value = false show.value = false
} }
} }
return { show, title, description, isDelete, form, rules, open, handleConfirm } return {
formRef: internalFormRef,
show,
title,
description,
isDelete,
form,
rules,
spanCandidates,
cellChildOptions: CELL_CHILD_OPTIONS,
open,
handleConfirm,
toggleSpanSelection,
getMergedRangeDesc,
getRangeDesc
}
} }
<template> <template>
<CommonModal v-model="show" :title="title" :width="360" @confirm="handleConfirm"> <CommonModal v-model="show" :title="title" :width="440" @confirm="handleConfirm">
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top"> <n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<div class="flex flex-col gap-4 py-2"> <div class="flex flex-col gap-4 py-2">
<n-form-item :label="description" path="count"> <n-form-item :label="description" path="count">
...@@ -19,6 +19,28 @@ ...@@ -19,6 +19,28 @@
<CommonCheckbox v-model:value="form.cellChildTags" :options="cellChildOptions" :space-size="24" /> <CommonCheckbox v-model:value="form.cellChildTags" :options="cellChildOptions" :space-size="24" />
</div> </div>
</n-form-item> </n-form-item>
<!-- 相邻跨列(SPANSPEC)合并选项:仅在有相邻跨列定义时展示 -->
<n-form-item v-if="spanCandidates.length > 0" label="合并到相邻跨列 (SPANSPEC)" path="selectedSpanIds">
<div class="w-full bg-fill-2 p-3 rounded border border-divider flex flex-col gap-2.5">
<div class="text-[11px] text-color3">
检测到新增列与以下跨列相邻,默认已勾选合并到跨列范围:
</div>
<div v-for="item in spanCandidates" :key="item.spanId" class="flex items-start">
<CommonCheckboxSingle
:checked="form.selectedSpanIds.includes(item.spanId)"
@update:checked="(val: boolean) => toggleSpanSelection(item.spanId, val)"
>
<div class="inline-flex flex-col ml-1 select-none">
<span class="text-xs font-bold text-color1">跨列「{{ item.spanName }}</span>
<span class="text-[11px] text-color3 mt-0.5">
范围:{{ getRangeDesc(item) }} ➔ 扩展后:{{ getMergedRangeDesc(item) }}
</span>
</div>
</CommonCheckboxSingle>
</div>
</div>
</n-form-item>
</template> </template>
</div> </div>
</n-form> </n-form>
...@@ -26,22 +48,27 @@ ...@@ -26,22 +48,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { FormInst } from 'naive-ui' import type { ConfirmEmit } from './constants'
import { useTableBatchModal } from './functionals' import { useTableBatchModal } from './functionals'
import CommonCheckbox from '@/components/CommonCheckbox.vue' const emit = defineEmits<ConfirmEmit>()
const emit = defineEmits<{
confirm: [count: number, action: string, cellChildTags: string[]]
}>()
const cellChildOptions = [
{ label: 'PARAC (中文)', value: 'PARAC' },
{ label: 'PARA (英文)', value: 'PARA' }
]
const formRef = ref<FormInst | null>(null) const {
const { show, title, description, isDelete, form, rules, open, handleConfirm } = useTableBatchModal(emit, formRef) formRef,
show,
title,
description,
isDelete,
form,
rules,
spanCandidates,
cellChildOptions,
open,
handleConfirm,
toggleSpanSelection,
getMergedRangeDesc,
getRangeDesc
} = useTableBatchModal(emit)
defineExpose({ open }) defineExpose({ open })
</script> </script>
......
...@@ -52,7 +52,7 @@ export interface BatchActionMeta { ...@@ -52,7 +52,7 @@ export interface BatchActionMeta {
/** 批量操作弹框组件对外暴露的接口(避免循环依赖) */ /** 批量操作弹框组件对外暴露的接口(避免循环依赖) */
export interface BatchModalRef { export interface BatchModalRef {
open: (meta: BatchActionMeta & { action: string }) => void open: (meta: BatchActionMeta & { action: string; spanCandidates?: any[] }) => void
} }
/** 上下文菜单所有批量操作的元数据映射 */ /** 上下文菜单所有批量操作的元数据映射 */
......
...@@ -422,7 +422,8 @@ const { ...@@ -422,7 +422,8 @@ const {
tableFrameClass, tableFrameClass,
getCellStyle, getCellStyle,
handleResizeStart, handleResizeStart,
executeMergeAction executeMergeAction,
getSpanCandidates
} = useTableEditor(props, { mergeModalRef }) } = useTableEditor(props, { mergeModalRef })
const selectNode = (id: string, forceScroll = false) => { const selectNode = (id: string, forceScroll = false) => {
...@@ -449,7 +450,8 @@ const { onContextMenuSelect, executeBatchAction } = useTableBatchActions({ ...@@ -449,7 +450,8 @@ const { onContextMenuSelect, executeBatchAction } = useTableBatchActions({
batchDeleteColumns, batchDeleteColumns,
contextMenu, contextMenu,
selectedCellIds, selectedCellIds,
closeContextMenu closeContextMenu,
getSpanCandidates
}) })
</script> </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