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[] = [
category: 'table',
categoryName: '表格编辑',
title: '如何使用批量操作弹窗一次性增删多行 / 多列?',
keywords: ['批量增删', '批量加行', '批量加列', '批量删除行', '批量操作', 'tablebatch', 'pl', 'piliang'],
synonyms: ['怎么一次加5行', '批量插入多列', '一次删多行', '表格批量管理'],
answer: '通过 TableBatchModal 弹窗可以极其高效地批量操作大型表格:',
keywords: ['批量增删', '批量加行', '批量加列', '批量删除行', '批量操作', 'tablebatch', 'spanspec', '跨列合并', 'pl', 'piliang', 'kl', 'kualie'],
synonyms: ['怎么一次加5行', '批量插入多列', '一次删多行', '表格批量管理', '加列合并跨列'],
answer: '通过 TableBatchModal 弹窗可以极其高效地批量操作大型表格,并支持跨列扩展与子节点初始化:',
steps: [
'在单元格右键菜单中选择【批量操作】 $\\rightarrow$ 【批量插入/删除】。',
'在弹出的批量操作窗口中,输入想要插入或删除的数量(如“插入 10 行”)。',
'点击确认,系统瞬间在当前位置完成连续多行/多列的生成或清理。'
'在单元格右键菜单中选择对应的行列操作(如【在左侧/右侧插入列】、【在上方/下方插入行】等)。',
'在弹出的批量操作窗口中,输入想要插入或删除的数量(支持批量操作 1~50 行/列)。',
'若为新增操作,可配置新单元格初始化的子段落标签(如 PARAC、PARA)。',
'若新增列与已有跨列(SPANSPEC)相邻,弹窗会自动检测并提供【合并到相邻跨列】选项,默认勾选,可由用户自主决定是否将新列纳入跨列范围。',
'点击确认后,系统自动同步更新 COLSPEC、SPANSPEC 以及各 ENTRY 的列名映射,并保证新增单元格严格对齐。'
],
highlights: ['系统智能感知 CALS 表格的省略列与跨列属性,保证新增列在各行中均精准定位不发生错位。'],
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',
......@@ -558,17 +561,19 @@ export const FAQ_DATABASE: FaqItem[] = [
id: 'faq_table_colspec',
category: 'table',
categoryName: '表格编辑',
title: '表格列宽调整与 COLSPEC 规范是如何工作的?',
keywords: ['列宽', 'colspec', 'colwidth', '列宽调整', '比例分配', '表格宽度', 'lk', 'liewan', 'kb', 'kuanbi'],
synonyms: ['怎么调列宽', '列宽太窄怎么变宽', '表格各列宽度比例', 'colspec是什么'],
answer: '航空 CALS 表格通过 `<COLSPEC>` 标签定义每列的名称(colname)与宽度比例(colwidth):',
steps: [
'系统在生成表格时,根据列数自动计算等宽或标准比例(如 `1*`, `2*` 或具体毫米/英寸)。',
'在右侧属性面板中选中 COLSPEC 节点,可直接修改其 `colwidth` 属性值。',
'渲染器会自动根据列宽权重进行弹性伸缩排版,在打印与 PDF 导出时保持完美比例。'
],
relatedQuestions: ['faq_table_row_col_ops', 'faq_table_alignment'],
contextTags: ['COLSPEC', 'TGROUP', 'TABLE']
title: '表格列规范(COLSPEC)与删除列时的级联更新是如何工作的?',
keywords: ['列宽', 'colspec', 'colwidth', '删除列', '删除colspec', 'spanspec联动', '列宽调整', '比例分配', '表格宽度', 'lk', 'liewan', 'kb', 'kuanbi', 'scl', 'shanchulie'],
synonyms: ['怎么调列宽', '大纲树删除colspec', '删除列节点', '删除列后跨列怎么变', '表格各列宽度比例', 'colspec是什么'],
answer: '航空 CALS 表格通过 `<COLSPEC>` 标签定义每列的名称(colname)与宽度比例(colwidth),在调整或删除列时具备完善的级联保护:',
steps: [
'【列宽配置】:系统在生成表格时自动计算等宽或标准比例(如 `1*`, `2*` 或具体数值)。选中 COLSPEC 节点可在属性抽屉直接微调 `colwidth`。',
'【大纲树与右键删除列】:无论在大纲树右键删除 `<COLSPEC>` 节点还是在表格内删除列,系统均会触发严密的级联更新。',
'【跨列(SPANSPEC)自动收缩】:删除列时,系统智能识别被删列相对于各跨列定义的位置,自动收缩 `NAMEST` 与 `NAMEEND` 范围;若退化为单列则安全销毁并解绑单元格。',
'【单元格精准物理删除】:各行仅物理删除覆盖该列的单列单元格,跨列单元格保留并自动收缩跨度,剩余单元格列名重新顺序编号。'
],
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',
......
......@@ -1115,7 +1115,7 @@ export const useEditorStore = defineStore('editor', {
const tgroup = parent
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
const currentCols = parseInt(tgroup.attributes.COLS || '0', 10)
const currentCols = parseInt(tgroup.attributes.COLS || '0', 10) || colSpecs.length
if (currentCols <= 1 || colSpecs.length <= 1) {
window.$message.warning('表格必须保留至少一列')
return
......@@ -1124,70 +1124,267 @@ export const useEditorStore = defineStore('editor', {
const colIndex = colSpecs.findIndex((c) => c.id === realId)
if (colIndex === -1) return
// 更新 COLS 属性
tgroup.attributes.COLS = (currentCols - 1).toString()
// 1. 建立「旧列名 / 旧索引」完整映射(不区分大小写)
const oldNameToIdx = new Map<string, number>()
colSpecs.forEach((spec, idx) => {
const name = (spec.attributes.COLNAME || spec.attributes.colname || '').trim().toLowerCase()
if (name) oldNameToIdx.set(name, idx)
const num = (spec.attributes.COLNUM || spec.attributes.colnum || '').trim().toLowerCase()
if (num) oldNameToIdx.set(num, idx)
oldNameToIdx.set(`col${idx + 1}`, idx)
})
const parseColIdx = (nameOrNum?: string): number | undefined => {
if (!nameOrNum) return undefined
const clean = nameOrNum.trim().toLowerCase()
if (oldNameToIdx.has(clean)) return oldNameToIdx.get(clean)
const match = clean.match(/^col(\d+)$/i)
if (match) {
const parsed = parseInt(match[1], 10) - 1
if (parsed >= 0 && parsed < colSpecs.length) return parsed
}
const numParsed = parseInt(clean, 10) - 1
if (!isNaN(numParsed) && numParsed >= 0 && numParsed < colSpecs.length) return numParsed
return undefined
}
// 预先建立旧 SPANSPEC 的跨列区间表: spanName (lowercase) -> { startIdx, endIdx }
const oldSpanspecMap = new Map<string, { startIdx: number; endIdx: number }>()
tgroup.children
.filter((c) => c.tagName === 'SPANSPEC')
.forEach((span) => {
const spanName = (span.attributes.SPANNAME || span.attributes.spanname || '').trim().toLowerCase()
const st = span.attributes.NAMEST || span.attributes.namest || ''
const ed = span.attributes.NAMEEND || span.attributes.nameend || ''
const sIdx = parseColIdx(st)
const eIdx = parseColIdx(ed)
if (spanName && sIdx !== undefined && eIdx !== undefined) {
oldSpanspecMap.set(spanName, {
startIdx: Math.min(sIdx, eIdx),
endIdx: Math.max(sIdx, eIdx)
})
}
})
// 删除该 COLSPEC 节点
// 2. 更新 TGROUP 的 COLS 属性
tgroup.attributes.COLS = (colSpecs.length - 1).toString()
// 3. 删除目标 COLSPEC 节点
const idx = tgroup.children.findIndex((c) => c.id === realId)
if (idx !== -1) tgroup.children.splice(idx, 1)
// 重新排列剩余 COLSPEC 的 COLNAME 和 COLNUM
// 4. 重新排列剩余 COLSPEC 的 COLNAME 和 COLNUM
const remainingColSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
remainingColSpecs.forEach((spec, index) => {
spec.attributes.COLNAME = `col${index + 1}`
spec.attributes.COLNUM = (index + 1).toString()
delete spec.attributes.colname
delete spec.attributes.colnum
})
// 在 rows 中删除对应的 ENTRY 单元格,并调整其余 entry 的 NAMEST 和 NAMEEND 属性
/**
* 计算列名在删除 colIndex 后的新列名
*/
const getNewColName = (oldName: string): string | null => {
const oldIdx = parseColIdx(oldName)
if (oldIdx === undefined) return null
if (oldIdx === colIndex) return null // 该列已被删除
const newIdx = oldIdx > colIndex ? oldIdx - 1 : oldIdx
return `col${newIdx + 1}`
}
// 5. 级联更新及清理 SPANSPEC 跨列节点
const degradedSpanNames = new Set<string>()
const currentSpans = tgroup.children.filter((c) => c.tagName === 'SPANSPEC')
for (let i = currentSpans.length - 1; i >= 0; i--) {
const span = currentSpans[i]
const spanName = (span.attributes.SPANNAME || span.attributes.spanname || '').trim()
const spanNameLower = spanName.toLowerCase()
const oldSpan = oldSpanspecMap.get(spanNameLower)
if (!oldSpan) continue
const sIdx = oldSpan.startIdx
const eIdx = oldSpan.endIdx
let newSIdx: number
let newEIdx: number
if (colIndex < sIdx) {
newSIdx = sIdx - 1
newEIdx = eIdx - 1
} else if (colIndex > eIdx) {
newSIdx = sIdx
newEIdx = eIdx
} else if (colIndex === sIdx) {
newSIdx = colIndex
newEIdx = eIdx - 1
} else if (colIndex === eIdx) {
newSIdx = sIdx
newEIdx = colIndex - 1
} else {
newSIdx = sIdx
newEIdx = eIdx - 1
}
if (newSIdx < newEIdx && newSIdx >= 0 && newEIdx < remainingColSpecs.length) {
span.attributes.NAMEST = `col${newSIdx + 1}`
span.attributes.NAMEEND = `col${newEIdx + 1}`
delete span.attributes.namest
delete span.attributes.nameend
} else {
degradedSpanNames.add(spanNameLower)
const sIdxInGroup = tgroup.children.findIndex((c) => c.id === span.id)
if (sIdxInGroup !== -1) {
tgroup.children.splice(sIdxInGroup, 1)
}
}
}
// 6. 在 THEAD 和 TBODY 各行中精准物理删除被删列单元格并更新属性
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
const deleteCellFromRows = (sectionNode?: XmlNode) => {
const processSectionRows = (sectionNode?: XmlNode) => {
if (!sectionNode) return
sectionNode.children
.filter((c) => c.tagName === 'ROW')
.forEach((row) => {
const entries = row.children.filter((c) => c.tagName === 'ENTRY')
if (entries[colIndex]) {
const cellId = entries[colIndex].id
const entryIdx = row.children.findIndex((c) => c.id === cellId)
if (entryIdx !== -1) row.children.splice(entryIdx, 1)
const rowEntries = row.children.filter((c) => c.tagName === 'ENTRY')
// 6.1 计算每个 entry 在该行实际覆盖的旧列范围 [startCol, endCol]
let currentCalculatedCol = 0
const entryRanges: Array<{
entry: XmlNode
startCol: number
endCol: number
}> = []
rowEntries.forEach((entry) => {
const spanRef = (entry.attributes.SPANNAME || entry.attributes.spanname || '').trim().toLowerCase()
const namest = entry.attributes.NAMEST || entry.attributes.namest
const nameend = entry.attributes.NAMEEND || entry.attributes.nameend
const colname = entry.attributes.COLNAME || entry.attributes.colname
const colnum = entry.attributes.COLNUM || entry.attributes.colnum
let s = currentCalculatedCol
let e = currentCalculatedCol
if (spanRef && oldSpanspecMap.has(spanRef)) {
const sp = oldSpanspecMap.get(spanRef)!
s = sp.startIdx
e = sp.endIdx
} else if (namest && nameend) {
const parsedS = parseColIdx(namest)
const parsedE = parseColIdx(nameend)
if (parsedS !== undefined && parsedE !== undefined) {
s = Math.min(parsedS, parsedE)
e = Math.max(parsedS, parsedE)
}
} else if (colname) {
const parsedC = parseColIdx(colname)
if (parsedC !== undefined) {
s = parsedC
e = parsedC
}
} else if (colnum) {
const parsedNum = parseColIdx(colnum)
if (parsedNum !== undefined) {
s = parsedNum
e = parsedNum
}
}
entryRanges.push({ entry, startCol: s, endCol: e })
currentCalculatedCol = e + 1
})
// 6.2 物理删除属于被删除列的单列单元格(跨列单元格保留,后续收缩)
for (let i = entryRanges.length - 1; i >= 0; i--) {
const { entry, startCol, endCol } = entryRanges[i]
if (startCol === colIndex && endCol === colIndex) {
const entryIdx = row.children.findIndex((c) => c.id === entry.id)
if (entryIdx !== -1) {
row.children.splice(entryIdx, 1)
}
}
}
// 6.3 调整剩余 entry 的跨列属性与列名
row.children
.filter((c) => c.tagName === 'ENTRY')
.forEach((entry) => {
const namest = entry.attributes.NAMEST
const nameend = entry.attributes.NAMEEND
if (namest && nameend) {
const sColIdx = colSpecs.findIndex((s) => s.attributes.COLNAME === namest)
const eColIdx = colSpecs.findIndex((s) => s.attributes.COLNAME === nameend)
if (sColIdx !== -1 && eColIdx !== -1) {
let newSColIdx = sColIdx
let newEColIdx = eColIdx
if (sColIdx > colIndex) {
newSColIdx = sColIdx - 1
}
if (eColIdx >= colIndex) {
newEColIdx = eColIdx - 1
// 处理退化 SPANSPEC 的引用
const spanRef = (entry.attributes.SPANNAME || entry.attributes.spanname || '').trim().toLowerCase()
if (spanRef && degradedSpanNames.has(spanRef)) {
delete entry.attributes.SPANNAME
delete entry.attributes.spanname
}
// 处理行内 NAMEST / NAMEEND 直接跨列
const ns = entry.attributes.NAMEST || entry.attributes.namest
const ne = entry.attributes.NAMEEND || entry.attributes.nameend
if (ns && ne) {
const sOldIdx = parseColIdx(ns)
const eOldIdx = parseColIdx(ne)
if (sOldIdx !== undefined && eOldIdx !== undefined) {
const s = Math.min(sOldIdx, eOldIdx)
const e = Math.max(sOldIdx, eOldIdx)
let newS: number
let newE: number
if (colIndex < s) {
newS = s - 1
newE = e - 1
} else if (colIndex > e) {
newS = s
newE = e
} else if (colIndex === s) {
newS = colIndex
newE = e - 1
} else if (colIndex === e) {
newS = s
newE = colIndex - 1
} else {
newS = s
newE = e - 1
}
if (newSColIdx < newEColIdx) {
entry.attributes.NAMEST = `col${newSColIdx + 1}`
entry.attributes.NAMEEND = `col${newEColIdx + 1}`
if (newS < newE && newS >= 0 && newE < remainingColSpecs.length) {
entry.attributes.NAMEST = `col${newS + 1}`
entry.attributes.NAMEEND = `col${newE + 1}`
delete entry.attributes.namest
delete entry.attributes.nameend
} else {
delete entry.attributes.NAMEST
delete entry.attributes.NAMEEND
delete entry.attributes.namest
delete entry.attributes.nameend
if (newS >= 0 && newS < remainingColSpecs.length) {
entry.attributes.COLNAME = `col${newS + 1}`
}
}
}
}
// 处理普通 COLNAME
const cn = entry.attributes.COLNAME || entry.attributes.colname
if (cn) {
const newCn = getNewColName(cn)
if (newCn) {
entry.attributes.COLNAME = newCn
delete entry.attributes.colname
} else {
delete entry.attributes.COLNAME
delete entry.attributes.colname
}
}
})
})
}
deleteCellFromRows(thead)
deleteCellFromRows(tbody)
processSectionRows(thead)
processSectionRows(tbody)
if (realId === this.selectedNodeId || `${realId}-txt-` === this.selectedNodeId) {
let nextSelectedId = tgroup.id
......@@ -1212,7 +1409,7 @@ export const useEditorStore = defineStore('editor', {
if (!parent || parent.tagName !== 'TGROUP') return
const tgroup = parent
const spanName = item.node.attributes.SPANNAME || item.node.attributes.spanname || ''
const spanName = (item.node.attributes.SPANNAME || item.node.attributes.spanname || '').trim()
const namest = item.node.attributes.NAMEST || item.node.attributes.namest || ''
const nameend = item.node.attributes.NAMEEND || item.node.attributes.nameend || ''
......@@ -1231,8 +1428,8 @@ export const useEditorStore = defineStore('editor', {
row.children
.filter((c) => c.tagName === 'ENTRY')
.forEach((entry) => {
const refSpanName = entry.attributes.SPANNAME || entry.attributes.spanname || ''
if (refSpanName === spanName) {
const refSpanName = (entry.attributes.SPANNAME || entry.attributes.spanname || '').trim()
if (refSpanName.toLowerCase() === spanName.toLowerCase()) {
delete entry.attributes.SPANNAME
delete entry.attributes.spanname
if (namest && nameend) {
......
......@@ -1115,6 +1115,10 @@ export function useNodeTree(
isVirtual || VIRTUAL_LAYOUT_TAGS.includes(node.tagName)
? 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)
// 位置特许规则:父级中最后一个子节点(按位置)允许删除
if (existingTags.length > 0 && existingTags[existingTags.length - 1] === node.tagName) {
......@@ -1445,9 +1449,18 @@ export function useNodeTree(
}
try {
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({
title: '确认删除',
content: `确定要删除${desc}吗?该操作将连带删除其所有子节点,且不可撤销!`
content
})
editorStore.deleteNode(nodeId)
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 {
/** 弹框标题 */
......@@ -8,7 +21,21 @@ export interface TableBatchModalProps {
count: number
/** 是否为删除类操作(控制 max 上限) */
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 { TableBatchModalProps, ConfirmEmit } from '../constants'
import type { TableBatchModalProps, ConfirmEmit, SpanCandidateItem } from '../constants'
import { CELL_CHILD_OPTIONS } from '../constants'
/**
* 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 title = ref('')
const description = ref('')
const isDelete = ref(false)
const pendingAction = ref('')
const spanCandidates = ref<SpanCandidateItem[]>([])
const form = reactive({
count: 1 as number | null,
cellChildTags: [] as string[]
cellChildTags: [] as string[],
selectedSpanIds: [] as string[]
})
const rules = {
......@@ -42,20 +46,72 @@ export function useTableBatchModal(emit: ConfirmEmit, formRef: Ref<FormInst | nu
form.count = meta.count
form.cellChildTags = []
pendingAction.value = meta.action
// 初始化 SPANSPEC 候选列表,默认全部勾选合并
spanCandidates.value = meta.spanCandidates || []
form.selectedSpanIds = (meta.spanCandidates || []).map((s) => s.spanId)
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 () => {
try {
await formRef.value?.validate()
await internalFormRef.value?.validate()
} catch {
return
}
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
}
}
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>
<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">
<div class="flex flex-col gap-4 py-2">
<n-form-item :label="description" path="count">
......@@ -19,6 +19,28 @@
<CommonCheckbox v-model:value="form.cellChildTags" :options="cellChildOptions" :space-size="24" />
</div>
</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>
</div>
</n-form>
......@@ -26,22 +48,27 @@
</template>
<script setup lang="ts">
import type { FormInst } from 'naive-ui'
import type { ConfirmEmit } from './constants'
import { useTableBatchModal } from './functionals'
import CommonCheckbox from '@/components/CommonCheckbox.vue'
const emit = defineEmits<{
confirm: [count: number, action: string, cellChildTags: string[]]
}>()
const cellChildOptions = [
{ label: 'PARAC (中文)', value: 'PARAC' },
{ label: 'PARA (英文)', value: 'PARA' }
]
const emit = defineEmits<ConfirmEmit>()
const formRef = ref<FormInst | null>(null)
const { show, title, description, isDelete, form, rules, open, handleConfirm } = useTableBatchModal(emit, formRef)
const {
formRef,
show,
title,
description,
isDelete,
form,
rules,
spanCandidates,
cellChildOptions,
open,
handleConfirm,
toggleSpanSelection,
getMergedRangeDesc,
getRangeDesc
} = useTableBatchModal(emit)
defineExpose({ open })
</script>
......
......@@ -52,7 +52,7 @@ export interface BatchActionMeta {
/** 批量操作弹框组件对外暴露的接口(避免循环依赖) */
export interface BatchModalRef {
open: (meta: BatchActionMeta & { action: string }) => void
open: (meta: BatchActionMeta & { action: string; spanCandidates?: any[] }) => void
}
/** 上下文菜单所有批量操作的元数据映射 */
......
......@@ -2,6 +2,7 @@ import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app'
import type { XmlNode } from '@/types/xmlNode'
import type { TableCellModel, TableRowModel, TableStructureModel, BatchModalRef, BatchActionMeta, CellParagraphModel } from '../constants'
import type { SpanCandidateItem } from '../components/TableBatchModal/constants'
import type { MergeContentItem, TableMergeModalOpenParams } from '../components/TableMergeModal/constants'
import { ACTION_META } from '../constants'
import { COMPLEX_ENTRY_TAGS, PARA_TAGS, CHINESE_FIRST_PARA_TAGS } from '@/configs/xmlTags'
......@@ -197,7 +198,7 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
colIdx: cIdx,
rowIdx: rIdx,
shouldRender: true,
isDummy: false
isDummy: true
}
occupied[rIdx][cIdx] = newCell
resultRows[rIdx].cells.push(newCell)
......@@ -209,6 +210,7 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
const namest = (entryNode.attributes.NAMEST || entryNode.attributes.namest || '').toLowerCase()
const nameend = (entryNode.attributes.NAMEEND || entryNode.attributes.nameend || '').toLowerCase()
const spanname = (entryNode.attributes.SPANNAME || entryNode.attributes.spanname || '').toLowerCase()
const colname = (entryNode.attributes.COLNAME || entryNode.attributes.colname || '').toLowerCase()
let startCol = cIdx
let endCol = cIdx
......@@ -228,6 +230,37 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
startCol = Math.min(sIdx, eIdx)
endCol = Math.max(sIdx, eIdx)
}
} else if (colname && colSpecMap.has(colname)) {
startCol = colSpecMap.get(colname)!
endCol = startCol
} else {
const colnumStr = entryNode.attributes.COLNUM || entryNode.attributes.colnum
if (colnumStr) {
const colnum = parseInt(colnumStr, 10)
if (!isNaN(colnum) && colnum >= 1 && colnum <= cols) {
startCol = colnum - 1
endCol = startCol
}
}
}
// 如果该 entry 指定的目标起始列在当前列之后,说明当前列在此行中被缺省省略了
if (startCol > cIdx) {
const cellId = crypto.randomUUID()
const placeholderCell: TableCellModel = {
id: cellId,
attributes: {},
paragraphs: [{ id: cellId, tagName: 'ENTRY', text: '' }],
colspan: 1,
rowspan: 1,
colIdx: cIdx,
rowIdx: rIdx,
shouldRender: true,
isDummy: true
}
occupied[rIdx][cIdx] = placeholderCell
resultRows[rIdx].cells.push(placeholderCell)
continue
}
// 修正由于前面的跨列导致的起点不对
......@@ -578,14 +611,106 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
store.rebuildNodeMap()
}
const addColumn = (node: XmlNode, activeColIdx?: number, insertRight = true, cellChildTags?: string[] | null): void => {
/** 获取指定插入列操作下的相邻 SPANSPEC 候选列表 */
const getSpanCandidatesForAction = (action: string, activeColIdx?: number): SpanCandidateItem[] => {
if (!['col-left', 'col-right', 'col-append'].includes(action)) return []
const tgroup = findTgroup(props.node)
if (!tgroup) return []
const currentCols = parseInt(tgroup.attributes.COLS || '0', 10)
let targetColIdx = currentCols
if (action === 'col-append') {
targetColIdx = currentCols
} else {
let col = activeColIdx
const targetCellId = contextMenu.value.cellId || selectedCellIds.value[0]
if (targetCellId) {
const allCells = [...structure.value.theadRows, ...structure.value.tbodyRows].flatMap((r) => r.cells)
const matched = allCells.find((c) => c.id === targetCellId)
if (matched && matched.colIdx !== undefined) {
if (col === undefined) {
col = matched.colIdx
}
if (action === 'col-right' && matched.colspan && matched.colspan > 1) {
col = matched.colIdx + matched.colspan - 1
}
}
}
if (col !== undefined) {
targetColIdx = action === 'col-right' ? col + 1 : col
}
}
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
const oldColNameToOldIdx = new Map<string, number>()
colSpecs.forEach((spec, idx) => {
const name = (spec.attributes.COLNAME || spec.attributes.colname || '').toLowerCase()
if (name) oldColNameToOldIdx.set(name, idx)
})
const candidates: SpanCandidateItem[] = []
tgroup.children
.filter((c) => c.tagName === 'SPANSPEC')
.forEach((spanSpec) => {
const spanName = spanSpec.attributes.SPANNAME || spanSpec.attributes.spanname || '未命名跨列'
const rawNs = spanSpec.attributes.NAMEST || spanSpec.attributes.namest || ''
const rawNe = spanSpec.attributes.NAMEEND || spanSpec.attributes.nameend || ''
const oldNsIdx = oldColNameToOldIdx.get(rawNs.toLowerCase()) ?? -1
const oldNeIdx = oldColNameToOldIdx.get(rawNe.toLowerCase()) ?? -1
if (oldNsIdx === -1 || oldNeIdx === -1) return
const minOldIdx = Math.min(oldNsIdx, oldNeIdx)
const maxOldIdx = Math.max(oldNsIdx, oldNeIdx)
if (targetColIdx === maxOldIdx + 1) {
// 新列紧邻该跨列右端(例如原范围 col1 ~ col3,新列为第 4 列 col4)
candidates.push({
spanId: spanSpec.id,
spanName,
type: 'right',
minOldIdx,
maxOldIdx,
rawNs,
rawNe,
currentRangeText: `col${minOldIdx + 1} ~ col${maxOldIdx + 1}`,
mergedRangeText: `col${minOldIdx + 1} ~ col${targetColIdx + 1}`
})
} else if (targetColIdx === minOldIdx) {
// 新列插入在跨列左端起始位置
candidates.push({
spanId: spanSpec.id,
spanName,
type: 'left',
minOldIdx,
maxOldIdx,
rawNs,
rawNe,
currentRangeText: `col${minOldIdx + 2} ~ col${maxOldIdx + 2}(原 ${rawNs} ~ ${rawNe})`,
mergedRangeText: `col${minOldIdx + 1} ~ col${maxOldIdx + 2}`
})
}
})
return candidates
}
const addColumn = (
node: XmlNode,
activeColIdx?: number,
insertRight = true,
cellChildTags?: string[] | null,
selectedSpanIds?: string[],
count = 1
): void => {
const tgroup = findTgroup(node)
if (!tgroup) return
const insertCount = Math.max(1, count)
store.saveSnapshot()
const currentCols = parseInt(tgroup.attributes.COLS || '0', 10)
const nextCols = currentCols + 1
const nextCols = currentCols + insertCount
tgroup.attributes.COLS = nextCols.toString()
let targetColIdx = currentCols
......@@ -595,6 +720,23 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
// 在重命名 COLSPEC 之前,先建立旧列名索引映射(用于 SPANSPEC 边缘扩展判断)
const oldColNameToOldIdx = new Map<string, number>() // lowercase old name → index in old colSpecs
colSpecs.forEach((spec, idx) => {
const name = (spec.attributes.COLNAME || '').toLowerCase()
if (name) oldColNameToOldIdx.set(name, idx)
})
// 捕获每个 SPANSPEC 的旧 NAMEST / NAMEEND 索引(在重命名前捕获,确保值为原始旧名)
const spanExtInfos = tgroup.children
.filter((c) => c.tagName === 'SPANSPEC')
.map((spanSpec) => ({
node: spanSpec,
spanName: spanSpec.attributes.SPANNAME || spanSpec.attributes.spanname || '',
oldNsIdx: oldColNameToOldIdx.get((spanSpec.attributes.NAMEST || spanSpec.attributes.namest || '').toLowerCase()) ?? -1,
oldNeIdx: oldColNameToOldIdx.get((spanSpec.attributes.NAMEEND || spanSpec.attributes.nameend || '').toLowerCase()) ?? -1
}))
// 计算新增列的自适应默认宽度,从偏好设置 store 中读取比重系数,防止权重失衡
const ratio = appStore.defaultNewColWidth ?? 0.2
let defaultWidth = `${ratio}*`
......@@ -621,18 +763,21 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
}
}
const newColSpec: XmlNode = {
id: crypto.randomUUID(),
tagName: 'COLSPEC',
attributes: {
COLNAME: `col_temp_${Date.now()}`,
COLNUM: '',
COLWIDTH: defaultWidth
},
children: [],
textContent: '',
mixedContent: [],
parentId: tgroup.id
const newColSpecs: XmlNode[] = []
for (let i = 0; i < insertCount; i++) {
newColSpecs.push({
id: crypto.randomUUID(),
tagName: 'COLSPEC',
attributes: {
COLNAME: `col_temp_${Date.now()}_${i}`,
COLNUM: '',
COLWIDTH: defaultWidth
},
children: [],
textContent: '',
mixedContent: [],
parentId: tgroup.id
})
}
if (colSpecs.length > 0) {
......@@ -644,45 +789,107 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
const targetSpecId = colSpecs[targetColIdx].id
colspecInsertIdx = tgroup.children.findIndex((c) => c.id === targetSpecId)
}
tgroup.children.splice(colspecInsertIdx, 0, newColSpec)
tgroup.children.splice(colspecInsertIdx, 0, ...newColSpecs)
} else {
tgroup.children.unshift(newColSpec)
tgroup.children.unshift(...newColSpecs)
}
const updatedColSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
// 在重命名 COLSPEC 之前,先建立「旧列名(小写)→ 新列名」的映射表。
// 这是关键步骤:updatedColSpecs 对象会被就地修改,若先重命名再查找旧名则失效。
const colNameRemap = new Map<string, string>() // lowercase old name → new col name
updatedColSpecs.forEach((spec, idx) => {
const oldName = (spec.attributes.COLNAME || '').toLowerCase()
if (oldName) colNameRemap.set(oldName, `col${idx + 1}`)
})
// 重命名所有 COLSPEC
updatedColSpecs.forEach((spec, idx) => {
spec.attributes.COLNAME = `col${idx + 1}`
spec.attributes.COLNUM = (idx + 1).toString()
})
/** 辅助:根据旧列名(不区分大小写)查找新列名 */
const remapColName = (oldName: string): string | undefined => colNameRemap.get(oldName.toLowerCase())
// 更新 ENTRY 的跨列属性 COLNAME / NAMEST / NAMEEND
const updateCellSpans = (sectionNode?: XmlNode) => {
if (!sectionNode) return
sectionNode.children.forEach((row) => {
row.children.forEach((entry) => {
const namest = entry.attributes.NAMEST
const nameend = entry.attributes.NAMEEND
if (namest && nameend) {
const sColIdx = colSpecs.findIndex((s) => s.attributes.COLNAME === namest)
const eColIdx = colSpecs.findIndex((s) => s.attributes.COLNAME === nameend)
if (sColIdx !== -1 && eColIdx !== -1) {
let newSColIdx = sColIdx
let newEColIdx = eColIdx
if (sColIdx >= targetColIdx) {
newSColIdx = sColIdx + 1
}
if (eColIdx >= targetColIdx) {
newEColIdx = eColIdx + 1
}
entry.attributes.NAMEST = `col${newSColIdx + 1}`
entry.attributes.NAMEEND = `col${newEColIdx + 1}`
// COLNAME:单列引用,也需要跟随列名重映射
const cn = entry.attributes.COLNAME || entry.attributes.colname
if (cn) {
const newCn = remapColName(cn)
if (newCn !== undefined) {
if (entry.attributes.COLNAME !== undefined) entry.attributes.COLNAME = newCn
if (entry.attributes.colname !== undefined) entry.attributes.colname = newCn
}
}
// NAMEST / NAMEEND
const ns = entry.attributes.NAMEST || entry.attributes.namest
const ne = entry.attributes.NAMEEND || entry.attributes.nameend
if (ns && ne) {
const newNs = remapColName(ns)
const newNe = remapColName(ne)
if (newNs !== undefined) {
if (entry.attributes.NAMEST !== undefined) entry.attributes.NAMEST = newNs
if (entry.attributes.namest !== undefined) entry.attributes.namest = newNs
}
if (newNe !== undefined) {
if (entry.attributes.NAMEEND !== undefined) entry.attributes.NAMEEND = newNe
if (entry.attributes.nameend !== undefined) entry.attributes.nameend = newNe
}
}
})
})
}
// 同步更新 SPANSPEC 的 NAMEST / NAMEEND(使用同一映射表,不区分大小写)
tgroup.children
.filter((c) => c.tagName === 'SPANSPEC')
.forEach((spanSpec) => {
const ns = spanSpec.attributes.NAMEST || spanSpec.attributes.namest
const ne = spanSpec.attributes.NAMEEND || spanSpec.attributes.nameend
if (ns && ne) {
const newNs = remapColName(ns)
const newNe = remapColName(ne)
if (newNs !== undefined) spanSpec.attributes.NAMEST = newNs
if (newNe !== undefined) spanSpec.attributes.NAMEEND = newNe
}
})
// 边缘扩展:根据用户在弹框中的勾选结果,将确认的 SPANSPEC 延伸到新列(支持插入多列)
// 若没有提供 selectedSpanIds(如非弹窗直接调用),则默认全部并入
const selectedSet = selectedSpanIds !== undefined ? new Set(selectedSpanIds) : new Set(spanExtInfos.map((s) => s.node.id))
const extendedSpanNames = new Set<string>()
spanExtInfos.forEach(({ node: spanSpec, spanName, oldNsIdx, oldNeIdx }) => {
if (oldNsIdx === -1 || oldNeIdx === -1) return
if (!selectedSet.has(spanSpec.id)) return
if (targetColIdx === oldNeIdx + 1) {
// 新列在跨列右端紧邻位置 → 延伸 NAMEEND 到新增所有列的末尾
const newNe = `col${targetColIdx + insertCount}`
if (spanSpec.attributes.NAMEEND !== undefined) spanSpec.attributes.NAMEEND = newNe
if (spanSpec.attributes.nameend !== undefined) spanSpec.attributes.nameend = newNe
if (spanSpec.attributes.NAMEEND === undefined && spanSpec.attributes.nameend === undefined) {
spanSpec.attributes.NAMEEND = newNe
}
if (spanName) extendedSpanNames.add(spanName.toLowerCase())
} else if (targetColIdx === oldNsIdx) {
// 新列在跨列左端起始位置(旧 NAMEST 右移后跳过了新列)→ 延伸 NAMEST 到新列的最左端
const newNs = `col${targetColIdx + 1}`
if (spanSpec.attributes.NAMEST !== undefined) spanSpec.attributes.NAMEST = newNs
if (spanSpec.attributes.namest !== undefined) spanSpec.attributes.namest = newNs
if (spanSpec.attributes.NAMEST === undefined && spanSpec.attributes.namest === undefined) {
spanSpec.attributes.NAMEST = newNs
}
if (spanName) extendedSpanNames.add(spanName.toLowerCase())
}
})
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
......@@ -692,19 +899,33 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
const tableStructure = parseTable(node)
const insertedColCellIds: string[] = []
const createDefaultColEntry = (parentRowId: string): XmlNode => {
const cellNode = createDefaultEntry(parentRowId, cellChildTags)
insertedColCellIds.push(cellNode.id)
return cellNode
const createDefaultColEntries = (parentRowId: string): XmlNode[] => {
const entries: XmlNode[] = []
for (let i = 0; i < insertCount; i++) {
const cellNode = createDefaultEntry(parentRowId, cellChildTags)
cellNode.attributes.COLNAME = `col${targetColIdx + 1 + i}`
insertedColCellIds.push(cellNode.id)
entries.push(cellNode)
}
return entries
}
const insertCellToRows = (sectionNode?: XmlNode, structRows: any[] = []) => {
if (!sectionNode) return
const rows = sectionNode.children.filter((c) => c.tagName === 'ROW')
rows.forEach((row) => {
// 如果当前行已有单元格使用了已延伸的跨列(该跨列已覆盖新列位置),则无需再插入重复的单列单元格
const isCovered = row.children.some((entry) => {
if (entry.tagName !== 'ENTRY') return false
const sn = (entry.attributes.SPANNAME || entry.attributes.spanname || '').toLowerCase()
return sn && extendedSpanNames.has(sn)
})
if (isCovered) return
const structRow = structRows.find((r) => r.id === row.id)
const newEntries = createDefaultColEntries(row.id)
if (!structRow) {
row.children.push(createDefaultColEntry(row.id))
row.children.push(...newEntries)
return
}
......@@ -712,7 +933,7 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
if (rightCell) {
const idx = row.children.findIndex((c) => c.id === rightCell.id)
if (idx !== -1) {
row.children.splice(idx, 0, createDefaultColEntry(row.id))
row.children.splice(idx, 0, ...newEntries)
return
}
}
......@@ -721,12 +942,12 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
if (leftCell) {
const idx = row.children.findIndex((c) => c.id === leftCell.id)
if (idx !== -1) {
row.children.splice(idx + 1, 0, createDefaultColEntry(row.id))
row.children.splice(idx + 1, 0, ...newEntries)
return
}
}
row.children.push(createDefaultColEntry(row.id))
row.children.push(...newEntries)
})
}
......@@ -754,37 +975,12 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
return
}
store.saveSnapshot()
tgroup.attributes.COLS = (currentCols - 1).toString()
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
if (colSpecs[colIndex]) {
const specId = colSpecs[colIndex].id
const idx = tgroup.children.findIndex((c) => c.id === specId)
if (idx !== -1) tgroup.children.splice(idx, 1)
}
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
const deleteCellFromRows = (sectionNode?: XmlNode) => {
if (!sectionNode) return
sectionNode.children
.filter((c) => c.tagName === 'ROW')
.forEach((row) => {
const entries = row.children.filter((c) => c.tagName === 'ENTRY')
if (entries[colIndex]) {
const cellId = entries[colIndex].id
const entryIdx = row.children.findIndex((c) => c.id === cellId)
if (entryIdx !== -1) row.children.splice(entryIdx, 1)
}
})
}
deleteCellFromRows(thead)
deleteCellFromRows(tbody)
const targetSpec = colSpecs[colIndex]
if (!targetSpec) return
store.saveSnapshot()
store.deleteColspecNode(targetSpec.id)
store.rebuildNodeMap()
}
......@@ -851,16 +1047,86 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
tgroup.attributes.COLS = (currentColsNow - 1).toString()
// 在删除 COLSPEC 和重命名之前,先建立旧列名索引映射
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
const oldNameToIdx2 = new Map<string, number>()
colSpecs.forEach((spec, i) => {
const name = (spec.attributes.COLNAME || spec.attributes.colname || '').toLowerCase()
if (name) oldNameToIdx2.set(name, i)
})
if (colSpecs[colIdx]) {
const specId = colSpecs[colIdx].id
const specIdx = tgroup.children.findIndex((c) => c.id === specId)
if (specIdx !== -1) tgroup.children.splice(specIdx, 1)
}
// 重新顺序编号剩余 COLSPEC
const remainingColSpecs2 = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
remainingColSpecs2.forEach((spec, rIdx) => {
spec.attributes.COLNAME = `col${rIdx + 1}`
spec.attributes.COLNUM = (rIdx + 1).toString()
})
const getNewColName2 = (oldName: string): string | null => {
const oldI = oldNameToIdx2.get(oldName.toLowerCase())
if (oldI === undefined) return null
if (oldI === colIdx) return null
const newI = oldI > colIdx ? oldI - 1 : oldI
return `col${newI + 1}`
}
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
const remapEntryColRefs2 = (sec?: XmlNode) => {
if (!sec) return
sec.children.filter(c => c.tagName === 'ROW').forEach(row => {
row.children.filter(c => c.tagName === 'ENTRY').forEach(entry => {
const cn = entry.attributes.COLNAME || entry.attributes.colname
if (cn) {
const newCn = getNewColName2(cn)
if (newCn === null) { delete entry.attributes.COLNAME; delete entry.attributes.colname }
else if (newCn !== undefined) {
if (entry.attributes.COLNAME !== undefined) entry.attributes.COLNAME = newCn
if (entry.attributes.colname !== undefined) entry.attributes.colname = newCn
}
}
const ns = entry.attributes.NAMEST || entry.attributes.namest
const ne = entry.attributes.NAMEEND || entry.attributes.nameend
if (ns && ne) {
let newNs = getNewColName2(ns)
let newNe = getNewColName2(ne)
if (newNs === null) { const next = colIdx < remainingColSpecs2.length ? colIdx : colIdx - 1; newNs = next >= 0 ? `col${next + 1}` : null }
if (newNe === null) { const prev = colIdx > 0 ? colIdx - 1 : 0; newNe = `col${prev + 1}` }
if (newNs && newNe && parseInt(newNs.slice(3)) < parseInt(newNe.slice(3))) {
entry.attributes.NAMEST = newNs; entry.attributes.NAMEEND = newNe
delete entry.attributes.namest; delete entry.attributes.nameend
} else {
delete entry.attributes.NAMEST; delete entry.attributes.namest
delete entry.attributes.NAMEEND; delete entry.attributes.nameend
}
}
})
})
}
remapEntryColRefs2(thead)
remapEntryColRefs2(tbody)
// 同步更新 SPANSPEC 的 NAMEST / NAMEEND
tgroup.children.filter(c => c.tagName === 'SPANSPEC').forEach(spanSpec => {
const ns = spanSpec.attributes.NAMEST || spanSpec.attributes.namest
const ne = spanSpec.attributes.NAMEEND || spanSpec.attributes.nameend
if (ns && ne) {
let newNs = getNewColName2(ns)
let newNe = getNewColName2(ne)
if (newNs === null) { const next = colIdx < remainingColSpecs2.length ? colIdx : colIdx - 1; newNs = next >= 0 ? `col${next + 1}` : null }
if (newNe === null) { const prev = colIdx > 0 ? colIdx - 1 : 0; newNe = `col${prev + 1}` }
if (newNs && newNe) { spanSpec.attributes.NAMEST = newNs; spanSpec.attributes.NAMEEND = newNe }
}
})
const deleteCellFromSection = (sec?: XmlNode) => {
if (!sec) return
sec.children
......@@ -1538,24 +1804,33 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
}
])
const handleColAddSelect = (key: string, cellChildTags?: string[] | null) => {
const handleColAddSelect = (
key: string,
cellChildTags?: string[] | null,
selectedSpanIds?: string[],
count = 1
) => {
let activeColIdx: number | undefined = undefined
if (selectedCellIds.value.length > 0) {
const activeCellId = selectedCellIds.value[0]
const targetCellId = selectedCellIds.value[0] || contextMenu.value.cellId
if (targetCellId) {
const allCells = [...structure.value.theadRows, ...structure.value.tbodyRows].flatMap((r) => r.cells)
const matched = allCells.find((c) => c.id === activeCellId)
if (matched) {
activeColIdx = matched.colIdx
const matched = allCells.find((c) => c.id === targetCellId)
if (matched && matched.colIdx !== undefined) {
if (key === 'right' && matched.colspan && matched.colspan > 1) {
activeColIdx = matched.colIdx + matched.colspan - 1
} else {
activeColIdx = matched.colIdx
}
}
}
if (key === 'left') {
addColumn(props.node, activeColIdx, false, cellChildTags)
addColumn(props.node, activeColIdx, false, cellChildTags, selectedSpanIds, count)
} else if (key === 'right') {
addColumn(props.node, activeColIdx, true, cellChildTags)
addColumn(props.node, activeColIdx, true, cellChildTags, selectedSpanIds, count)
} else {
addColumn(props.node, undefined, true, cellChildTags)
addColumn(props.node, undefined, true, cellChildTags, selectedSpanIds, count)
}
}
......@@ -2138,7 +2413,8 @@ export function useTableEditor(props: { node: XmlNode }, options?: { mergeModalR
colWidthStyles,
tableFrameClass,
getCellStyle,
handleResizeStart
handleResizeStart,
getSpanCandidates: getSpanCandidatesForAction
}
}
......@@ -2150,12 +2426,13 @@ export function useTableBatchActions(deps: {
batchModalRef: Ref<BatchModalRef | undefined>
handleContextMenuSelect: (key: string) => void
handleRowAddSelect: (pos: string, cellChildTags?: string[] | null) => void
handleColAddSelect: (pos: string, cellChildTags?: string[] | null) => void
handleColAddSelect: (pos: string, cellChildTags?: string[] | null, selectedSpanIds?: string[], count?: number) => void
batchDeleteRows: (rowId: string, count: number) => void
batchDeleteColumns: (colIdx: number, count: number) => void
contextMenu: Ref<{ cellId: string; rowId: string; colIdx: number; show: boolean; x: number; y: number }>
selectedCellIds: Ref<string[]>
closeContextMenu: () => void
getSpanCandidates?: (action: string, activeColIdx?: number) => SpanCandidateItem[]
}) {
const store = useEditorStore()
......@@ -2166,7 +2443,8 @@ export function useTableBatchActions(deps: {
deps.handleContextMenuSelect(key)
return
}
deps.batchModalRef.value?.open({ ...meta, action: key })
const spanCandidates = deps.getSpanCandidates ? deps.getSpanCandidates(key, deps.contextMenu.value.colIdx) : []
deps.batchModalRef.value?.open({ ...meta, action: key, spanCandidates })
}
/** 右键菜单选中时拦截,弹出数量弹框 */
......@@ -2176,7 +2454,12 @@ export function useTableBatchActions(deps: {
}
/** 确认执行批量操作(由 TableBatchModal 的 @confirm 事件触发) */
const executeBatchAction = (count: number, action: string, cellChildTags: string[]) => {
const executeBatchAction = (
count: number,
action: string,
cellChildTags: string[],
selectedSpanIds?: string[]
) => {
const { cellId, rowId, colIdx } = deps.contextMenu.value
if (cellId && !deps.selectedCellIds.value.includes(cellId)) {
......@@ -2195,13 +2478,13 @@ export function useTableBatchActions(deps: {
for (let i = 0; i < count; i++) deps.handleRowAddSelect('append', cellChildTags)
break
case 'col-left':
for (let i = 0; i < count; i++) deps.handleColAddSelect('left', cellChildTags)
deps.handleColAddSelect('left', cellChildTags, selectedSpanIds, count)
break
case 'col-right':
for (let i = 0; i < count; i++) deps.handleColAddSelect('right', cellChildTags)
deps.handleColAddSelect('right', cellChildTags, selectedSpanIds, count)
break
case 'col-append':
for (let i = 0; i < count; i++) deps.handleColAddSelect('append', cellChildTags)
deps.handleColAddSelect('append', cellChildTags, selectedSpanIds, count)
break
case 'row-delete':
if (rowId) deps.batchDeleteRows(rowId, count)
......
......@@ -422,7 +422,8 @@ const {
tableFrameClass,
getCellStyle,
handleResizeStart,
executeMergeAction
executeMergeAction,
getSpanCandidates
} = useTableEditor(props, { mergeModalRef })
const selectNode = (id: string, forceScroll = false) => {
......@@ -449,7 +450,8 @@ const { onContextMenuSelect, executeBatchAction } = useTableBatchActions({
batchDeleteColumns,
contextMenu,
selectedCellIds,
closeContextMenu
closeContextMenu,
getSpanCandidates
})
</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