Commit 50321a59 by pangchong

chore(deps): 添加 @types/diff 类型定义依赖

- 在 package.json 和 package-lock.json 中新增 @types/diff 依赖
- 确保开发环境对 diff 类型的支持和代码智能提示
- 安装包版本为 7.0.2,许可证为 MIT
parent 8128cbff
...@@ -36,6 +36,7 @@ ...@@ -36,6 +36,7 @@
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.8.0", "@commitlint/cli": "^19.8.0",
"@commitlint/config-conventional": "^19.8.0", "@commitlint/config-conventional": "^19.8.0",
"@types/diff": "^7.0.2",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.14", "@types/node": "^24.10.14",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
...@@ -1667,6 +1668,13 @@ ...@@ -1667,6 +1668,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/diff": {
"version": "7.0.2",
"resolved": "https://registry.npmmirror.com/@types/diff/-/diff-7.0.2.tgz",
"integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
......
...@@ -45,6 +45,7 @@ ...@@ -45,6 +45,7 @@
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.8.0", "@commitlint/cli": "^19.8.0",
"@commitlint/config-conventional": "^19.8.0", "@commitlint/config-conventional": "^19.8.0",
"@types/diff": "^7.0.2",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.14", "@types/node": "^24.10.14",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
<template>
<CommonModal v-model="show" title="导出确认" :width="500">
<div class="p-4 py-6">
<n-form-item label="选择导出范围" label-placement="left">
<n-radio-group v-model:value="exportMode" name="exportMode">
<n-space>
<n-radio value="page">当前页数据</n-radio>
<n-radio value="all">
全量数据
<n-text depth="3" class="text-xs ml-1">(最多 2,147,483,647 条)</n-text>
</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-alert v-if="exportMode === 'all'" title="提示" type="info" class="mt-4">全量导出可能会耗时较长,具体取决于系统数据总量。</n-alert>
<n-progress v-if="loading" type="line" :percentage="exportProgress" :indicator-placement="'inside'" processing class="mt-4" />
</div>
<template #footer>
<n-space justify="end">
<CommonButton @click="show = false" :disabled="loading">取消</CommonButton>
<CommonButton type="primary" :loading="loading" @click="handleConfirm">开始导出</CommonButton>
</n-space>
</template>
</CommonModal>
</template>
<script setup lang="ts">
const show = ref(false)
const loading = ref(false)
const exportProgress = ref(0)
const exportMode = ref<'page' | 'all'>('page')
let progressTimer: any = null
// 导出上下文
const context = ref<{
fileName: string
functionCode: string
params: any
} | null>(null)
/**
* 打开导出选择弹窗
* @param options 导出配置
*/
const open = (options: { fileName: string; functionCode: string; params: any }) => {
context.value = options
exportMode.value = 'page' // 默认当前页
show.value = true
}
/** 执行下载 */
const handleConfirm = async () => {
if (!context.value) return
loading.value = true
exportProgress.value = 0
// 开启模拟进度条定时器
progressTimer = setInterval(() => {
if (exportProgress.value < 90) {
exportProgress.value += Math.floor(Math.random() * 3) + 1
} else if (exportProgress.value < 98) {
exportProgress.value += 0.3
}
}, 200)
const { fileName, functionCode, params } = context.value
const exportParams = {
...params,
functionCode,
fileName,
page: exportMode.value === 'all' ? 1 : params.page || 1,
rows: exportMode.value === 'all' ? 2147483647 : params.rows || 15
}
try {
const success = await service.download('/excel/export', exportParams, `${fileName}_${new Date().getTime()}.xlsx`, {
showLoading: false
})
if (success) {
exportProgress.value = 100
window.$message.success('导出任务已启动,请查看浏览器下载')
// 延迟关闭,让用户看到 100% 进度
setTimeout(() => {
show.value = false
}, 500)
}
} catch (err) {
console.error('Export failed', err)
exportProgress.value = 0
} finally {
loading.value = false
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
}
}
defineExpose({ open })
</script>
...@@ -27,26 +27,10 @@ ...@@ -27,26 +27,10 @@
</div> </div>
<template #footer> <template #footer>
<div class="flex justify-between items-center w-full">
<CommonButton v-if="showDownload" type="primary" secondary @click="handleDownloadTemplate">
<template #icon>
<n-icon><DownloadOutline /></n-icon>
</template>
下载导入模板
</CommonButton>
<div v-else></div>
<n-space>
<CommonButton @click="showModal = false">取消</CommonButton> <CommonButton @click="showModal = false">取消</CommonButton>
<CommonButton <CommonButton type="primary" :loading="uploading" :disabled="!importResult?.success && fileList.length === 0" @click="handleConfirm">
type="primary"
:loading="uploading"
:disabled="!importResult?.success && fileList.length === 0"
@click="handleConfirm"
>
{{ importResult?.success ? '完成' : '确定' }} {{ importResult?.success ? '完成' : '确定' }}
</CommonButton> </CommonButton>
</n-space>
</div>
</template> </template>
</CommonModal> </CommonModal>
</template> </template>
...@@ -78,7 +62,6 @@ const context = ref<ImportOptions | null>(null) ...@@ -78,7 +62,6 @@ const context = ref<ImportOptions | null>(null)
let progressTimer: any = null let progressTimer: any = null
const title = computed(() => context.value?.title || '批量导入') const title = computed(() => context.value?.title || '批量导入')
const showDownload = computed(() => !!(context.value?.templateName || context.value?.templateApi))
/** /**
* 打开导入弹窗 * 打开导入弹窗
...@@ -185,21 +168,6 @@ const handleConfirm = async () => { ...@@ -185,21 +168,6 @@ const handleConfirm = async () => {
} }
} }
const handleDownloadTemplate = () => {
if (!context.value?.templateName && !context.value?.templateApi) return
const params = {
...(context.value.templateName ? { filename: context.value.templateName } : {}),
...(context.value.downloadParams || {})
}
const downloadApi = context.value.templateApi || '/v1/plugins/ATTACHMENT_DOWN'
const fileName = context.value.templateTitle || '导入模板.xlsx'
openDownloadModal({
downloadFunc: () => service.download(downloadApi, params, fileName),
fileName,
title: '下载导入模板'
})
}
const emit = defineEmits(['success']) const emit = defineEmits(['success'])
defineExpose({ defineExpose({
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
:draggable="{ bounds: 'none' }" :draggable="{ bounds: 'none' }"
v-bind="$attrs" v-bind="$attrs"
:style="modalStyle" :style="modalStyle"
class="app-modal" :class="['app-modal', { 'is-fullscreen': fullscreen }]"
header-class="!px-[20px] !py-[15px]" header-class="!px-[20px] !py-[15px]"
content-class="!p-0" content-class="!p-0"
footer-class="!m-0 !p-0" footer-class="!m-0 !p-0"
...@@ -65,6 +65,7 @@ interface Props { ...@@ -65,6 +65,7 @@ interface Props {
maxHeight?: string | number maxHeight?: string | number
padding?: string | number padding?: string | number
scrollable?: boolean scrollable?: boolean
fullscreen?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -78,12 +79,22 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -78,12 +79,22 @@ const props = withDefaults(defineProps<Props>(), {
width: '600px', width: '600px',
maxHeight: '75vh', maxHeight: '75vh',
padding: '15px', padding: '15px',
scrollable: true scrollable: true,
fullscreen: false
}) })
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']) const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const modalStyle = computed(() => { const modalStyle = computed(() => {
if (props.fullscreen) {
return {
width: '100vw',
height: '100vh',
maxHeight: '100vh',
margin: 0,
borderRadius: 0
}
}
const w = props.width const w = props.width
return { return {
width: typeof w === 'number' ? `${w}px` : w width: typeof w === 'number' ? `${w}px` : w
...@@ -104,7 +115,43 @@ const handleCancel = () => { ...@@ -104,7 +115,43 @@ const handleCancel = () => {
emit('cancel') emit('cancel')
} }
</script> </script>
<style></style> <style>
.app-modal.is-fullscreen {
width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
margin: 0 !important;
border-radius: 0 !important;
display: flex !important;
flex-direction: column !important;
}
.app-modal.is-fullscreen > .n-card__content {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
min-height: 0 !important;
padding: 0 !important;
}
.app-modal.is-fullscreen > .n-card__content > .n-spin-container {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
min-height: 0 !important;
}
.app-modal.is-fullscreen > .n-card__content > .n-spin-container > .n-spin-content {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
min-height: 0 !important;
}
.app-modal.is-fullscreen > .n-card__content > .n-spin-container > .n-spin-content > .space-y-4 {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
min-height: 0 !important;
margin: 0 !important;
}
</style>
<style scoped> <style scoped>
:deep(.rule-op) { :deep(.rule-op) {
@apply text-primary font-bold; @apply text-primary font-bold;
......
...@@ -11,7 +11,6 @@ ...@@ -11,7 +11,6 @@
<!-- 全局弹窗组件,供 window 全局挂载调用 --> <!-- 全局弹窗组件,供 window 全局挂载调用 -->
<CommonImportModal ref="globalImportModalRef" /> <CommonImportModal ref="globalImportModalRef" />
<CommonExportModal ref="globalExportModalRef" />
<CommonUploadModal ref="globalUploadModalRef" /> <CommonUploadModal ref="globalUploadModalRef" />
<CommonDownloadModal ref="globalDownloadModalRef" /> <CommonDownloadModal ref="globalDownloadModalRef" />
</div> </div>
...@@ -19,13 +18,10 @@ ...@@ -19,13 +18,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAppStore } from '@/store/app/index' import { useAppStore } from '@/store/app/index'
import { useEditorStore } from '@/store/editor'
import CommonDownloadModal from '@/components/CommonDownloadModal.vue'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const appStore = useAppStore() const appStore = useAppStore()
const globalImportModalRef = ref() const globalImportModalRef = ref()
const globalExportModalRef = ref()
const globalAttachmentModalRef = ref() const globalAttachmentModalRef = ref()
const globalUploadModalRef = ref() const globalUploadModalRef = ref()
const globalPreviewModalRef = ref() const globalPreviewModalRef = ref()
...@@ -36,7 +32,6 @@ useKeyboardShortcuts({}, true) ...@@ -36,7 +32,6 @@ useKeyboardShortcuts({}, true)
onMounted(() => { onMounted(() => {
window.$importModal = globalImportModalRef.value window.$importModal = globalImportModalRef.value
window.$exportModal = globalExportModalRef.value
window.$attachmentModal = globalAttachmentModalRef.value window.$attachmentModal = globalAttachmentModalRef.value
window.$uploadModal = globalUploadModalRef.value window.$uploadModal = globalUploadModalRef.value
window.$previewModal = globalPreviewModalRef.value window.$previewModal = globalPreviewModalRef.value
......
...@@ -242,13 +242,31 @@ ...@@ -242,13 +242,31 @@
<div class="text-sm font-semibold mb-3" :style="{ color: themeVars.textColor2 }">编辑器设置</div> <div class="text-sm font-semibold mb-3" :style="{ color: themeVars.textColor2 }">编辑器设置</div>
<!-- 自动展开新增节点 --> <!-- 自动展开新增节点 -->
<div class="flex items-center justify-between py-3" :style="{ borderColor: themeVars.dividerColor }"> <div class="flex items-center justify-between py-3 border-b" :style="{ borderColor: themeVars.dividerColor }">
<div> <div>
<div class="text-sm" :style="{ color: themeVars.textColor1 }">插入节点自动展开</div> <div class="text-sm" :style="{ color: themeVars.textColor1 }">插入节点自动展开</div>
<div class="text-xs mt-0.5" :style="{ color: themeVars.textColor3 }">插入/粘贴节点及表格行列时,自动展开相关节点</div> <div class="text-xs mt-0.5" :style="{ color: themeVars.textColor3 }">插入/粘贴节点及表格行列时,自动展开相关节点</div>
</div> </div>
<n-switch v-model:value="appStore.autoExpandOnInsert" /> <n-switch v-model:value="appStore.autoExpandOnInsert" />
</div> </div>
<!-- 表格新增列默认宽度 -->
<div class="py-3" :style="{ borderColor: themeVars.dividerColor }">
<div class="flex items-center justify-between mb-1.5">
<span class="text-sm" :style="{ color: themeVars.textColor1 }">表格新增列默认宽度比</span>
<div class="w-32">
<n-input-number
v-model:value="appStore.defaultNewColWidth"
:min="0.05"
:max="2.0"
:step="0.05"
size="small"
placeholder="默认 0.2"
/>
</div>
</div>
<div class="text-xs" :style="{ color: themeVars.textColor3 }">设定新增列占常规平分列宽的比例或折算系数</div>
</div>
</div> </div>
</n-tab-pane> </n-tab-pane>
...@@ -410,6 +428,7 @@ const resetPrefs = () => { ...@@ -410,6 +428,7 @@ const resetPrefs = () => {
appStore.collapsed = false appStore.collapsed = false
appStore.transitionName = 'none' appStore.transitionName = 'none'
appStore.autoExpandOnInsert = true appStore.autoExpandOnInsert = true
appStore.defaultNewColWidth = 0.2
customColor.value = '#165DFF' customColor.value = '#165DFF'
applyAndSave() applyAndSave()
window.$message.success('已重置为默认设置') window.$message.success('已重置为默认设置')
...@@ -425,7 +444,8 @@ const copyPrefs = async () => { ...@@ -425,7 +444,8 @@ const copyPrefs = async () => {
colorWeak: appStore.colorWeak, colorWeak: appStore.colorWeak,
grayMode: appStore.grayMode, grayMode: appStore.grayMode,
transitionName: appStore.transitionName, transitionName: appStore.transitionName,
autoExpandOnInsert: appStore.autoExpandOnInsert autoExpandOnInsert: appStore.autoExpandOnInsert,
defaultNewColWidth: appStore.defaultNewColWidth
}, },
null, null,
2 2
...@@ -456,6 +476,7 @@ const importPrefs = async () => { ...@@ -456,6 +476,7 @@ const importPrefs = async () => {
if (typeof conf.grayMode === 'boolean') appStore.grayMode = conf.grayMode if (typeof conf.grayMode === 'boolean') appStore.grayMode = conf.grayMode
if (conf.transitionName) appStore.transitionName = conf.transitionName if (conf.transitionName) appStore.transitionName = conf.transitionName
if (typeof conf.autoExpandOnInsert === 'boolean') appStore.autoExpandOnInsert = conf.autoExpandOnInsert if (typeof conf.autoExpandOnInsert === 'boolean') appStore.autoExpandOnInsert = conf.autoExpandOnInsert
if (typeof conf.defaultNewColWidth === 'number') appStore.defaultNewColWidth = conf.defaultNewColWidth
applyAndSave() applyAndSave()
window.$message.success('偏好设置已成功导入并应用') window.$message.success('偏好设置已成功导入并应用')
......
...@@ -20,7 +20,8 @@ export const useAppStore = defineStore('app', { ...@@ -20,7 +20,8 @@ export const useAppStore = defineStore('app', {
transitionName: 'none', transitionName: 'none',
settingsPinned: false, settingsPinned: false,
settingsOpen: false, settingsOpen: false,
autoExpandOnInsert: true autoExpandOnInsert: true,
defaultNewColWidth: 0.2
}), }),
actions: { actions: {
toggleSidebar() { toggleSidebar() {
......
...@@ -20,4 +20,5 @@ export interface AppState { ...@@ -20,4 +20,5 @@ export interface AppState {
settingsPinned: boolean settingsPinned: boolean
settingsOpen: boolean settingsOpen: boolean
autoExpandOnInsert: boolean // 新增/粘贴节点及表格行列时自动展开 autoExpandOnInsert: boolean // 新增/粘贴节点及表格行列时自动展开
defaultNewColWidth: number // 表格新增列的默认宽度比例 (当前默认 0.2)
} }
export interface DiffResult {
value: string
added?: boolean
removed?: boolean
count?: number
}
...@@ -17,6 +17,8 @@ export interface XmlNode { ...@@ -17,6 +17,8 @@ export interface XmlNode {
mixedContent: MixedContentItem[] mixedContent: MixedContentItem[]
/** 父节点 ID(不参与序列化) */ /** 父节点 ID(不参与序列化) */
parentId: string | null parentId: string | null
/** 比对差异状态 */
diffStatus?: 'added' | 'removed' | 'modified' | 'none'
} }
/** /**
...@@ -30,6 +32,10 @@ export interface MixedContentItem { ...@@ -30,6 +32,10 @@ export interface MixedContentItem {
text?: string text?: string
/** 子元素节点 ID(type=element 时) */ /** 子元素节点 ID(type=element 时) */
nodeId?: string nodeId?: string
/** 比对差异状态 */
diffStatus?: 'added' | 'removed' | 'modified' | 'none'
/** 单词级差异详情 */
diffWords?: { value: string; added?: boolean; removed?: boolean }[]
} }
/** /**
......
import * as Diff from 'diff'
import type { XmlNode } from '@/types/xmlNode'
import type { DiffResult } from '@/types/diff'
import { serializeTreeToXml } from './xmlParser'
/**
* 比较两份工卡(XML 字符串或 XmlNode 树结构)的行差异
* @param oldCard 旧工卡(可以是 XML 字符串或 XmlNode 树结构)
* @param newCard 新工卡(可以是 XML 字符串或 XmlNode 树结构)
* @returns 差异结果数组
*/
export function diffJobCardsLines(
oldCard: string | XmlNode,
newCard: string | XmlNode
): DiffResult[] {
const oldXml = typeof oldCard === 'string' ? oldCard : serializeTreeToXml(oldCard, 0, false)
const newXml = typeof newCard === 'string' ? newCard : serializeTreeToXml(newCard, 0, false)
return Diff.diffLines(oldXml, newXml)
}
/**
* 比较两份工卡(XML 字符串或 XmlNode 树结构)的单词差异
* @param oldCard 旧工卡
* @param newCard 新工卡
* @returns 差异结果数组
*/
export function diffJobCardsWords(
oldCard: string | XmlNode,
newCard: string | XmlNode
): DiffResult[] {
const oldXml = typeof oldCard === 'string' ? oldCard : serializeTreeToXml(oldCard, 0, false)
const newXml = typeof newCard === 'string' ? newCard : serializeTreeToXml(newCard, 0, false)
return Diff.diffWords(oldXml, newXml)
}
/**
* 比较两个 XmlNode 对象的 JSON 结构差异
* @param oldNode 旧节点
* @param newNode 新节点
* @returns 差异结果数组
*/
export function diffJobCardsJson(
oldNode: XmlNode,
newNode: XmlNode
): DiffResult[] {
// 序列化为规范 JSON 字符串,排除 parentId 以免产生循环引用
const replacer = (key: string, value: any) => {
if (key === 'parentId') return undefined
return value
}
const oldJson = JSON.stringify(oldNode, replacer, 2)
const newJson = JSON.stringify(newNode, replacer, 2)
return Diff.diffJson(oldJson, newJson)
}
...@@ -152,6 +152,19 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b ...@@ -152,6 +152,19 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b
} }
/** /**
* 格式化 XML 字符串(转换为带换行和缩进的排版形式)
*/
export function formatXmlText(xmlStr: string): string {
if (!xmlStr) return ''
try {
const tree = parseXmlToTree(xmlStr)
return serializeTreeToXml(tree, 0, false)
} catch (e) {
return xmlStr
}
}
/**
* 转义 XML 属性值 * 转义 XML 属性值
*/ */
function escapeXmlAttr(str: string): string { function escapeXmlAttr(str: string): string {
......
...@@ -496,3 +496,13 @@ export const isAllEinDataSameEffect = (einlst: XmlNode): boolean => { ...@@ -496,3 +496,13 @@ export const isAllEinDataSameEffect = (einlst: XmlNode): boolean => {
const firstEff = getEffString(einlst.children[0]) const firstEff = getEffString(einlst.children[0])
return einlst.children.every((c) => getEffString(c) === firstEff) return einlst.children.every((c) => getEffString(c) === firstEff)
} }
export const getDiffWordClass = (w: { added?: boolean; removed?: boolean }, notItalic = false): string => {
if (w.added) {
return `bg-success-1 text-success-6 dark:text-success-5 font-bold border-b border-success-3 px-0.5 rounded${notItalic ? ' not-italic' : ''}`
}
if (w.removed) {
return `bg-danger-1 text-danger-6 dark:text-danger-5 line-through border-b border-danger-3 px-0.5 rounded${notItalic ? ' not-italic' : ''}`
}
return ''
}
export interface DiffMixedContentItem {
type: 'text' | 'element'
text?: string
nodeId?: string
diffStatus?: 'added' | 'removed' | 'modified' | 'none'
diffWords?: { value: string; added?: boolean; removed?: boolean }[]
}
export interface DiffXmlNode {
id: string
tagName: string
attributes: Record<string, string>
children: DiffXmlNode[]
textContent: string
mixedContent: DiffMixedContentItem[]
parentId: string | null
diffStatus: 'added' | 'removed' | 'modified' | 'none'
}
export interface DiffLine {
type: 'added' | 'removed' | 'normal' | 'empty'
num?: number
content: string
}
export const CELL_CHILD_OPTIONS = [
{ label: 'PARAC (中文)', value: 'PARAC' },
{ label: 'PARA (英文)', value: 'PARA' }
]
import type { FormInst, FormRules } from 'naive-ui'
import { CHINESE_FIRST_PARA_TAGS } from '@/configs/xmlTags'
export const useCreateTableModal = (
emit: (event: 'confirm', rows: number, cols: number, cellChildTags: string[]) => void
) => {
const show = ref(false)
const formRef = ref<FormInst | null>(null)
const form = reactive({
rows: 3,
cols: 3,
cellChildTags: [...CHINESE_FIRST_PARA_TAGS]
})
const rules: FormRules = {
rows: { type: 'number', required: true, message: '请指定行数', trigger: ['blur', 'change'] },
cols: { type: 'number', required: true, message: '请指定列数', trigger: ['blur', 'change'] }
}
const open = () => {
form.rows = 3
form.cols = 3
form.cellChildTags = [...CHINESE_FIRST_PARA_TAGS]
show.value = true
}
const handleConfirm = async () => {
try {
await formRef.value?.validate()
emit('confirm', form.rows, form.cols, form.cellChildTags)
show.value = false
} catch (err) {
// validation failed
}
}
return {
show,
formRef,
form,
rules,
open,
handleConfirm
}
}
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
</div> </div>
<n-form-item label="单元格初始化段落节点" path="cellChildTags"> <n-form-item label="单元格初始化段落节点" path="cellChildTags">
<div class="w-full bg-fill-2 p-3 rounded border border-divider"> <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="CELL_CHILD_OPTIONS" :space-size="24" />
</div> </div>
</n-form-item> </n-form-item>
</div> </div>
...@@ -39,48 +39,14 @@ ...@@ -39,48 +39,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { FormInst, FormRules } from 'naive-ui' import { CELL_CHILD_OPTIONS } from './constants'
import { CHINESE_FIRST_PARA_TAGS } from '@/configs/xmlTags' import { useCreateTableModal } from './functionals'
const emit = defineEmits<{ const emit = defineEmits<{
confirm: [rows: number, cols: number, cellChildTags: string[]] confirm: [rows: number, cols: number, cellChildTags: string[]]
}>() }>()
const cellChildOptions = [ const { show, formRef, form, rules, open, handleConfirm } = useCreateTableModal(emit)
{ label: 'PARAC (中文)', value: 'PARAC' },
{ label: 'PARA (英文)', value: 'PARA' }
]
const show = ref(false)
const formRef = ref<FormInst | null>(null)
const form = reactive({
rows: 3,
cols: 3,
cellChildTags: [...CHINESE_FIRST_PARA_TAGS]
})
const rules: FormRules = {
rows: { type: 'number', required: true, message: '请指定行数', trigger: ['blur', 'change'] },
cols: { type: 'number', required: true, message: '请指定列数', trigger: ['blur', 'change'] }
}
const open = () => {
form.rows = 3
form.cols = 3
form.cellChildTags = [...CHINESE_FIRST_PARA_TAGS]
show.value = true
}
const handleConfirm = async () => {
try {
await formRef.value?.validate()
emit('confirm', form.rows, form.cols, form.cellChildTags)
show.value = false
} catch (err) {
// validation failed
}
}
defineExpose({ open }) defineExpose({ open })
</script> </script>
...@@ -91,6 +91,14 @@ ...@@ -91,6 +91,14 @@
<n-icon class="text-base"><search-outline /></n-icon> <n-icon class="text-base"><search-outline /></n-icon>
<span class="text-xs font-medium">搜索翻译</span> <span class="text-xs font-medium">搜索翻译</span>
</button> </button>
<button
type="button"
class="toolbar-btn flex items-center gap-1.5 px-2.5 h-7 rounded text-color2 hover:bg-fill-2 active:bg-fill-3 transition-colors select-none focus:outline-none flex-shrink-0"
@click="compareModalRef?.open()"
>
<n-icon class="text-base"><git-compare-outline /></n-icon>
<span class="text-xs font-medium">对比工卡</span>
</button>
</div> </div>
<div class="w-px h-5 flex-shrink-0 mx-3" style="background: #d0d0d0"></div> <div class="w-px h-5 flex-shrink-0 mx-3" style="background: #d0d0d0"></div>
...@@ -223,6 +231,9 @@ ...@@ -223,6 +231,9 @@
<!-- 搜索翻译数据库弹窗 --> <!-- 搜索翻译数据库弹窗 -->
<SearchTranslateModal ref="searchTranslateModalRef" /> <SearchTranslateModal ref="searchTranslateModalRef" />
<!-- 工卡对比弹窗 -->
<CompareModal ref="compareModalRef" />
</div> </div>
</template> </template>
...@@ -241,7 +252,8 @@ import { ...@@ -241,7 +252,8 @@ import {
CodeWorkingOutline, CodeWorkingOutline,
SyncOutline, SyncOutline,
CheckmarkCircleOutline, CheckmarkCircleOutline,
SettingsOutline SettingsOutline,
GitCompareOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { useEditorToolbar } from './functionals' import { useEditorToolbar } from './functionals'
import { GREEN_BUTTONS } from './constants' import { GREEN_BUTTONS } from './constants'
...@@ -252,6 +264,7 @@ import CreateSignoffModal from './components/CreateSignoffModal/index.vue' ...@@ -252,6 +264,7 @@ import CreateSignoffModal from './components/CreateSignoffModal/index.vue'
import BatchTranslateModal from './components/BatchTranslateModal/index.vue' import BatchTranslateModal from './components/BatchTranslateModal/index.vue'
import ExtractTranslateModal from './components/ExtractTranslateModal/index.vue' import ExtractTranslateModal from './components/ExtractTranslateModal/index.vue'
import SearchTranslateModal from './components/SearchTranslateModal/index.vue' import SearchTranslateModal from './components/SearchTranslateModal/index.vue'
import CompareModal from './components/CompareModal/index.vue'
const emit = defineEmits(['save', 'validate', 'export', 'preview', 'download-html']) const emit = defineEmits(['save', 'validate', 'export', 'preview', 'download-html'])
...@@ -276,6 +289,7 @@ const { ...@@ -276,6 +289,7 @@ const {
} = useEditorToolbar(emit) } = useEditorToolbar(emit)
const insertFragmentModalRef = ref<any>(null) const insertFragmentModalRef = ref<any>(null)
const compareModalRef = ref<any>(null)
</script> </script>
<style scoped> <style scoped>
......
import { viewXmlVisible, viewXmlTitle, viewXmlContent } from '../../../functionals'
export const useViewXmlModal = () => {
const handleCopyXml = async () => {
try {
await navigator.clipboard.writeText(viewXmlContent.value)
window.$message?.success('XML 片段已成功复制到剪贴板')
} catch (err) {
window.$message?.error('复制失败,请手动选择复制')
}
}
const open = (title: string, content: string) => {
viewXmlTitle.value = title
viewXmlContent.value = content
viewXmlVisible.value = true
}
return {
viewXmlVisible,
viewXmlTitle,
viewXmlContent,
handleCopyXml,
open
}
}
...@@ -25,22 +25,9 @@ ...@@ -25,22 +25,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ClipboardOutline } from '@vicons/ionicons5' import { ClipboardOutline } from '@vicons/ionicons5'
import { viewXmlVisible, viewXmlTitle, viewXmlContent } from '../../functionals' import { useViewXmlModal } from './functionals'
const handleCopyXml = async () => { const { viewXmlVisible, viewXmlTitle, viewXmlContent, handleCopyXml, open } = useViewXmlModal()
try {
await navigator.clipboard.writeText(viewXmlContent.value)
window.$message?.success('XML 片段已成功复制到剪贴板')
} catch (err) {
window.$message?.error('复制失败,请手动选择复制')
}
}
const open = (title: string, content: string) => {
viewXmlTitle.value = title
viewXmlContent.value = content
viewXmlVisible.value = true
}
defineExpose({ open }) defineExpose({ open })
</script> </script>
......
...@@ -420,15 +420,9 @@ export function useNodeTree( ...@@ -420,15 +420,9 @@ export function useNodeTree(
return lines return lines
}) })
// 仅渲染可视区域的垂直虚线 // 渲染所有计算出的垂直虚线,防止视口尺寸监控失效或滚动缓冲区外节点连线断裂
const visibleVerticalLines = computed(() => { const visibleVerticalLines = computed(() => {
const sTop = scrollTop.value return verticalLines.value
const vHeight = viewportHeight.value
const sBottom = sTop + vHeight
return verticalLines.value.filter((line) => {
const lineBottom = line.top + line.height
return lineBottom >= sTop && line.top <= sBottom
})
}) })
// 展开/折叠逻辑 // 展开/折叠逻辑
......
...@@ -56,7 +56,7 @@ ...@@ -56,7 +56,7 @@
</div> </div>
<!-- 可见列表节点 --> <!-- 可见列表节点 -->
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0 space-y-0.5"> <div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0">
<div <div
v-for="item in visibleItems" v-for="item in visibleItems"
:key="item.id" :key="item.id"
......
...@@ -45,6 +45,8 @@ const setDeepText = (node: XmlNode, text: string): void => { ...@@ -45,6 +45,8 @@ const setDeepText = (node: XmlNode, text: string): void => {
export function useTableEditor(props: { node: XmlNode }) { export function useTableEditor(props: { node: XmlNode }) {
const store = useEditorStore() const store = useEditorStore()
const appStore = useAppStore() const appStore = useAppStore()
const isDiffModeRaw = inject<any>('diffMode', false)
const isDiffMode = computed(() => typeof isDiffModeRaw === 'boolean' ? isDiffModeRaw : (isDiffModeRaw?.value ?? false))
const structure = ref<TableStructureModel>({ const structure = ref<TableStructureModel>({
cols: 0, cols: 0,
...@@ -552,12 +554,44 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -552,12 +554,44 @@ export function useTableEditor(props: { node: XmlNode }) {
} }
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC') const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
// 计算新增列的自适应默认宽度,从偏好设置 store 中读取比重系数,防止权重失衡
const ratio = appStore.defaultNewColWidth ?? 0.2
let defaultWidth = `${ratio}*`
const activeWidths = colSpecs
.map((spec) => spec.attributes.COLWIDTH || spec.attributes.colwidth || '')
.filter((w) => !!w)
if (activeWidths.length > 0) {
const starWidths = activeWidths
.filter((w) => w.includes('*'))
.map((w) => parseFloat(w.replace('*', '')))
.filter((v) => !isNaN(v))
if (starWidths.length > 0) {
const avgStar = starWidths.reduce((sum, v) => sum + v, 0) / starWidths.length
// 基于偏好设置的比例,星号下限根据设定的比例安全限制
const starLimit = Math.max(0.05, ratio / 2)
defaultWidth = `${Math.max(starLimit, Math.round(avgStar * ratio * 10) / 10)}*`
} else {
const absWidths = activeWidths
.map((w) => parseFloat(w))
.filter((v) => !isNaN(v))
if (absWidths.length > 0) {
const avgAbs = absWidths.reduce((sum, v) => sum + v, 0) / absWidths.length
// 基于偏好设置的像素比例,下限为 20px
defaultWidth = `${Math.max(20, Math.round(avgAbs * ratio))}px`
}
}
}
const newColSpec: XmlNode = { const newColSpec: XmlNode = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
tagName: 'COLSPEC', tagName: 'COLSPEC',
attributes: { attributes: {
COLNAME: `col_temp_${Date.now()}`, COLNAME: `col_temp_${Date.now()}`,
COLNUM: '' COLNUM: '',
COLWIDTH: defaultWidth
}, },
children: [], children: [],
textContent: '', textContent: '',
...@@ -1008,7 +1042,22 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1008,7 +1042,22 @@ export function useTableEditor(props: { node: XmlNode }) {
allCells.push(...row.cells) allCells.push(...row.cells)
} }
const matchedCell = allCells.find((c) => c.id === newId || c.paragraphs.some((p) => p.id === newId)) // 优先通过 nodeMap 向上回溯祖先链寻找最近的 ENTRY 节点,以支持嵌套复杂子节点时的精确定位
let entryId: string | null = null
if (allCells.some((c) => c.id === newId)) {
entryId = newId
} else {
let curr = store.nodeMap.get(newId)
while (curr) {
if (curr.node.tagName === 'ENTRY') {
entryId = curr.node.id
break
}
curr = curr.parent ? store.nodeMap.get(curr.parent.id) : undefined
}
}
const matchedCell = entryId ? allCells.find((c) => c.id === entryId) : null
if (matchedCell) { if (matchedCell) {
if (selectedCellIds.value.includes(matchedCell.id)) { if (selectedCellIds.value.includes(matchedCell.id)) {
return return
...@@ -1078,31 +1127,7 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1078,31 +1127,7 @@ export function useTableEditor(props: { node: XmlNode }) {
} }
const expectedArea = (maxCol - minCol + 1) * (maxRow - minRow + 1) const expectedArea = (maxCol - minCol + 1) * (maxRow - minRow + 1)
if (actualArea !== expectedArea) return false return actualArea === expectedArea
const mask = Array.from({ length: rows }, () => Array(cols).fill(false))
for (const cell of cells) {
const cIdx = cell.colIdx ?? 0
const rIdx = cell.rowIdx ?? 0
const cSpan = cell.colspan ?? 1
const rSpan = cell.rowspan ?? 1
for (let dr = 0; dr < rSpan; dr++) {
for (let dc = 0; dc < cSpan; dc++) {
if (rIdx + dr < rows && cIdx + dc < cols) {
mask[rIdx + dr][cIdx + dc] = true
}
}
}
}
for (let r = minRow; r <= maxRow; r++) {
for (let c = minCol; c <= maxCol; c++) {
if (!mask[r][c]) return false
}
}
return true
}) })
const canSplitSelected = computed(() => { const canSplitSelected = computed(() => {
...@@ -1122,6 +1147,8 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1122,6 +1147,8 @@ export function useTableEditor(props: { node: XmlNode }) {
} }
if (selectedCellIds.value.length > 0) { if (selectedCellIds.value.length > 0) {
store.setSelectedNodeId(selectedCellIds.value[selectedCellIds.value.length - 1]) store.setSelectedNodeId(selectedCellIds.value[selectedCellIds.value.length - 1])
} else {
store.setSelectedNodeId(props.node.id)
} }
} else { } else {
selectedCellIds.value = [cell.id] selectedCellIds.value = [cell.id]
...@@ -1130,14 +1157,41 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1130,14 +1157,41 @@ export function useTableEditor(props: { node: XmlNode }) {
} }
const handleCellClick = (cell: TableCellModel, e: MouseEvent) => { const handleCellClick = (cell: TableCellModel, e: MouseEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (preventCellClick.value) return if (preventCellClick.value) return
// 如果是 Ctrl 键点击,多选逻辑已在捕获阶段处理,冒泡阶段直接忽略
if (isCtrl) return
selectCellLogic(cell, e) selectCellLogic(cell, e)
} }
const handleCellClickCapture = (cell: TableCellModel, e: MouseEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isDiffMode.value) return
if (preventCellClick.value) return
if (isCtrl) {
e.stopImmediatePropagation()
e.stopPropagation()
e.preventDefault()
selectCellLogic(cell, e)
}
}
const handleCellMousedownCapture = (e: MouseEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (isDiffMode.value) return
if (isCtrl) {
e.preventDefault()
}
}
const handleParaClick = (para: any, cell: TableCellModel, e: MouseEvent) => { const handleParaClick = (para: any, cell: TableCellModel, e: MouseEvent) => {
const isCtrl = e.ctrlKey || e.metaKey
if (preventCellClick.value) return if (preventCellClick.value) return
store.setSelectedNodeId(para.id)
selectCellLogic(cell, e) selectCellLogic(cell, e)
// 只有非 Ctrl 点击(单选)且单元格已被选中时,才将全局选中节点联动为该段落,避免多选时被段落选中状态干扰
if (!isCtrl && selectedCellIds.value.includes(cell.id)) {
store.setSelectedNodeId(para.id)
}
} }
const handleCellBlur = (cellId: string, e: FocusEvent) => { const handleCellBlur = (cellId: string, e: FocusEvent) => {
...@@ -1815,6 +1869,8 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1815,6 +1869,8 @@ export function useTableEditor(props: { node: XmlNode }) {
canSplitSelected, canSplitSelected,
selectedCells, selectedCells,
handleCellClick, handleCellClick,
handleCellClickCapture,
handleCellMousedownCapture,
handleParaClick, handleParaClick,
handleCellBlur, handleCellBlur,
handleRowAddSelect, handleRowAddSelect,
......
...@@ -81,7 +81,7 @@ ...@@ -81,7 +81,7 @@
:options="attrDef.enumValues.map((v: any) => ({ label: v, value: v }))" :options="attrDef.enumValues.map((v: any) => ({ label: v, value: v }))"
size="tiny" size="tiny"
class="flex-1" class="flex-1"
@update:value="syncMixedContent" @change="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>
......
...@@ -5,7 +5,7 @@ import { DEFAULT_FILE_NAME } from '../constants' ...@@ -5,7 +5,7 @@ import { DEFAULT_FILE_NAME } from '../constants'
// 静态导入 DTD JSON // 静态导入 DTD JSON
import dtdJson from '@/assets/json/dtd.json' import dtdJson from '@/assets/json/dtd.json'
// 导入原始 XML 文本 (?raw) // 导入原始 XML 文本 (?raw)
import xmlText from '@/assets/file/AMEA-A282400-02-1_0_0.xml?raw' import xmlText from '@/assets/file/AMEA-A282400-02-1_0_0_updated.xml?raw'
/** /**
* 工卡 XML 编辑器核心业务逻辑 Hook * 工卡 XML 编辑器核心业务逻辑 Hook
......
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