第二阶段代码

This commit is contained in:
2026-04-23 11:37:37 +08:00
parent 618bb6699e
commit ef64c3b7fb
937 changed files with 118372 additions and 10248 deletions

View File

@@ -0,0 +1,173 @@
<script setup lang="ts">
import { getQuotaCatalogItemTree, updateQuotaCatalogItem } from '#/api/database/quota/index';
import { DbTree } from '#/components/db-tree';
import { Page } from '@vben/common-ui';
import { ElCard, ElSplitter, ElSplitterPanel, ElTabs, ElTabPane } from 'element-plus';
import { onMounted, onUnmounted, ref } from 'vue';
import { DbHst } from '#/components/db-hst';
import { init as directoryInit, settings as directorySettings, contextMenuItems as directoryContextMenuItems } from './cardinalRate/directory';
import { init as itemInit, settings as itemSettings, contextMenuItems as itemContextMenuItems } from './cardinalRate/item';
import { getCatalogTree, updateCatalogNode, getDirectoryTree, getRateItemList } from '#/api/database/interface/cardinalRate';
import { rootMenus, nodeMenus } from './cardinalRate/tree';
import { ElMessage } from 'element-plus';
type Tree = { id: string; label: string; children?: Tree[];[key: string]: any }
const treeData = ref<Tree[]>([])
const directoryHstRef = ref()
const itemHstRef = ref()
// 转换后端数据为树形结构
const transformTreeData = (data: any[]): Tree[] => {
if (!data || !Array.isArray(data)) {
console.warn('transformTreeData: 数据不是数组', data)
return []
}
return data.map(item => {
const treeNode: Tree = {
...item, // 保留所有原始属性
id: String(item.id),
label: item.name || item.label || '未命名',
}
// 只有当 children 存在且长度大于 0 时才递归处理
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
treeNode.children = transformTreeData(item.children)
}
return treeNode
})
}
// 转换数据为 Handsontable 格式
const transformData = (data: any[]): any[] => {
if (!data || !Array.isArray(data)) {
console.warn('transformData: 数据不是数组', data)
return []
}
return data.map(item => {
const transformed = {
...item, // 保留所有原始属性
}
// 如果有 __children转换为 children
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
transformed.__children = transformData(item.children)
}
return transformed
})
}
// 加载定额专业树(第一层)
const loadCategoryTree = async () => {
try {
const res = await getQuotaCatalogItemTree({ exclude: 'rate_mode,fields_majors' })
console.log('定额专业树原始数据:', res)
treeData.value = transformTreeData(res)
console.log('定额专业树转换后数据:', treeData.value)
} catch (error) {
console.error('加载定额专业树失败:', error)
}
// try {
// const res = await getCatalogTree()
// console.log('定额专业树原始数据:', res)
// treeData.value = transformTreeData(res)
// console.log('定额专业树转换后数据:', treeData.value)
// } catch (error) {
// console.error('加载定额专业树失败:', error)
// }
}
const handleEditSave = async (payload: { node: any, data: any, oldLabel: string, newLabel: string }) => {
const { node, data, oldLabel, newLabel } = payload
console.log('节点编辑保存:', node, data)
// 更新节点名称回调
await updateQuotaCatalogItem({
id: data.id,
code: data.code,
name: newLabel,
});
ElMessage.success('更新成功');
}
// 处理树节点点击(只有 specialty 节点才显示变量设置)
const handleNodeClick = async (payload: { node: any, data: any, event: MouseEvent }) => {
const { node, data } = payload
console.log('节点点击:', node, data)
// 清空之前的选择
directoryHstRef.value.hotInstance.deselectCell()
itemHstRef.value.hotInstance.deselectCell()
const result = await getDirectoryTree(data.id)
directoryHstRef.value.calcBaseRateCatalogId = data.id
directoryHstRef.value.nestedRowsLoadData(transformData(result))
itemHstRef.value.hotInstance.loadData([])
}
onMounted(() => {
loadCategoryTree()
setTimeout(() => {
directoryInit(directoryHstRef)
itemInit(itemHstRef)
directoryHstRef.value.loadList = async ()=>{
const result = await getDirectoryTree(directoryHstRef.value.calcBaseRateCatalogId)
directoryHstRef.value.nestedRowsLoadData(transformData(result))
}
directoryHstRef.value.onCellMouseDownCallback = async (data: any)=>{
console.log('onCellMouseDownCallback',data)
if(data.id){
itemHstRef.value.calcBaseRateDirectoryId = data.id
const result = await getRateItemList(data.id)
itemHstRef.value.hotInstance.loadData(result)
}
}
itemHstRef.value.loadList = async ()=>{
const result = await getRateItemList(itemHstRef.value.calcBaseRateDirectoryId)
itemHstRef.value.hotInstance.loadData(result)
}
}, 200);
})
onUnmounted(() => {
})
</script>
<template>
<Page auto-content-height>
<ElSplitter>
<ElSplitterPanel size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbTree
ref="treeRef"
:tree-data="treeData"
:draggable="false"
:default-expanded-level="4"
:root-menus="rootMenus"
:node-menus="nodeMenus"
@node-edit="handleEditSave"
@node-click="handleNodeClick"
/>
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel size="25%" :min="200">
<div class="h-full w-full flex flex-col">
<DbHst ref="directoryHstRef" :settings="directorySettings" :contextMenuItems="directoryContextMenuItems"></DbHst>
</div>
</ElSplitterPanel>
<ElSplitterPanel>
<div class="h-full w-full flex flex-col">
<DbHst ref="itemHstRef" :settings="itemSettings" :contextMenuItems="itemContextMenuItems"></DbHst>
</div>
</ElSplitterPanel>
</ElSplitter>
</Page>
</template>
<style lang="scss">
</style>

View File

@@ -0,0 +1,120 @@
import { ref } from 'vue';
import type { Ref } from 'vue';
import { ElMessage } from 'element-plus';
import { createDirectoryNode, updateDirectoryNode, deleteDirectoryNode } from '#/api/database/interface/cardinalRate';
import { validatorRow } from '#/components/db-hst/validator';
const targetId = ref();
let hstRef = ref();
const selectedRow = ref()
export function init(_hstRef: Ref<any>){
hstRef = _hstRef
}
const columns = [
{ type: 'db.nestedRows', data: 'sortOrder', title: '序号', width: 150 },
{ type: 'text', data: 'name', title: '目录名称', required: true },
]
const contextMenuSettings = {
callback(key, selection, clickEvent){
// Common callback for all options
// console.log(key, selection, clickEvent);
const row = selection[0].start.row
if(key == 'add_child'){
targetId.value = row
}
},
items: {
'row_above': {},
'row_below': {},
'__add_child': 'add_child',//nestedRows: true 单独设置'add_child'无法显示只能在items中设置
'space1': '---------',
'remove_row': {
async callback(key: string, selection: Selection[], clickEvent: MouseEvent){
const row = selection[0].start.row;
const rowData = this.getSourceDataAtRow(row)
// console.log(row,rowData)
this.alter('remove_row', row, 1);
if(rowData && rowData.id){
await deleteDirectoryNode(rowData.id)
ElMessage.success('删除成功')
}
}
},
// 'space2': '---------',
// 'option4': {
// name() {
// return '4444';
// },
// callback (key: string, selection: Selection[], clickEvent: MouseEvent) {
// // this.alter('insert_row', coords.row + 1)
// }
// },
}
};
export let settings = {
data: [],
// dataSchema: initSchema(columns),
colWidths: 200,
columns: columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
contextMenu: contextMenuSettings,
outsideClickDeselects: false,
currentRowClassName: 'row-highlight',
afterOnCellMouseDown(event: MouseEvent, coords: any, TD: HTMLTableCellElement){
if (event.detail === 1 && event.button === 0) {
selectedRow.value = coords
const rowData = this.getSourceDataAtRow(coords.row)
if(rowData)hstRef?.value?.onCellMouseDownCallback(rowData)
}
},
afterChange(changes: any[], source: string) {
if (!changes || source === 'loadData' || source === 'updateId') return
if(!validatorRow(this, changes, columns)) return;
const row = changes[0][0]
const rowData = this.getSourceDataAtRow(row)
const targetRowData = this.getSourceDataAtRow(targetId.value)
// 排除 sortOrder 字段,序号只显示不保存
const data = {
id: rowData.id ?? null,
calcBaseRateCatalogId: hstRef?.value?.calcBaseRateCatalogId ?? null,
parentId: targetRowData?.id ?? null,
name: rowData.name,
sortOrder: rowData.sortOrder,
// attributes: {}
}
console.log('rowData', rowData, data)
if (rowData.id == null) {
createDirectoryNode(data).then(res => {
hstRef?.value?.loadList()
ElMessage.success('新增成功')
}).catch(err => {
console.error('新增失败', err)
})
} else {
updateDirectoryNode(data).then(res => {
ElMessage.success('更新成功')
}).catch(err => {
console.error('更新失败', err)
})
}
}
}
export const contextMenuItems = [
{
key: 'insert',
name: '插入目录',
callback: (hotInstance: any) => {
if(!hstRef.value.calcBaseRateCatalogId) {
ElMessage.error('请选择左侧树数据')
return;
}
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
}
},
]

View File

@@ -0,0 +1,103 @@
import { ref } from 'vue';
import type { Ref } from 'vue';
import { ElMessage } from 'element-plus';
import { createRateItem, updateRateItem, deleteRateItem } from '#/api/database/interface/cardinalRate';
import { validatorRow } from '#/components/db-hst/validator';
const targetId = ref();
let hstRef = ref();
export function init(_hstRef: Ref<any>){
hstRef = _hstRef
}
const columns = [
{ type: 'text', data: 'sortOrder', title: '序号', width: 60 },
{ type: 'text', data: 'name', title: '名称', required: true },
{ type: 'text', data: 'rate', title: '费率' },
// { type: 'text', data: 'calcBase', title: '计算基数' },
{ type: 'text', data: 'remark', title: '备注' },
]
const contextMenuSettings = {
// callback(key, selection, clickEvent) {
// // Common callback for all options
// console.log(key, selection, clickEvent);
// },
items: {
'row_above': {},
'row_below': {},
'remove_row': {
async callback(key: string, selection: Selection[], clickEvent: MouseEvent){
const row = selection[0].start.row;
const rowData = this.getSourceDataAtRow(row)
// console.log(row,rowData)
this.alter('remove_row', row, 1);
if(rowData && rowData.id){
await deleteRateItem(rowData.id)
ElMessage.success('删除成功')
}
}
},
// 'space2': '---------',
// 'option4': {
// name() {
// return '4444';
// },
// callback (key: string, selection: Selection[], clickEvent: MouseEvent) {
// // this.alter('insert_row', coords.row + 1)
// }
// },
}
};
export let settings = {
data: [],
// dataSchema: initSchema(columns),
colWidths: 100,
columns: columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
contextMenu: contextMenuSettings,
outsideClickDeselects: false,
currentRowClassName: 'row-highlight',
afterChange(changes: any[], source: string) {
if (!changes || source === 'loadData' || source === 'updateId') return
if(!validatorRow(this, changes, columns)) return;
const row = changes[0][0]
const rowData = this.getSourceDataAtRow(row)
// 排除 sortOrder 字段,序号只显示不保存
const data = {
...rowData,
calcBaseRateDirectoryId: hstRef?.value?.calcBaseRateDirectoryId ?? null,
// attributes: {}
}
console.log('rowData', rowData, data)
if (rowData.id == null) {
createRateItem(data).then(res => {
hstRef?.value?.loadList()
ElMessage.success('新增成功')
}).catch(err => {
console.error('新增失败', err)
})
} else {
updateRateItem(data).then(res => {
ElMessage.success('更新成功')
}).catch(err => {
console.error('更新失败', err)
})
}
}
}
export const contextMenuItems = [
{
key: 'insert',
name: '插入目录',
callback: (hotInstance: any) => {
if(!hstRef.value.calcBaseRateDirectoryId) {
ElMessage.error('请选择左侧树数据')
return;
}
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
}
},
]

View File

@@ -0,0 +1,166 @@
// import {
// createCatalogNode,
// deleteCatalogNode,
// type CardinalRateApi,
// } from '#/api/database/interface/cardinalRate';
import {
createQuotaCatalogItem,
deleteQuotaCatalogItem
} from '#/api/database/quota/index';
import { ElMessage } from 'element-plus';
type MenuCallback = (command: string, node: any, data: any, treeInstance: any) => Promise<void>;
type MenuItem = {
key: string;
label: string;
divided?: boolean;
callback: MenuCallback;
};
type NodeConfig = {
nodeType: string;
name: string;
codePrefix: string;
};
// 生成唯一代码
const generateCode = (prefix: string): string => `${prefix}-${Date.now()}`;
// 节点配置
const ROOT_CONFIG: NodeConfig = { nodeType: 'root', name: '根节点', codePrefix: 'ROOT' };
const PROVINCE_CONFIG: NodeConfig = { nodeType: 'province', name: '省市', codePrefix: 'PROVINCE' };
const CONTENT_CONFIG: NodeConfig = { nodeType: 'content', name: '清单', codePrefix: 'CONTENT' };
const MAJORS_CONFIG: NodeConfig = { nodeType: 'majors', name: '专业', codePrefix: 'MAJORS' };
// 创建菜单项
const createMenuItem = (key: string, label: string, callback: MenuCallback, divided?: boolean): MenuItem => ({
key,
label,
callback,
...(divided && { divided }),
});
// 添加根节点回调
const createAddRootCallback = (config: NodeConfig): MenuCallback => {
return async (_command, _node, _data, treeInstance) => {
const source = {
code: generateCode(config.codePrefix),
name: config.name,
nodeType: config.nodeType,
sortOrder: treeInstance.getData().length + 1,
attributes: {},
};
const res = await createQuotaCatalogItem(source);
treeInstance.addData({
id: String(res.id),
label: source.name,
children: [],
...source,
});
ElMessage.success('添加成功');
};
};
// 添加子节点回调
const createAddChildCallback = (config: NodeConfig): MenuCallback => {
return async (_command, _node, data, treeInstance) => {
const source = {
parentId: data.id,
code: generateCode(config.codePrefix),
name: config.name,
nodeType: config.nodeType,
sortOrder: (data.children?.length || 0) + 1,
attributes: {},
};
const res = await createQuotaCatalogItem(source);
treeInstance.addData({
id: String(res),
label: source.name,
children: [],
...source,
});
ElMessage.success('添加成功');
};
};
// 插入节点回调
const createInsertCallback = (config: NodeConfig, position: 'above' | 'below'): MenuCallback => {
return async (_command, node, data, treeInstance) => {
const source = {
parentId: node.parent?.data?.id || null,
code: generateCode(config.codePrefix),
name: config.name,
nodeType: config.nodeType,
sortOrder: data.sortOrder || 1,
attributes: {},
};
const res = await createQuotaCatalogItem(source);
const insertMethod = position === 'above' ? 'insertAbove' : 'insertBelow';
treeInstance[insertMethod]({
id: String(res),
label: source.name,
children: [],
...source,
});
ElMessage.success('插入成功');
};
};
// 删除节点回调
const createDeleteCallback = (): MenuCallback => {
return async (_command, _node, data, treeInstance) => {
await deleteQuotaCatalogItem(data.id);
treeInstance.removeData();
ElMessage.success('删除成功');
};
};
// 根节点菜单 - 空树时显示
export const rootMenus = [
// {
// key: 'add-root',
// label: '添加根节点',
// callback: createAddRootCallback(ROOT_CONFIG),
// },
];
// 节点层级菜单
export const nodeMenus = [
// {
// level: 1, // 根节点层级
// items: [
// createMenuItem('add-province', '添加省市', createAddChildCallback(PROVINCE_CONFIG)),
// createMenuItem('remove-root', '删除', createDeleteCallback(), true),
// ],
// },
// {
// level: 2, // 省市节点层级
// items: [
// createMenuItem('add-province-above', '上方添加省市', createInsertCallback(PROVINCE_CONFIG, 'above')),
// createMenuItem('add-province-below', '下方添加省市', createInsertCallback(PROVINCE_CONFIG, 'below')),
// createMenuItem('add-content', '添加清单', createAddChildCallback(CONTENT_CONFIG)),
// createMenuItem('remove-province', '删除', createDeleteCallback(), true),
// ],
// },
{
level: 3, // 内容节点层级(叶子节点)
items: [
// createMenuItem('add-content-above', '上方添加清单', createInsertCallback(CONTENT_CONFIG, 'above')),
// createMenuItem('add-content-below', '下方添加清单', createInsertCallback(CONTENT_CONFIG, 'below')),
createMenuItem('add-cardinal-rate', '添加专业类别', createAddChildCallback(MAJORS_CONFIG)),
// createMenuItem('remove-content', '删除', createDeleteCallback(), true),
],
},
{
level: 4, // 内容节点层级(叶子节点)
items: [
createMenuItem('add-cardinal-rate-above', '上方添加专业类别', createInsertCallback(MAJORS_CONFIG, 'above')),
createMenuItem('add-cardinal-rate-below', '下方添加专业类别', createInsertCallback(MAJORS_CONFIG, 'below')),
createMenuItem('remove-cardinal-rate', '删除', createDeleteCallback(), true),
],
},
];

View File

@@ -1,204 +1,126 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch,computed, readonly } from 'vue'
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch, computed, readonly } from 'vue'
import { Page } from '@vben/common-ui';
import { ElSplitter,ElSplitterPanel,ElCard } from 'element-plus';
import { getIndustryOptions } from '#/api/database/interface/project';
import { ElSplitter, ElSplitterPanel, ElCard } from 'element-plus';
import { useElementSize } from '@vueuse/core'
import { DbTree } from '#/components/db-tree';
import { DbHst } from '#/components/db-hst';
import { handleRowOperation, codeRenderer } from '#/components/db-hst/tree'
// import { sourceDataObject } from '#/components/db-hst/mockData'
const containerRef = ref<HTMLElement | null>(null)
const { height: containerHeight } = useElementSize(containerRef)
const topContainerRef = ref<HTMLElement | null>(null)
const { height: topContainerHeight } = useElementSize(topContainerRef)
const bottomContainerRef = ref<HTMLElement | null>(null)
const { height: bottomContainerHeight } = useElementSize(bottomContainerRef)
import type { DetailedSettings } from 'handsontable/plugins/contextMenu';
type Tree = { id: string; label: string; children?: Tree[] }
const categoryTreeData = ref<Tree[]>([
{
id: '1',
label: '行业总类',
children: [
{
id: '2',
label: '广东',
children: [
{ id: '3', label: '行业1' },
{ id: '4', label: '行业2' },
{ id: '5', label: '行业3' }
]
}
]
},
{
id: '11',
label: '行业2',
children: [
{
id: '12',
label: '广西',
children: [
{ id: '13', label: '行业5' },
{ id: '14', label: '行业6' },
{ id: '15', label: '行业7' }
]
}
]
}
])
const tagRenderer = (instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) => {
// 清空单元格内容
td.innerHTML = ''
td.style.padding = '4px'
td.style.overflow = 'visible'
// 获取当前单元格的标签数据
const getCurrentTags = (): string[] => {
const currentValue = instance.getDataAtCell(row, col)
// console.log(currentValue)
if (typeof currentValue === 'string') {
return currentValue ? currentValue.split(',').map(t => t.trim()).filter(t => t) : []
} else if (Array.isArray(currentValue)) {
return [...currentValue]
}
return []
}
// 创建容器
const container = document.createElement('div')
container.style.cssText = 'display: flex; flex-wrap: wrap; gap: 4px; align-items: center; min-height: 24px;'
// 渲染标签
const renderTags = () => {
const tags = getCurrentTags()
container.innerHTML = ''
tags.forEach((tag, index) => {
const tagEl = document.createElement('span')
tagEl.style.cssText = `
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: #f0f0f0;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
gap: 4px;
`
tagEl.textContent = tag
// 删除按钮
const closeBtn = document.createElement('span')
closeBtn.innerHTML = '×'
closeBtn.style.cssText = `
cursor: pointer;
font-size: 14px;
font-weight: bold;
color: #999;
margin-left: 2px;
`
closeBtn.onmouseover = () => closeBtn.style.color = '#333'
closeBtn.onmouseout = () => closeBtn.style.color = '#999'
closeBtn.onclick = (e) => {
e.stopPropagation()
const currentTags = getCurrentTags()
currentTags.splice(index, 1)
instance.setDataAtCell(row, col, currentTags.join(','))
renderTags()
}
tagEl.appendChild(closeBtn)
container.appendChild(tagEl)
})
// 添加输入框
const inputWrapper = document.createElement('span')
inputWrapper.style.cssText = 'display: inline-flex; align-items: center;'
const input = document.createElement('input')
input.type = 'text'
input.placeholder = '按Enter回车键添加'
//border: 1px solid #d9d9d9;
input.style.cssText = `
const categoryTreeData = ref<any[]>([])
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
outline: none;
width: 100%;
`
input.onfocus = () => {
input.style.borderColor = '#409eff'
}
input.onblur = () => {
input.style.borderColor = '#d9d9d9'
// 失去焦点时添加标签
if (input.value.trim()) {
const newTag = input.value.trim()
const currentTags = getCurrentTags()
console.log('添加前的标签:', currentTags)
if (!currentTags.includes(newTag)) {
currentTags.push(newTag)
console.log('添加后的标签:', currentTags)
instance.setDataAtCell(row, col, currentTags.join(','))
input.value = ''
renderTags()
} else {
input.value = ''
}
}
}
input.onkeydown = (e) => {
// 保留 Enter 键功能,按 Enter 也可以添加
if (e.key === 'Enter' && input.value.trim()) {
e.preventDefault()
input.blur() // 触发失去焦点事件
}
}
inputWrapper.appendChild(input)
container.appendChild(inputWrapper)
}
renderTags()
td.appendChild(container)
return td
const treeProps = {
value: 'id',
label: 'name',
children: 'children',
}
const bottomColumns = ref<any[]>([
{type:'text',data:'code',title:'序号'},
{type:'text',data:'name',title:'名称'},
{type:'text',data:'content',title:'内容',width: 300, renderer: tagRenderer, readOnly:true},
{type:'text',data:'spec',title:'代'},
const columns = [
{ type: 'db.nestedRows', data: 'number', title: '序号', width: 100 },
{ type: 'text', data: 'name', title: '名称' },
{ type: 'text', data: 'content', title: '内容', width: 700, readOnly: true },//renderer: tagRenderer
{ type: 'text', data: 'code', title: '代' },
])
]
// const colHeaders = ref<string[]>(topColHeaders)
const bottomHstRef = ref<any>(null)
const hstRef = ref<any>(null)
const contextMenuSettings: DetailedSettings = {
callback(key, selection, clickEvent) {
// Common callback for all options
console.log(key, selection, clickEvent);
// dataManager.value.setData(dataManager.value.getData());
// dataManager.value.updateWithData(dataInstance);
// console.log(dataManager.value,dataManager.value.getData());
// dataManager.value.updateWithData(dataManager.value.getData());
},
items: {
'row_above': {},
'row_below': {},
'__add_child': 'add_child',//nestedRows: true 单独设置'add_child'无法显示只能在items中设置
'space1': '---------',
'remove_row': {},
// 'space2': '---------',
// 'option4': {
// name() {
// return '4444';
// },
// callback (key: string, selection: Selection[], clickEvent: MouseEvent) {
// // this.alter('insert_row', coords.row + 1)
// }
// },
}
};
const bootomMock = ()=>{
// 生成模拟数据
const mockData = Array.from({ length: 30 }, (_, index) => ({
code: `DTL${String(index + 1).padStart(6, '0')}`,
name: `明细项目${index + 1}`,
content: ``,
spec: `规格${index + 1}`,
}))
return mockData;
}
let bottomDbSettings = {
columns: bottomColumns.value,
data: [],
// dataSchema: initSchema(columns),
colWidths: 150,
columns: columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
contextMenu: contextMenuSettings,
outsideClickDeselects: false,
currentRowClassName: 'row-highlight',
// afterOnCellMouseDown(event: MouseEvent, coords: any, TD: HTMLTableCellElement){
// if (event.detail === 1 && event.button === 0) {
// }
// },
afterChange(changes, source) {
if (!changes || source === 'loadData' || source === 'updateId') return
// selectedRow.value = null
// applyAutoFitColum(this, changes)
// if (!validatorRow(this, changes)) return
const row = changes[0][0]
const rowData = this.getSourceDataAtRow(row)
// 排除 sortOrder 字段,序号只显示不保存
const { sortOrder, ...dataWithoutSortOrder } = rowData
let sendData: any = {
}
console.log('rowData', rowData, sendData)
if (rowData.id == null) {
// createItemTree(sendData).then(res => {
// this.setDataAtRowProp(row, 'id', res, 'updateId')
// ElMessage.success('新增成功')
// }).catch(err => {
// console.error('新增失败', err)
// })
} else {
// updateItemTree(sendData).then(res => {
// console.log('updateResourceItems', res)
// }).catch(err => {
// console.error('更新失败', err)
// })
}
}
}
const categoryHandleSelect = (node: Tree) => {
console.log('categoryhandleSelect',node)
const contextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
// handleRowOperation(hotInstance, 'append')
// // 等待 DOM 更新后重新渲染以应用验证样式
// nextTick(() => {
// hotInstance.render()
// })
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
}
},
]
const categoryHandleSelect = async (payload: { node: any, data: any, event: MouseEvent }) => {
const { node, data, event } = payload
console.log('categoryhandleSelect', node, data, event)
}
const detailHandleSelect = (node: Tree) => {
@@ -207,23 +129,46 @@ const detailHandleSelect = (node: Tree) => {
// // topHstRef.value.loadData(topHstData.value)
// }
}
function onBottomHeight(height: number){
if (bottomHstRef.value?.hotInstance) {
bottomHstRef.value.hotInstance.updateSettings({
height: height-15
})
bottomHstRef.value.loadData(bootomMock())
bottomHstRef.value.hotInstance.render()
console.log('onResizeEnd-bottomHstRef',height);
const loadTreeData = async () => {
try {
const res = await getIndustryOptions()
categoryTreeData.value = res || []
} catch (error) {
console.error('加载行业树数据失败:', error)
}
}
onMounted(() => {
setTimeout(() => {
onBottomHeight(bottomContainerHeight.value)
}, 200);
loadTreeData()
// load(bootomMock())
// const mockData: any[] = [
// {
// __children: [
// { code: '1-1', name: '项目编号', content: '', spec: '', type: 'text' },
// { code: '1-2', name: '项目名称', content: '', spec: '', type: 'text' },
// { code: '1-3', name: '行业', content: '建筑,市政,水利', spec: '', type: 'select' },
// { code: '1-4', name: '信息价专业', content: '土建,安装,装饰', spec: '', type: 'select' },
// { code: '1-5', name: '信息价地区', content: '广东,广西,湖南', spec: '', type: 'select' },
// { code: '1-6', name: '信息价时间', content: '', spec: '', type: 'text' },
// { code: '1-7', name: '造价类型', content: '概算,预算,结算', spec: '', type: 'select' },
// { code: '1-8', name: '工作内容', content: '设计,施工,监理', spec: '', type: 'checkbox' },
// { code: '1-9', name: '务与人员', content: '', spec: '', type: 'checkbox' },
// ],
// code: '1',
// name: '工程信息',
// content: '',
// spec: '',
// type: 'text',
// }
// ]
// setTimeout(() => {
// hstRef?.value.nestedRowsLoadData(mockData)
// console.log('nestedRows', hstRef?.value.hotInstance?.getPlugin('nestedRows').enabled)
// }, 400)
})
onUnmounted(() => {
})
@@ -231,22 +176,22 @@ onUnmounted(() => {
</script>
<template>
<Page auto-content-height>
<ElSplitter >
<Page auto-content-height>
<ElSplitter>
<ElSplitterPanel size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef">
<DbTree :height="containerHeight" :data="categoryTreeData" @select="categoryHandleSelect" :defaultExpandedKeys="2" :search="false" />
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<!-- <DbTree :data="categoryTreeData" @select="categoryHandleSelect"
:defaultExpandedKeys="2" :search="false" /> -->
<DbTree ref="detailTreeRef" :tree-data="categoryTreeData" :tree-props="treeProps" @node-click="categoryHandleSelect" :defaultExpandedLevel="2"/>
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full">
<DbHst ref="bottomHstRef" :settings="bottomDbSettings"></DbHst>
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbHst ref="hstRef" :settings="bottomDbSettings" :contextMenuItems="contextMenuItems"></DbHst>
</ElCard>
</ElSplitterPanel>
</ElSplitter>
</Page>
</Page>
</template>
<style lang="css">
</style>
<style lang="css"></style>

View File

@@ -1,77 +1,65 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch,computed, readonly } from 'vue'
import { Page } from '@vben/common-ui';
import { ElSplitter,ElSplitterPanel,ElCard,ElTabs,ElTabPane } from 'element-plus';
import { useElementSize } from '@vueuse/core'
import { getQuotaCatalogItemTree } from '#/api/database/quota/index';
import { DbTree } from '#/components/db-tree';
import { DbHst } from '#/components/db-hst';
import { handleRowOperation, codeRenderer } from '#/components/db-hst/tree'
// import { sourceDataObject } from '#/components/db-hst/mockData'
import { Page } from '@vben/common-ui';
import { ElCard, ElSplitter, ElSplitterPanel } from 'element-plus';
import { onMounted, onUnmounted, ref } from 'vue';
// 导入子组件
import FieldName from './unit/FieldName.vue'
import SubItem from './unit/SubItem.vue'
import MeasureItem from './unit/MeasureItem.vue'
import OtherItem from './unit/OtherItem.vue'
import UnitSummary from './unit/UnitSummary.vue'
import VariableSettings from './unit/VariableSettings.vue'
import MaterialField from './unit/MaterialField.vue'
const containerRef = ref<HTMLElement | null>(null)
const { height: containerHeight } = useElementSize(containerRef)
// const topContainerRef = ref<HTMLElement | null>(null)
// const { height: topContainerHeight } = useElementSize(topContainerRef)
// const bottomContainerRef = ref<HTMLElement | null>(null)
// const { height: bottomContainerHeight } = useElementSize(bottomContainerRef)
// const bottomPanelHeight = ref<number>(0)
type Tree = { id: string; label: string; children?: Tree[] }
const categoryTreeData = ref<Tree[]>([
{
id: '1',
label: '行业总类',
children: [
{
id: '2',
label: '广东',
children: [
{ id: '3', label: '行业1' },
{ id: '4', label: '行业2' },
{ id: '5', label: '行业3' }
]
}
]
},
{
id: '11',
label: '行业2',
children: [
{
id: '12',
label: '广西',
children: [
{ id: '13', label: '行业5' },
{ id: '14', label: '行业6' },
{ id: '15', label: '行业7' }
]
}
]
import VariableSettings from './unit/VariableSettings.vue';
type Tree = { id: string; label: string; children?: Tree[];[key: string]: any }
const treeData = ref<Tree[]>([])
const selectedNode = ref<any>(null)
// 转换后端数据为树形结构(后端已通过 exclude: 'rate_mode' 过滤掉费率模式节点)
const transformTreeData = (data: any[]): Tree[] => {
if (!data || !Array.isArray(data)) {
console.warn('transformTreeData: 数据不是数组', data)
return []
}
])
// const colHeaders = ref<string[]>(topColHeaders)
const activeTab = ref('fieldName')
const categoryHandleSelect = (node: Tree) => {
console.log('categoryhandleSelect',node)
return data.map(item => {
const treeNode: Tree = {
...item,
id: String(item.id),
label: item.name || item.label || '未命名',
}
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
treeNode.children = transformTreeData(item.children)
}
return treeNode
})
}
const detailHandleSelect = (node: Tree) => {
// if (topHstRef.value && typeof topHstRef.value.loadData === 'function') {
// // console.log('hstData.value',hstData.value)
// // topHstRef.value.loadData(topHstData.value)
// }
// 加载定额专业树(排除 rate_mode 节点,与定额基价页面一致)
const loadCategoryTree = async () => {
try {
const res = await getQuotaCatalogItemTree({ exclude: 'rate_mode,majors,fields_majors' })
console.log('定额专业树原始数据:', res)
treeData.value = transformTreeData(res)
console.log('定额专业树转换后数据:', treeData.value)
} catch (error) {
console.error('加载定额专业树失败:', error)
}
}
// 处理树节点点击(只有 specialty 节点才显示变量设置)
const handleNodeClick = (payload: { node: any, data: any, event: MouseEvent }) => {
const { node, data } = payload
console.log('节点点击:', node, data)
// 只有定额专业节点才显示变量设置
if (data.nodeType === 'specialty') {
selectedNode.value = data
} else {
selectedNode.value = null
}
}
onMounted(() => {
loadCategoryTree()
})
onUnmounted(() => {
})
@@ -80,50 +68,29 @@ onUnmounted(() => {
<template>
<Page auto-content-height>
<!-- <DbHst ref="bottomHstRef" :settings="bottomDbSettings"></DbHst> -->
<ElSplitter >
<ElSplitterPanel collapsible size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef">
<DbTree :height="containerHeight" :data="categoryTreeData" @select="categoryHandleSelect" :defaultExpandedKeys="2" :search="false" />
<ElSplitter>
<ElSplitterPanel size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbTree
ref="treeRef"
:tree-data="treeData"
:draggable="false"
:default-expanded-level="3"
@node-click="handleNodeClick"
/>
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel collapsible :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full">
<ElTabs v-model="activeTab" type="border-card" class="h-full">
<ElTabPane label="字段名称" name="fieldName" lazy>
<FieldName :height="containerHeight"/>
</ElTabPane>
<ElTabPane label="分部分项" name="subItem" lazy>
<!-- <SubItem /> -->
<FieldName :height="containerHeight"/>
</ElTabPane>
<ElTabPane label="措施项目" name="measureItem" lazy>
<!-- <MeasureItem /> -->
<FieldName :height="containerHeight"/>
</ElTabPane>
<ElTabPane label="其他项目" name="otherItem" lazy>
<!-- <OtherItem /> -->
<FieldName :height="containerHeight"/>
</ElTabPane>
<ElTabPane label="单位汇总" name="unitSummary" lazy>
<!-- <UnitSummary /> -->
<FieldName :height="containerHeight"/>
</ElTabPane>
<ElTabPane label="变量设置" name="variableSettings" lazy>
<VariableSettings />
</ElTabPane>
<ElTabPane label="工料机字段" name="materialField" lazy>
<MaterialField />
</ElTabPane>
</ElTabs>
<ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full">
<VariableSettings :selectedNode="selectedNode"/>
</ElCard>
</ElSplitterPanel>
</ElSplitter>
</Page>
</template>
<style lang="css">
<style lang="scss">
.el-tabs--border-card>.el-tabs__content {
padding: 5px 0;
padding: 0 !important;
}
</style>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { DbHst } from '#/components/db-hst';
import { ElButton, ElDialog, ElInput, ElTabPane, ElTabs } from 'element-plus';
import { computed, ref, watch } from 'vue';
import {
createDivisionSettings,
createMeasureSettings,
createOtherSettings,
createUnitSummarySettings,
} from './cardinal';
const props = defineProps<{
modelValue: boolean
rowData?: any
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success', value: any): void
}>()
const isVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const activeTab = ref('division')
const formulaInput = ref('')
const settingsByTab = {
division: createDivisionSettings({ formulaInput }),
measure: createMeasureSettings({ formulaInput }),
other: createOtherSettings({ formulaInput }),
supplement: createUnitSummarySettings({ formulaInput }),
} as const
const handleSubmit = () => {
emit('success', formulaInput.value)
}
watch(
() => props.modelValue,
(visible) => {
if (!visible) {
formulaInput.value = ''
return
}
activeTab.value = 'division'
const currentCardinal = props.rowData?.cardinal
formulaInput.value = currentCardinal == null ? '' : String(currentCardinal)
},
)
</script>
<template>
<ElDialog v-model="isVisible" title="费用代码设置" width="60%" :close-on-click-modal="false" body-class="fee-body-height">
<div class="h-full w-full flex flex-col">
<!-- <div style="color: #909399; font-size: 13px; margin-bottom: 8px;">
提示: 现在书写的公式只能调用在当前项目以前的取费和系统参数
</div> -->
<ElInput v-model="formulaInput" placeholder="请输入公式" clearable />
<ElTabs v-model="activeTab" size="small" class="!p-0 h-full w-full fee-tabs">
<!-- <ElTabPane label="全局费用" name="global">
<ElTable :data="tabTableData.global" border style="width: 100%" max-height="400">
<ElTableColumn prop="seq" label="序号" width="80" />
<ElTableColumn prop="code" label="费用代号" width="150" />
<ElTableColumn prop="name" label="费用名称" min-width="200" />
<ElTableColumn prop="value" label="值" width="120" />
</ElTable>
</ElTabPane> -->
<ElTabPane label="分部分项" name="division" lazy class="h-full">
<div class="h-full w-full flex flex-col">
<DbHst :settings="settingsByTab.division" v-if="isVisible" />
</div>
</ElTabPane>
<ElTabPane label="措施费" name="measure" lazy class="h-full">
<DbHst :settings="settingsByTab.measure" v-if="isVisible" />
</ElTabPane>
<ElTabPane label="其他项目" name="other" lazy class="h-full">
<DbHst :settings="settingsByTab.other" v-if="isVisible" />
</ElTabPane>
<ElTabPane label="单位汇总" name="supplement" lazy class="h-full">
<DbHst :settings="settingsByTab.supplement" v-if="isVisible" />
</ElTabPane>
</ElTabs>
</div>
<template #footer>
<ElButton @click="isVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">确定</ElButton>
</template>
</ElDialog>
</template>
<style lang="scss" >
.fee-body-height{
height: 500px;
}
.fee-tabs{
.el-tabs__header{
margin: 0;
}
}
</style>

View File

@@ -1,150 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { DbHst } from '#/components/db-hst'
import { handleRowOperation, codeRenderer } from '#/components/db-hst/tree'
const props = defineProps<{
height?: number
}>()
const hstRef = ref<any>(null)
const columns = ref<any[]>([
{ type: 'text', data: 'seq', title: '序', width: 40 },
{ type: 'text', data: 'code', title: '编码', renderer: codeRenderer, code:true },
{ type: 'text', data: 'category', title: '类别' },
{ type: 'text', data: 'name', title: '名称' },
{ type: 'text', data: 'feature', title: '项目特征' },
{ type: 'text', data: 'locked', title: '锁定' },
{ type: 'text', data: 'unitPrice', title: '综合单价' },
{ type: 'text', data: 'unit', title: '单位' },
{ type: 'text', data: 'quantity', title: '工程量' },
{ type: 'text', data: 'comprehensivePrice', title: '综合单价' },
{ type: 'text', data: 'totalPrice', title: '综合合价' },
{ type: 'text', data: 'remark', title: '备注' },
])
let rowSchema: any = {}
// 根据 columns 的 data 字段生成对象结构
columns.value.forEach((col: any) => {
if (col.data ) {
rowSchema[col.data] = null
}
})
const mockData = (() => {
const data: any[] = []
// 生成5个父级数据
for (let i = 1; i <= 5; i++) {
const parent = {
seq: `${i}`,
code: `CODE${String(i).padStart(6, '0')}`,
category: i % 3 === 0 ? '分部分项' : i % 3 === 1 ? '措施项目' : '其他项目',
name: `项目名称${i}`,
feature: `特征描述${i}`,
locked: i % 2 === 0 ? '是' : '否',
unitPrice: `${i * 100}`,
unit: 'm²',
quantity: `${i * 10}`,
comprehensivePrice: `${i * 100}`,
totalPrice: `${i * 1000}`,
remark: `备注${i}`,
level: String(i - 1),
__children: [] as any[]
}
// 为每个父级生成3-5个子级
const childCount = Math.floor(Math.random() * 3) + 3
for (let j = 1; j <= childCount; j++) {
const child = {
seq: `${i}.${j}`,
code: `CODE${String(i).padStart(6, '0')}-${String(j).padStart(3, '0')}`,
category: '子项',
name: `子项目${i}-${j}`,
feature: `子特征${j}`,
locked: '否',
unitPrice: `${j * 50}`,
unit: 'm²',
quantity: `${j * 5}`,
comprehensivePrice: `${j * 50}`,
totalPrice: `${j * 250}`,
remark: `子备注${j}`,
level: `${i - 1}.${j - 1}`,
__children: []
}
parent.__children.push(child)
}
data.push(parent)
}
return data
})()
const settings = {
data: mockData,
dataSchema: rowSchema,
colWidths: 120,
columns: columns.value,
rowHeaders: false,
nestedRows: true,
bindRowsWithHeaders: true,
contextMenu: {
items: {
custom_row_above: {
name: '在上方插入行',
callback: function() {
handleRowOperation(this, 'above')
}
},
custom_row_below: {
name: '在下方插入行',
callback: function() {
handleRowOperation(this, 'below')
}
},
separator1: '---------',
custom_add_child: {
name: '添加子行',
callback: function() {
handleRowOperation(this, 'child')
}
},
separator2: '---------',
remove_row: {
name: '删除行',
callback: function() {
handleRowOperation(this, 'delete')
}
},
// separator3: '---------',
// undo: {},
// redo: {}
}
},
}
watch(
() => props.height,
(newHeight) => {
console.log('MarketMaterials newHeight', newHeight)
if (newHeight && hstRef.value?.hotInstance) {
hstRef.value.hotInstance.updateSettings({
height: newHeight - 50 - 15// 减去 tabs 头部和 padding 的高度,滚动条
})
hstRef.value.hotInstance.render()
}
},
{ immediate: true }
)
onMounted(() => {
setTimeout(() => {
// if (hstRef.value) {
// hstRef.value.loadData(mockData)
// }
}, 100)
})
</script>
<template>
<div class="h-full">
<DbHst ref="hstRef" :settings="settings" />
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { DbHst } from '#/components/db-hst'
const hstRef = ref<any>(null)
type ColumnConfig = {
type: 'text' | 'checkbox'
data: string
title: string
width?: number
}
const columns: ColumnConfig[] = [
{ type: 'text', data: 'seq', title: '序号', width: 60 },
{ type: 'text', data: 'category', title: '类别', width: 120 },
{ type: 'text', data: 'name', title: '名称', width: 260 },
{ type: 'text', data: 'feature', title: '项目特征', width: 220 },
{ type: 'checkbox', data: 'isLockedUnitPrice', title: '锁定综合单价', width: 120 },
{ type: 'text', data: 'unit', title: '单位', width: 80 },
{ type: 'text', data: 'quantity', title: '工程量', width: 100 },
{ type: 'text', data: 'unitPrice', title: '综合单价', width: 110 },
{ type: 'text', data: 'totalPrice', title: '综合合价', width: 120 },
]
const createRowSchema = (cols: ColumnConfig[]) => {
const schema: Record<string, null> = {}
cols.forEach((col) => {
if (col.data) schema[col.data] = null
})
return schema
}
const rowSchema = createRowSchema(columns)
const mockData = Array.from({ length: 20 }, (_, index) => {
const rowNumber = index + 1
return {
seq: String(rowNumber),
category: `类别${((rowNumber - 1) % 5) + 1}`,
name: `分部分项${rowNumber}`,
feature: `特征${((rowNumber - 1) % 3) + 1}`,
isLockedUnitPrice: false,
unit: 'm²',
quantity: String((rowNumber * 10).toFixed(2)),
unitPrice: String((rowNumber * 12.5).toFixed(2)),
totalPrice: String((rowNumber * 10 * 12.5).toFixed(2)),
}
})
const settings = {
data: mockData,
dataSchema: rowSchema,
colWidths: 120,
columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
className: 'htCenter htMiddle',
contextMenu: {
items: {
row_above: {},
row_below: {},
separator2: '---------',
remove_row: {
name: '删除行',
callback: function (this: any, key: string, selection: any[]) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
if (rowData?.id) {
this.alter('remove_row', selectedRow, 1)
} else {
this.alter('remove_row', selectedRow, 1)
}
},
},
},
},
}
</script>
<template>
<div class="h-full w-full flex flex-col">
<DbHst ref="hstRef" :settings="settings" />
</div>
</template>

View File

@@ -1,36 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { DbHst } from '#/components/db-hst'
const hstRef = ref<any>(null)
const columns = ref<any[]>([
{ type: 'text', data: 'code', title: '序号' },
{ type: 'text', data: 'name', title: '字段名称' },
{ type: 'checkbox', data: 'unit', title: '分部分项隐藏' },
{ type: 'checkbox', data: 'price', title: '措施项目隐藏' },
])
const settings = {
columns: columns.value,
}
const mockData = Array.from({ length: 20 }, (_, index) => ({
code: `MAT${String(index + 1).padStart(6, '0')}`,
name: `工料机${index + 1}`,
unit: false,
price: false,
}))
onMounted(() => {
setTimeout(() => {
if (hstRef.value) {
hstRef.value.loadData(mockData)
}
}, 100)
})
</script>
<template>
<div class="h-full">
<DbHst ref="hstRef" :settings="settings" />
</div>
</template>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { DbHst } from '#/components/db-hst'
const hstRef = ref<any>(null)
const columns = ref<any[]>([
{ type: 'text', data: 'seq', title: '序', width: 40 },
{ type: 'text', data: 'code', title: '字段名称',},
{ type: 'checkbox', data: 'category', title: '分部分项隐藏' },
{ type: 'checkbox', data: 'name', title: '措施项目隐藏' },
{ type: 'text', data: 'remark', title: '备注' },
])
let rowSchema: any = {}
// 根据 columns 的 data 字段生成对象结构
columns.value.forEach((col: any) => {
if (col.data ) {
rowSchema[col.data] = null
}
})
const mockData = (() => {
const data: any[] = []
// 生成5个父级数据
for (let i = 1; i <= 5; i++) {
const parent = {
seq: `${i}`,
code: `CODE${String(i).padStart(6, '0')}`,
category: false,
name: false,
feature: false,
locked: false,
remark: `备注${i}`,
level: String(i - 1),
__children: [] as any[]
}
data.push(parent)
}
return data
})()
const settings = {
data: mockData,
dataSchema: rowSchema,
colWidths: 120,
columns: columns.value,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
className: 'htCenter htMiddle',
contextMenu: {
items: {
row_above:{
},
row_below: {
},
separator2: '---------',
remove_row: {
name: '删除行',
callback: function(key: string, selection: any[]) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
if (rowData?.id) {
this.alter('remove_row', selectedRow, 1)
// deleteResourceMerged(rowData.id).then(res => {
// console.log('deleteResourceMerged', res)
// })
} else {
this.alter('remove_row', selectedRow, 1)
}
},
},
// separator3: '---------',
// undo: {},
// redo: {}
}
},
}
onMounted(() => {
setTimeout(() => {
// if (hstRef.value) {
// hstRef.value.loadData(mockData)
// }
}, 100)
})
</script>
<template>
<div class="h-full flex flex-col">
<DbHst ref="hstRef" :settings="settings" />
</div>
</template>

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { getCategoriesList } from '#/api/database/materials/root';
import { DbHst } from '#/components/db-hst';
import { ElButton, ElDialog, ElInput } from 'element-plus';
import { computed, nextTick, ref, watch } from 'vue';
const props = defineProps<{
modelValue: boolean
rowData?: any
catalogItemId?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success', value: string): void
}>()
const isVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const formulaInput = ref('')
const hstRef = ref<any>(null)
const tableData = ref<any[]>([])
const handleSubmit = () => {
emit('success', formulaInput.value)
isVisible.value = false
}
// 加载工料机总类数据
const loadCategoriesData = async () => {
try {
const res = await getCategoriesList()
// 转换数据格式,映射到表格列
tableData.value = (res || []).map((item: any, index: number) => ({
code: String(index + 1),
category: item.code || '',
taxFreeBaseCode: item.taxExclBaseCode || '',
taxIncludedBaseCode: item.taxInclBaseCode || '',
taxFreeCompileCode: item.taxExclCompileCode || '',
taxIncludedCompileCode: item.taxInclCompileCode || '',
}))
} catch (error) {
console.error('加载工料机总类失败:', error)
tableData.value = []
}
}
const columns = [
{ type: 'text', data: 'code', title: '序号', width: 60, readOnly: true },
{ type: 'text', data: 'category', title: '类别', readOnly: true },
{ type: 'text', data: 'taxFreeBaseCode', title: '除税基价代码', readOnly: true },
{ type: 'text', data: 'taxIncludedBaseCode', title: '含税基价代码', readOnly: true },
{ type: 'text', data: 'taxFreeCompileCode', title: '除税编制代码', readOnly: true },
{ type: 'text', data: 'taxIncludedCompileCode', title: '含税编制代码', readOnly: true },
]
const allowedSelectionDataKeys = new Set([
'taxFreeBaseCode',
'taxIncludedBaseCode',
'taxFreeCompileCode',
'taxIncludedCompileCode',
])
let settings = {
data: [],
colWidths: 150,
columns: columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
selectionMode: 'single',
className: 'htCenter',
afterSelection(row: number, col: number, row2: number, col2: number) {
if (row < 0 || col < 0) return
const selectedDataKey = columns?.[col]?.data
if (!selectedDataKey || !allowedSelectionDataKeys.has(selectedDataKey)) return
const cellValue = this.getDataAtCell(row, col)
if (cellValue == null) return
formulaInput.value += String(cellValue)
}
}
watch(
() => props.modelValue,
async (visible) => {
if (!visible) {
formulaInput.value = ''
return
}
// 加载工料机总类数据
await loadCategoriesData()
nextTick(() => {
setTimeout(() => {
if(hstRef?.value?.hotInstance) {
hstRef?.value?.hotInstance.loadData(tableData.value)
}
}, 200);
})
const currentCardinal = props.rowData?.cardinal
formulaInput.value = currentCardinal == null ? '' : String(currentCardinal)
},
{ immediate: true, deep: true },
)
</script>
<template>
<ElDialog v-model="isVisible" title="工料机总类" width="60%" :close-on-click-modal="false" body-class="materials-body-height">
<div class="h-full w-full flex flex-col">
<div>
<ElInput v-model="formulaInput" placeholder="请输入计算基数公式" clearable></ElInput>
</div>
<DbHst ref="hstRef" :settings="settings" v-if="isVisible"></DbHst>
</div>
<template #footer>
<ElButton @click="isVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">确定</ElButton>
</template>
</ElDialog>
</template>
<style lang="scss">
.materials-body-height{
height: 500px;
}
</style>

View File

@@ -1,38 +1,184 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, watch, nextTick } from 'vue'
import { DbHst } from '#/components/db-hst'
import CardinalDialog from './CardinalDialog.vue'
const hstRef = ref<any>(null)
const columns = ref<any[]>([
{ type: 'text', data: 'code', title: '编码' },
{ type: 'text', data: 'name', title: '措施项目名称' },
{ type: 'text', data: 'unit', title: '单位' },
{ type: 'text', data: 'amount', title: '金额' },
])
const isBaseDialogVisible = ref(false)
const currentRowData = ref<any>(null)
const currentRowIndex = ref<number | null>(null)
const settings = {
columns: columns.value,
const openBaseDialog = (rowData: any, row: number) => {
currentRowData.value = rowData == null ? null : { ...rowData, cardinal: rowData.base }
currentRowIndex.value = row
isBaseDialogVisible.value = true
}
const mockData = Array.from({ length: 20 }, (_, index) => ({
code: `MSR${String(index + 1).padStart(6, '0')}`,
name: `措施项目${index + 1}`,
unit: '项',
amount: `${(index + 1) * 1000}`,
}))
const handleBaseDialogSuccess = (formula: string) => {
const hot = hstRef.value?.hotInstance
if (!hot) return
if (currentRowIndex.value == null) return
onMounted(() => {
setTimeout(() => {
if (hstRef.value) {
hstRef.value.loadData(mockData)
}
}, 100)
hot.setDataAtRowProp(currentRowIndex.value, 'base', formula, 'baseDialog')
hot.render()
isBaseDialogVisible.value = false
}
type ColumnConfig = {
type: 'text' | 'checkbox'
data: string
title: string
width?: number
renderer?: any
}
const baseRenderer = (
instance: any,
td: HTMLTableCellElement,
row: number,
_col: number,
_prop: string | number,
value: any,
_cellProperties: any,
) => {
td.innerHTML = ''
td.style.padding = '0 8px'
td.style.display = 'flex'
td.style.alignItems = 'center'
td.style.justifyContent = 'space-between'
td.style.gap = '8px'
td.style.boxSizing = 'border-box'
const textSpan = document.createElement('span')
textSpan.textContent = value == null ? '' : String(value)
textSpan.style.flex = '1 1 auto'
textSpan.style.minWidth = '0'
textSpan.style.overflow = 'hidden'
textSpan.style.textOverflow = 'ellipsis'
textSpan.style.whiteSpace = 'nowrap'
const iconButton = document.createElement('button')
iconButton.type = 'button'
iconButton.title = '编辑基数'
iconButton.setAttribute('aria-label', '编辑基数')
iconButton.style.display = 'inline-flex'
iconButton.style.alignItems = 'center'
iconButton.style.justifyContent = 'center'
iconButton.style.width = '18px'
iconButton.style.height = '18px'
iconButton.style.flex = '0 0 auto'
iconButton.style.border = '0'
iconButton.style.background = 'transparent'
iconButton.style.padding = '0'
iconButton.style.cursor = 'pointer'
iconButton.style.color = '#909399'
iconButton.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><circle cx="5" cy="12" r="2" fill="currentColor" /><circle cx="12" cy="12" r="2" fill="currentColor" /><circle cx="19" cy="12" r="2" fill="currentColor" /></svg>'
iconButton.addEventListener('mousedown', (event) => {
event.preventDefault()
event.stopPropagation()
})
iconButton.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
const rowData = instance.getSourceDataAtRow?.(row)
openBaseDialog(rowData, row)
})
td.appendChild(textSpan)
td.appendChild(iconButton)
return td
}
const columns: ColumnConfig[] = [
{ type: 'text', data: 'seq', title: '序号', width: 60 },
{ type: 'text', data: 'code', title: '编码', width: 130 },
{ type: 'text', data: 'category', title: '类别', width: 120 },
{ type: 'checkbox', data: 'adjustment', title: '调整', width: 80 },
{ type: 'checkbox', data: 'reduction', title: '降效', width: 80 },
{ type: 'text', data: 'name', title: '名称', width: 260 },
{ type: 'text', data: 'feature', title: '项目特征', width: 220 },
{ type: 'text', data: 'specification', title: '规格', width: 140 },
{ type: 'text', data: 'unit', title: '单位', width: 80 },
{ type: 'text', data: 'quantity', title: '工程量', width: 100 },
{ type: 'text', data: 'liao', title: '聊', width: 90 },
{ type: 'checkbox', data: 'isExcludedIndicator', title: '不计指标', width: 90 },
{ type: 'text', data: 'base', title: '基数', width: 110, renderer: baseRenderer },
{ type: 'text', data: 'rate', title: '费率', width: 90 },
]
const createRowSchema = (cols: ColumnConfig[]) => {
const schema: Record<string, null> = {}
cols.forEach((col) => {
if (col.data) schema[col.data] = null
})
return schema
}
const rowSchema = createRowSchema(columns)
const mockData = Array.from({ length: 20 }, (_, index) => {
const rowNumber = index + 1
const quantity = rowNumber * 5
const base = rowNumber * 100
const rate = (rowNumber % 5) * 0.02
return {
seq: String(rowNumber),
code: `CS${String(rowNumber).padStart(6, '0')}`,
category: `类别${((rowNumber - 1) % 4) + 1}`,
adjustment: rowNumber % 3 === 0 ? true : false,
reduction: rowNumber % 4 === 0 ? true : false,
name: `措施项目${rowNumber}`,
feature: `特征${((rowNumber - 1) % 3) + 1}`,
specification: `规格${((rowNumber - 1) % 6) + 1}`,
unit: '项',
quantity: String(quantity.toFixed(2)),
liao: String((rowNumber * 1.2).toFixed(2)),
isExcludedIndicator: rowNumber % 7 === 0,
base: String(base.toFixed(2)),
rate: String((rate * 100).toFixed(2)) + '%',
}
})
const settings = {
data: mockData,
dataSchema: rowSchema,
colWidths: 120,
columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
className: 'htCenter htMiddle',
contextMenu: {
items: {
row_above: {},
row_below: {},
separator2: '---------',
remove_row: {
name: '删除行',
callback: function (this: any, key: string, selection: any[]) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
if (rowData?.id) {
this.alter('remove_row', selectedRow, 1)
} else {
this.alter('remove_row', selectedRow, 1)
}
},
},
},
},
}
</script>
<template>
<div class="h-full">
<div class="h-full w-full flex flex-col">
<DbHst ref="hstRef" :settings="settings" />
</div>
<CardinalDialog v-model="isBaseDialogVisible" :rowData="currentRowData" @success="handleBaseDialogSuccess" />
</template>

View File

@@ -1,38 +1,89 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, watch, nextTick } from 'vue'
import { DbHst } from '#/components/db-hst'
const hstRef = ref<any>(null)
const columns = ref<any[]>([
{ type: 'text', data: 'code', title: '编码' },
{ type: 'text', data: 'name', title: '其他项目名称' },
{ type: 'text', data: 'type', title: '类型' },
{ type: 'text', data: 'amount', title: '金额' },
])
const settings = {
columns: columns.value,
type ColumnConfig = {
type: 'text' | 'checkbox'
data: string
title: string
width?: number
}
const mockData = Array.from({ length: 20 }, (_, index) => ({
code: `OTH${String(index + 1).padStart(6, '0')}`,
name: `其他项目${index + 1}`,
type: '其他',
amount: `${(index + 1) * 500}`,
}))
const columns: ColumnConfig[] = [
{ type: 'text', data: 'seq', title: '序号', width: 60 },
{ type: 'text', data: 'name', title: '名称', width: 260 },
{ type: 'text', data: 'base', title: '计算基数', width: 120 },
{ type: 'text', data: 'rate', title: '费率', width: 90 },
{ type: 'text', data: 'amount', title: '金额', width: 120 },
{ type: 'text', data: 'feeCategory', title: '费用类别', width: 120 },
{ type: 'checkbox', data: 'isNonCompetitiveFee', title: '不可竞争费', width: 100 },
{ type: 'checkbox', data: 'isExcludedFromTotal', title: '不计入合价', width: 100 },
]
onMounted(() => {
setTimeout(() => {
if (hstRef.value) {
hstRef.value.loadData(mockData)
}
}, 100)
const createRowSchema = (cols: ColumnConfig[]) => {
const schema: Record<string, null> = {}
cols.forEach((col) => {
if (col.data) schema[col.data] = null
})
return schema
}
const rowSchema = createRowSchema(columns)
const mockData = Array.from({ length: 20 }, (_, index) => {
const rowNumber = index + 1
const base = rowNumber * 100
const rate = (rowNumber % 6) * 0.015
const amount = base * rate
return {
seq: String(rowNumber),
name: `其他项目${rowNumber}`,
base: String(base.toFixed(2)),
rate: String((rate * 100).toFixed(2)) + '%',
amount: String(amount.toFixed(2)),
feeCategory: `类别${((rowNumber - 1) % 4) + 1}`,
isNonCompetitiveFee: rowNumber % 5 === 0,
isExcludedFromTotal: rowNumber % 7 === 0,
}
})
const settings = {
data: mockData,
dataSchema: rowSchema,
colWidths: 120,
columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
className: 'htCenter htMiddle',
contextMenu: {
items: {
row_above: {},
row_below: {},
separator2: '---------',
remove_row: {
name: '删除行',
callback: function (this: any, key: string, selection: any[]) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
if (rowData?.id) {
this.alter('remove_row', selectedRow, 1)
} else {
this.alter('remove_row', selectedRow, 1)
}
},
},
},
},
}
</script>
<template>
<div class="h-full">
<div class="h-full flex flex-col">
<DbHst ref="hstRef" :settings="settings" />
</div>
</template>

View File

@@ -1,38 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { DbHst } from '#/components/db-hst'
const hstRef = ref<any>(null)
const columns = ref<any[]>([
{ type: 'text', data: 'code', title: '编码' },
{ type: 'text', data: 'name', title: '项目名称' },
{ type: 'text', data: 'unit', title: '单位' },
{ type: 'text', data: 'quantity', title: '工程量' },
])
const settings = {
columns: columns.value,
}
const mockData = Array.from({ length: 20 }, (_, index) => ({
code: `SUB${String(index + 1).padStart(6, '0')}`,
name: `分部分项${index + 1}`,
unit: 'm²',
quantity: `${(index + 1) * 10}`,
}))
onMounted(() => {
setTimeout(() => {
if (hstRef.value) {
hstRef.value.loadData(mockData)
}
}, 100)
})
</script>
<template>
<div class="h-full">
<DbHst ref="hstRef" :settings="settings" />
</div>
</template>

View File

@@ -1,38 +1,87 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, watch, nextTick } from 'vue'
import { DbHst } from '#/components/db-hst'
const hstRef = ref<any>(null)
const columns = ref<any[]>([
{ type: 'text', data: 'category', title: '类别' },
{ type: 'text', data: 'count', title: '数量' },
{ type: 'text', data: 'totalAmount', title: '合计金额' },
{ type: 'text', data: 'remark', title: '备注' },
])
const settings = {
columns: columns.value,
type ColumnConfig = {
type: 'text'
data: string
title: string
width?: number
}
const mockData = [
{ category: '分部分项', count: '50', totalAmount: '500000', remark: '' },
{ category: '措施项目', count: '20', totalAmount: '100000', remark: '' },
{ category: '其他项目', count: '10', totalAmount: '50000', remark: '' },
{ category: '合计', count: '80', totalAmount: '650000', remark: '' },
const columns: ColumnConfig[] = [
{ type: 'text', data: 'seq', title: '序号', width: 60 },
{ type: 'text', data: 'name', title: '名称', width: 260 },
{ type: 'text', data: 'base', title: '计算基数', width: 120 },
{ type: 'text', data: 'rate', title: '费率', width: 90 },
{ type: 'text', data: 'total', title: '合计', width: 120 },
{ type: 'text', data: 'code', title: '代号', width: 100 },
{ type: 'text', data: 'remark', title: '备注', width: 200 },
]
onMounted(() => {
setTimeout(() => {
if (hstRef.value) {
hstRef.value.loadData(mockData)
}
}, 100)
const createRowSchema = (cols: ColumnConfig[]) => {
const schema: Record<string, null> = {}
cols.forEach((col) => {
if (col.data) schema[col.data] = null
})
return schema
}
const rowSchema = createRowSchema(columns)
const mockData = Array.from({ length: 10 }, (_, index) => {
const rowNumber = index + 1
const base = rowNumber * 1000
const rate = (rowNumber % 5) * 0.01
const total = base * rate
return {
seq: String(rowNumber),
name: `单位汇总${rowNumber}`,
base: String(base.toFixed(2)),
rate: String((rate * 100).toFixed(2)) + '%',
total: String(total.toFixed(2)),
code: `DH${String(rowNumber).padStart(3, '0')}`,
remark: rowNumber % 3 === 0 ? '备注' : '',
}
})
const settings = {
data: mockData,
dataSchema: rowSchema,
colWidths: 120,
columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
className: 'htCenter htMiddle',
contextMenu: {
items: {
row_above: {},
row_below: {},
separator2: '---------',
remove_row: {
name: '删除行',
callback: function (this: any, key: string, selection: any[]) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
if (rowData?.id) {
this.alter('remove_row', selectedRow, 1)
} else {
this.alter('remove_row', selectedRow, 1)
}
},
},
},
},
}
</script>
<template>
<div class="h-full">
<div class="h-full flex flex-col">
<DbHst ref="hstRef" :settings="settings" />
</div>
</template>

View File

@@ -1,267 +1,321 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ElDialog, ElTable, ElTableColumn, ElButton, ElTabs, ElTabPane, ElInput, ElSegmented } from 'element-plus'
import {
createVariableSetting,
deleteVariableSetting,
getVariableSettingList,
updateVariableSetting,
type VariableSettingVO
} from '#/api/database/quota/variableSetting'
import { DbHst } from '#/components/db-hst'
import { handleRowOperation, codeRenderer } from '#/components/db-hst/tree'
import { ElMessage, ElSegmented } from 'element-plus'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import MaterialsDialog from './MaterialsDialog.vue'
import {
createDbHstSettings,
segmentedOptions,
type ColumnConfig,
type SegmentedType
} from './segmentedSettings'
// Props
const props = defineProps<{
height?: number
// catalogItemId?: string;
selectedNode?: any
}>()
const catalogItemId = computed(()=>props.selectedNode?.id ?? '')
// 类别映射
const categoryMap: Record<string, string> = {
'分部分项': 'division',
'措施项目': 'measure',
'其他项目': 'other',
'单位汇总': 'unit_summary',
}
const hstRef = ref<any>(null)
const dialogVisible = ref(false)
const activeTab = ref('global')
const isDialogVisible = ref(false)
const currentRowData = ref<any>(null)
const formulaInput = ref('')
const currentRowIndex = ref<number | null>(null)
const tableData = ref<any[]>([])
// 各个 Tab 的表格数据
const tabTableData = ref({
global: [
{ seq: '1', code: 'GF001', name: '全局费用1', value: '100' },
{ seq: '2', code: 'GF002', name: '全局费用2', value: '200' },
],
division: [
{ seq: '1', code: 'FB001', name: '分部分项1', value: '150' },
{ seq: '2', code: 'FB002', name: '分部分项2', value: '250' },
],
measure: [
{ seq: '1', code: 'CS001', name: '措施项目1', value: '180' },
{ seq: '2', code: 'CS002', name: '措施项目2', value: '280' },
],
other: [
{ seq: '1', code: 'QT001', name: '其他项目1', value: '120' },
{ seq: '2', code: 'QT002', name: '其他项目2', value: '220' },
],
supplement: [
{ seq: '1', code: 'BC001', name: '补充费用1', value: '160' },
{ seq: '2', code: 'BC002', name: '补充费用2', value: '260' },
],
})
const dialogRenderer = (instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) => {
td.innerHTML = ''
td.style.position = 'relative'
td.style.padding = '0 8px'
// 创建文本容器
const textSpan = document.createElement('span')
textSpan.textContent = value || ''
textSpan.style.display = 'inline-block'
textSpan.style.verticalAlign = 'middle'
textSpan.style.maxWidth = 'calc(100% - 24px)'
textSpan.style.overflow = 'hidden'
textSpan.style.textOverflow = 'ellipsis'
textSpan.style.whiteSpace = 'nowrap'
// 创建图标按钮
const iconBtn = document.createElement('span')
iconBtn.innerHTML = '⚙️'
iconBtn.style.cursor = 'pointer'
iconBtn.style.marginLeft = '4px'
iconBtn.style.fontSize = '14px'
iconBtn.style.verticalAlign = 'middle'
iconBtn.style.float = 'right'
iconBtn.addEventListener('click', (e) => {
e.stopPropagation()
const rowData = instance.getSourceDataAtRow(instance.toPhysicalRow(row))
currentRowData.value = rowData
dialogVisible.value = true
})
td.appendChild(textSpan)
td.appendChild(iconBtn)
return td
const openDialog = (rowData: any, row: number) => {
currentRowData.value = rowData
currentRowIndex.value = row
isDialogVisible.value = true
}
const columns = ref<any[]>([
{ type: 'text', data: 'seq', title: '序', width: 40 },
{ type: 'text', data: 'name', title: '费用名称', renderer: codeRenderer , code:true},
{ type: 'text', data: 'spec', title: '费用代码', width: 300,renderer: dialogRenderer },
{ type: 'text', data: 'cardinal', title: '计算基数(用户端不显示)', width: 200 },
])
let rowSchema: any = {}
// 根据 columns 的 data 字段生成对象结构
columns.value.forEach((col: any) => {
if (col.data ) {
rowSchema[col.data] = null
}
})
const mockData = (() => {
const data: any[] = []
// 生成5个父级数据
for (let i = 1; i <= 5; i++) {
const parent: any = {
seq: `${i}`,
name: `费用名称${i}`,
spec: `SPEC${String(i).padStart(3, '0')}`,
cardinal: `基数${i}`,
__children: [] as any[]
}
// 为每个父级生成3-5个子级
const childCount = Math.floor(Math.random() * 3) + 3
for (let j = 1; j <= childCount; j++) {
const child: any = {
seq: `${i}.${j}`,
name: `子费用${i}-${j}`,
spec: `SPEC${String(i).padStart(3, '0')}-${String(j).padStart(2, '0')}`,
cardinal: `子基数${i}-${j}`,
__children: []
}
parent.__children.push(child)
}
data.push(parent)
}
return data
})()
const handleDialogSuccess = async (formula: string) => {
const hot = hstRef.value?.hotInstance
if (!hot) return
if (currentRowIndex.value == null) return
const settings = {
data: mockData,
dataSchema: rowSchema,
colWidths: 120,
columns: columns.value,
rowHeaders: false,
nestedRows: true,
bindRowsWithHeaders: true,
contextMenu: {
items: {
custom_row_above: {
name: '在上方插入行',
callback: function() {
handleRowOperation(this, 'above')
}
},
custom_row_below: {
name: '在下方插入行',
callback: function() {
handleRowOperation(this, 'below')
}
},
separator1: '---------',
custom_add_child: {
name: '添加子行',
callback: function() {
handleRowOperation(this, 'child')
}
},
separator2: '---------',
remove_row: {
name: '删除行',
callback: function() {
handleRowOperation(this, 'delete')
}
},
// separator3: '---------',
// undo: {},
// redo: {}
}
},
}
watch(
() => props.height,
(newHeight) => {
console.log('MarketMaterials newHeight', newHeight)
if (newHeight && hstRef.value?.hotInstance) {
hstRef.value.hotInstance.updateSettings({
height: newHeight - 50 - 15// 减去 tabs 头部和 padding 的高度,滚动条
const rowData = hot.getSourceDataAtRow(currentRowIndex.value)
if (rowData?.id) {
try {
await updateVariableSetting({
id: rowData.id,
catalogItemId: catalogItemId.value || '',
category: categoryMap[value.value] || 'division',
calcBase: { formula }
})
hstRef.value.hotInstance.render()
hot.setDataAtRowProp(currentRowIndex.value, 'calcBase', { formula }, 'calcBaseDialog')
hot.setDataAtRowProp(currentRowIndex.value, 'cardinal', formula, 'calcBaseDialog')
hot.render()
ElMessage.success('保存成功')
} catch (error) {
ElMessage.error('保存计算基数失败')
}
} else {
hot.setDataAtRowProp(currentRowIndex.value, 'calcBase', { formula }, 'calcBaseDialog')
hot.setDataAtRowProp(currentRowIndex.value, 'cardinal', formula, 'calcBaseDialog')
hot.render()
}
}
const value = ref<SegmentedType>('分部分项')
const options = segmentedOptions
// 延迟渲染 ElSegmented 以避免初始化时宽度计算问题
const isSegmentedReady = ref(false)
onMounted(() => {
// 使用 requestAnimationFrame 确保 DOM 已完全渲染
requestAnimationFrame(() => {
isSegmentedReady.value = true
})
})
// 加载数据
const loadData = async () => {
if (!catalogItemId.value) return
try {
const category = categoryMap[value.value] || 'division'
const res = await getVariableSettingList(catalogItemId.value, category)
tableData.value = (res || []).map((item: VariableSettingVO, index: number) => ({
...item,
number: String(index + 1),
unit: item.code || '',
cardinal: item.calcBase?.formula || '',
// 保留 source 字段用于判断只读
source: item.source || 'manual'
}))
nextTick(() => {
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.loadData(tableData.value)
}
})
} catch (error) {
console.error('加载变量设置失败:', error)
tableData.value = []
nextTick(() => {
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.loadData([])
}
})
}
}
// 根据 Segmented 切换动态生成 settings
const dynamicSettings = computed(() => {
return createDbHstSettings(value.value, {
initSchema: (cols: ColumnConfig[]) => {
const schema: Record<string, null> = {}
cols.forEach((col: ColumnConfig) => {
if (col.data) schema[col.data] = null
})
return schema
},
dialogRendererCallbacks: {
onIconClick: (rowData, row) => {
openDialog(rowData, row)
},
},
afterChangeCallbacks: {
onCreate: async (rowData: any, _row: number) => {
if (!rowData.name && !rowData.code) return
// 定额取费来源的数据不能创建(理论上不会触发)
if (rowData.source === 'fee_item') return
try {
const id = await createVariableSetting({
catalogItemId: catalogItemId.value || '',
category: categoryMap[value.value] || 'division',
name: rowData.name || '',
code: rowData.code || '',
calcBase: rowData.calcBase,
})
rowData.id = id
ElMessage.success('创建成功')
} catch (error) {
ElMessage.error('创建失败')
}
},
onUpdate: async (rowData: any, _row: number) => {
if (!rowData.id) return
// 定额取费来源的数据不能更新
if (rowData.source === 'fee_item') return
try {
await updateVariableSetting({
id: rowData.id,
catalogItemId: catalogItemId.value || '',
category: categoryMap[value.value] || 'division',
name: rowData.name,
code: rowData.code,
calcBase: rowData.calcBase,
})
ElMessage.success('保存成功')
} catch (error) {
ElMessage.error('保存失败')
}
}
}
})
})
// 右键菜单
const dynamicContextMenuItems = computed(() => [
{
key: 'insert_row_above',
name: '上方插入行',
callback: async (arg: any) => {
const { selection } = arg || {}
const targetRow = selection && selection.length > 0 ? selection[0][0] : 0
const referenceNodeId = tableData.value[targetRow]?.id
try {
await createVariableSetting({
catalogItemId: catalogItemId.value || '',
category: categoryMap[value.value] || 'division',
name: '',
code: '',
referenceNodeId: referenceNodeId,
insertPosition: 'above',
})
await loadData()
ElMessage.success('插入成功')
} catch (error) {
ElMessage.error('插入失败')
}
}
},
{ immediate: true }
)
{
key: 'insert_row_below',
name: '下方插入行',
callback: async (arg: any) => {
const { selection } = arg || {}
const targetRow = selection && selection.length > 0 ? selection[0][0] : tableData.value.length - 1
const referenceNodeId = tableData.value[targetRow]?.id
const value = ref('分部分项???')
try {
await createVariableSetting({
catalogItemId: catalogItemId.value || '',
category: categoryMap[value.value] || 'division',
name: '',
code: '',
referenceNodeId: referenceNodeId,
insertPosition: 'below',
})
await loadData()
ElMessage.success('插入成功')
} catch (error) {
ElMessage.error('插入失败')
}
}
},
{
key: 'delete_row',
name: '删除行',
callback: async (arg: any) => {
const { selection, hotInstance } = arg || {}
if (!selection || selection.length === 0) {
ElMessage.warning('请先选中要删除的行')
return
}
const options = ['分部分项???', '措施项目???', '其他项目???', '单位汇总???']
onMounted(() => {
setTimeout(() => {
// if (hstRef.value) {
// hstRef.value.loadData(mockData)
// }
}, 100)
const row = selection[0][0]
const rowData = hotInstance?.getSourceDataAtRow(row) || tableData.value[row]
if (!rowData?.id) {
ElMessage.warning('该行没有数据')
return
}
// 定额取费来源的数据不能删除
if (rowData.source === 'fee_item') {
ElMessage.warning('定额取费变量不能删除')
return
}
try {
await deleteVariableSetting(rowData.id)
await loadData()
ElMessage.success('删除成功')
} catch (error) {
ElMessage.error('删除失败')
}
}
}
])
// 监听类别切换
watch(value, () => {
loadData()
})
// 监听 catalogItemId 变化
watch(catalogItemId, (val) => {
console.log('catalogItemId', val)
loadData()
}, { immediate: true })
</script>
<template>
<div class="w-full h-full">
<div class="w-full h-full flex flex-col">
<div class="flex flex-col items-start gap-4">
<ElSegmented v-model="value" :options="options" size="large" />
<ElSegmented v-if="isSegmentedReady" class="variable-settings-segmented" v-model="value" :options="options" size="small" />
</div>
<DbHst ref="hstRef" :settings="settings" />
<DbHst ref="hstRef" :settings="dynamicSettings" :contextMenuItems="dynamicContextMenuItems" />
<!-- 弹窗 -->
<ElDialog v-model="dialogVisible" title="费用代码设置" width="60%" :close-on-click-modal="false">
<div style="margin-bottom: 16px;">
<div style="color: #909399; font-size: 13px; margin-bottom: 8px;">
提示: 现在书写的公式只能调用在当前项目以前的取费和系统参数
</div>
<ElInput
v-model="formulaInput"
placeholder="请输入公式"
clearable
/>
</div>
<ElTabs v-model="activeTab">
<ElTabPane label="全局费用" name="global">
<ElTable :data="tabTableData.global" border style="width: 100%" max-height="400">
<ElTableColumn prop="seq" label="序号" width="80" />
<ElTableColumn prop="code" label="费用代号" width="150" />
<ElTableColumn prop="name" label="费用名称" min-width="200" />
<ElTableColumn prop="value" label="值" width="120" />
</ElTable>
</ElTabPane>
<ElTabPane label="分部分项" name="division">
<ElTable :data="tabTableData.division" border style="width: 100%" max-height="400">
<ElTableColumn prop="seq" label="序号" width="80" />
<ElTableColumn prop="code" label="费用代号" width="150" />
<ElTableColumn prop="name" label="费用名称" min-width="200" />
<ElTableColumn prop="value" label="值" width="120" />
</ElTable>
</ElTabPane>
<ElTabPane label="措施项目" name="measure">
<ElTable :data="tabTableData.measure" border style="width: 100%" max-height="400">
<ElTableColumn prop="seq" label="序号" width="80" />
<ElTableColumn prop="code" label="费用代号" width="150" />
<ElTableColumn prop="name" label="费用名称" min-width="200" />
<ElTableColumn prop="value" label="值" width="120" />
</ElTable>
</ElTabPane>
<ElTabPane label="其他项目" name="other">
<ElTable :data="tabTableData.other" border style="width: 100%" max-height="400">
<ElTableColumn prop="seq" label="序号" width="80" />
<ElTableColumn prop="code" label="费用代号" width="150" />
<ElTableColumn prop="name" label="费用名称" min-width="200" />
<ElTableColumn prop="value" label="值" width="120" />
</ElTable>
</ElTabPane>
<ElTabPane label="补充费用" name="supplement">
<ElTable :data="tabTableData.supplement" border style="width: 100%" max-height="400">
<ElTableColumn prop="seq" label="序号" width="80" />
<ElTableColumn prop="code" label="费用代号" width="150" />
<ElTableColumn prop="name" label="费用名称" min-width="200" />
<ElTableColumn prop="value" label="值" width="120" />
</ElTable>
</ElTabPane>
</ElTabs>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="dialogVisible = false">确定</ElButton>
</template>
</ElDialog>
<MaterialsDialog
v-model="isDialogVisible"
:rowData="currentRowData"
:catalogItemId="catalogItemId"
@success="handleDialogSuccess"
/>
</div>
</template>
<style lang="scss">
.variable-settings-segmented{
--el-border-radius-base: 0;
padding: 0;
}
.handsontable .htCalculate {
position: relative;
cursor: default;
width: var(--ht-icon-size);
height: var(--ht-icon-size);
font-size: 0;
float: right;
top: calc((var(--ht-line-height) - var(--ht-icon-size)) / 2);
margin-left: calc(var(--ht-gap-size)* 2);
margin-right: 1px;
}
.handsontable .htCalculate::after {
width: var(--ht-icon-size);
height: var(--ht-icon-size);
-webkit-mask-size: contain;
-webkit-mask-image: url('data:image/svg+xml;base64,PHN2ZyB0PSIxNzY2MjAwNTkzNTAwIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjgxMzAiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTI1MS4yIDM4N0gzMjB2NjguOGMwIDEuOCAxLjggMy4yIDQgMy4yaDQ4YzIuMiAwIDQtMS40IDQtMy4zVjM4N2g2OC44YzEuOCAwIDMuMi0xLjggMy4yLTR2LTQ4YzAtMi4yLTEuNC00LTMuMy00SDM3NnYtNjguOGMwLTEuOC0xLjgtMy4yLTQtMy4yaC00OGMtMi4yIDAtNCAxLjQtNCAzLjJWMzMxaC02OC44Yy0xLjggMC0zLjIgMS44LTMuMiA0djQ4YzAgMi4yIDEuNCA0IDMuMiA0eiBtMzI4IDBoMTkzLjZjMS44IDAgMy4yLTEuOCAzLjItNHYtNDhjMC0yLjItMS40LTQtMy4zLTRINTc5LjJjLTEuOCAwLTMuMiAxLjgtMy4yIDR2NDhjMCAyLjIgMS40IDQgMy4yIDR6IG0wIDI2NWgxOTMuNmMxLjggMCAzLjItMS44IDMuMi00di00OGMwLTIuMi0xLjQtNC0zLjMtNEg1NzkuMmMtMS44IDAtMy4yIDEuOC0zLjIgNHY0OGMwIDIuMiAxLjQgNCAzLjIgNHogbTAgMTA0aDE5My42YzEuOCAwIDMuMi0xLjggMy4yLTR2LTQ4YzAtMi4yLTEuNC00LTMuMy00SDU3OS4yYy0xLjggMC0zLjIgMS44LTMuMiA0djQ4YzAgMi4yIDEuNCA0IDMuMiA0eiBtLTE5NS43LTgxbDYxLjItNzQuOWM0LjMtNS4yIDAuNy0xMy4xLTUuOS0xMy4xSDM4OGMtMi4zIDAtNC41IDEtNS45IDIuOWwtMzQgNDEuNi0zNC00MS42Yy0xLjUtMS44LTMuNy0yLjktNS45LTIuOWgtNTAuOWMtNi42IDAtMTAuMiA3LjktNS45IDEzLjFsNjEuMiA3NC45LTYyLjcgNzYuOGMtNC40IDUuMi0wLjggMTMuMSA1LjggMTMuMWg1MC44YzIuMyAwIDQuNS0xIDUuOS0yLjlsMzUuNS00My41IDM1LjUgNDMuNWMxLjUgMS44IDMuNyAyLjkgNS45IDIuOWg1MC44YzYuNiAwIDEwLjItNy45IDUuOS0xMy4xTDM4My41IDY3NXoiIHAtaWQ9IjgxMzEiPjwvcGF0aD48cGF0aCBkPSJNODgwIDExMkgxNDRjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjczNmMwIDE3LjcgMTQuMyAzMiAzMiAzMmg3MzZjMTcuNyAwIDMyLTE0LjMgMzItMzJWMTQ0YzAtMTcuNy0xNC4zLTMyLTMyLTMyeiBtLTM2IDczMkgxODBWMTgwaDY2NHY2NjR6IiBwLWlkPSI4MTMyIj48L3BhdGg+PC9zdmc+');
background-color: currentColor;
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { DbHst } from '#/components/db-hst'
const hstRef = ref<any>(null)
const columns = ref<any[]>([
{ type: 'text', data: 'seq', title: '序', width: 40 },
{ type: 'text', data: 'code', title: '字段名称',},
{ type: 'checkbox', data: 'category', title: '分部分项隐藏' },
{ type: 'checkbox', data: 'name', title: '措施项目隐藏' },
{ type: 'checkbox', data: 'feature', title: '其他项目隐葱' },
{ type: 'checkbox', data: 'locked', title: '汇总分析隐藏' },
{ type: 'text', data: 'remark', title: '备注' },
])
let rowSchema: any = {}
// 根据 columns 的 data 字段生成对象结构
columns.value.forEach((col: any) => {
if (col.data ) {
rowSchema[col.data] = null
}
})
const mockData = (() => {
const data: any[] = []
// 生成5个父级数据
for (let i = 1; i <= 5; i++) {
const parent = {
seq: `${i}`,
code: `CODE${String(i).padStart(6, '0')}`,
category: false,
name: false,
feature: false,
locked: false,
remark: `备注${i}`,
level: String(i - 1),
__children: [] as any[]
}
data.push(parent)
}
return data
})()
const settings = {
data: mockData,
dataSchema: rowSchema,
colWidths: 120,
columns: columns.value,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
className: 'htCenter htMiddle',
contextMenu: {
items: {
row_above:{
},
row_below: {
},
separator2: '---------',
remove_row: {
name: '删除行',
callback: function(key: string, selection: any[]) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
if (rowData?.id) {
this.alter('remove_row', selectedRow, 1)
// deleteResourceMerged(rowData.id).then(res => {
// console.log('deleteResourceMerged', res)
// })
} else {
this.alter('remove_row', selectedRow, 1)
}
},
},
// separator3: '---------',
// undo: {},
// redo: {}
}
},
}
onMounted(() => {
setTimeout(() => {
// if (hstRef.value) {
// hstRef.value.loadData(mockData)
// }
}, 100)
})
</script>
<template>
<div class="h-full flex flex-col">
<DbHst ref="hstRef" :settings="settings" />
</div>
</template>

View File

@@ -0,0 +1,66 @@
import type { Ref } from 'vue'
type ColumnConfig = {
type: 'text'
data: string
title: string
width?: number
readOnly?: boolean
}
type CreateSettingsDeps = {
formulaInput: Ref<string>
}
const createRowSchema = (columns: ColumnConfig[]) => {
const schema: Record<string, null> = {}
columns.forEach((col) => {
if (col.data) schema[col.data] = null
})
return schema
}
const createMockRows = (count: number) => {
return Array.from({ length: count }, (_, index) => {
const rowNumber = index + 1
const rowText = String(rowNumber).padStart(3, '0')
return {
seq: String(rowNumber),
code: `FB${rowText}`,
name: `分部分项费用${rowNumber}`,
value: String((rowNumber * 10).toFixed(2)),
}
})
}
export const createDivisionSettings = ({ formulaInput }: CreateSettingsDeps) => {
const columns: ColumnConfig[] = [
{ type: 'text', data: 'seq', title: '序号', width: 60, readOnly: true },
{ type: 'text', data: 'name', title: '费用名称', width: 240, readOnly: true },
{ type: 'text', data: 'code', title: '代号', width: 160, readOnly: true },
]
const allowedSelectionDataKeys = new Set(['code', 'value'])
return {
data: createMockRows(30),
//dataSchema: createRowSchema(columns),
colWidths: 150,
height: 400,
columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
selectionMode: 'single',
className: 'htCenter',
afterSelection(row: number, col: number) {
if (row < 0 || col < 0) return
const selectedDataKey = columns?.[col]?.data
if (!selectedDataKey || !allowedSelectionDataKeys.has(selectedDataKey)) return
const cellValue = this.getDataAtCell(row, col)
if (cellValue == null) return
formulaInput.value += String(cellValue)
},
}
}

View File

@@ -0,0 +1,5 @@
export { createDivisionSettings } from './division'
export { createMeasureSettings } from './measure'
export { createOtherSettings } from './other'
export { createUnitSummarySettings } from './summary'

View File

@@ -0,0 +1,66 @@
import type { Ref } from 'vue'
type ColumnConfig = {
type: 'text'
data: string
title: string
width?: number
readOnly?: boolean
}
type CreateSettingsDeps = {
formulaInput: Ref<string>
}
const createRowSchema = (columns: ColumnConfig[]) => {
const schema: Record<string, null> = {}
columns.forEach((col) => {
if (col.data) schema[col.data] = null
})
return schema
}
const createMockRows = (count: number) => {
return Array.from({ length: count }, (_, index) => {
const rowNumber = index + 1
const rowText = String(rowNumber).padStart(3, '0')
return {
seq: String(rowNumber),
code: `CS${rowText}`,
name: `措施费${rowNumber}`,
value: String((rowNumber * 12).toFixed(2)),
}
})
}
export const createMeasureSettings = ({ formulaInput }: CreateSettingsDeps) => {
const columns: ColumnConfig[] = [
{ type: 'text', data: 'seq', title: '序号', width: 60, readOnly: true },
{ type: 'text', data: 'name', title: '费用名称', width: 240, readOnly: true },
{ type: 'text', data: 'code', title: '代号', width: 160, readOnly: true },
]
const allowedSelectionDataKeys = new Set(['code', 'value'])
return {
data: createMockRows(30),
//dataSchema: createRowSchema(columns),
colWidths: 150,
height: 400,
columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
selectionMode: 'single',
className: 'htCenter',
afterSelection(row: number, col: number) {
if (row < 0 || col < 0) return
const selectedDataKey = columns?.[col]?.data
if (!selectedDataKey || !allowedSelectionDataKeys.has(selectedDataKey)) return
const cellValue = this.getDataAtCell(row, col)
if (cellValue == null) return
formulaInput.value += String(cellValue)
},
}
}

View File

@@ -0,0 +1,66 @@
import type { Ref } from 'vue'
type ColumnConfig = {
type: 'text'
data: string
title: string
width?: number
readOnly?: boolean
}
type CreateSettingsDeps = {
formulaInput: Ref<string>
}
const createRowSchema = (columns: ColumnConfig[]) => {
const schema: Record<string, null> = {}
columns.forEach((col) => {
if (col.data) schema[col.data] = null
})
return schema
}
const createMockRows = (count: number) => {
return Array.from({ length: count }, (_, index) => {
const rowNumber = index + 1
const rowText = String(rowNumber).padStart(3, '0')
return {
seq: String(rowNumber),
code: `QT${rowText}`,
name: `其他项目费用${rowNumber}`,
value: String((rowNumber * 8).toFixed(2)),
}
})
}
export const createOtherSettings = ({ formulaInput }: CreateSettingsDeps) => {
const columns: ColumnConfig[] = [
{ type: 'text', data: 'seq', title: '序号', width: 60, readOnly: true },
{ type: 'text', data: 'name', title: '费用名称', width: 240, readOnly: true },
{ type: 'text', data: 'code', title: '代号', width: 160, readOnly: true },
]
const allowedSelectionDataKeys = new Set(['code', 'value'])
return {
data: createMockRows(30),
//dataSchema: createRowSchema(columns),
colWidths: 150,
height: 400,
columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
selectionMode: 'single',
className: 'htCenter',
afterSelection(row: number, col: number) {
if (row < 0 || col < 0) return
const selectedDataKey = columns?.[col]?.data
if (!selectedDataKey || !allowedSelectionDataKeys.has(selectedDataKey)) return
const cellValue = this.getDataAtCell(row, col)
if (cellValue == null) return
formulaInput.value += String(cellValue)
},
}
}

View File

@@ -0,0 +1,66 @@
import type { Ref } from 'vue'
type ColumnConfig = {
type: 'text'
data: string
title: string
width?: number
readOnly?: boolean
}
type CreateSettingsDeps = {
formulaInput: Ref<string>
}
const createRowSchema = (columns: ColumnConfig[]) => {
const schema: Record<string, null> = {}
columns.forEach((col) => {
if (col.data) schema[col.data] = null
})
return schema
}
const createMockRows = (count: number) => {
return Array.from({ length: count }, (_, index) => {
const rowNumber = index + 1
const rowText = String(rowNumber).padStart(3, '0')
return {
seq: String(rowNumber),
code: `DW${rowText}`,
name: `单位汇总费用${rowNumber}`,
value: String((rowNumber * 6).toFixed(2)),
}
})
}
export const createUnitSummarySettings = ({ formulaInput }: CreateSettingsDeps) => {
const columns: ColumnConfig[] = [
{ type: 'text', data: 'seq', title: '序号', width: 60, readOnly: true },
{ type: 'text', data: 'name', title: '费用名称', width: 240, readOnly: true },
{ type: 'text', data: 'code', title: '代号', width: 160, readOnly: true },
]
const allowedSelectionDataKeys = new Set(['code', 'value'])
return {
data: createMockRows(30),
//dataSchema: createRowSchema(columns),
colWidths: 150,
height: 400,
columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
selectionMode: 'single',
className: 'htCenter',
afterSelection(row: number, col: number) {
if (row < 0 || col < 0) return
const selectedDataKey = columns?.[col]?.data
if (!selectedDataKey || !allowedSelectionDataKeys.has(selectedDataKey)) return
const cellValue = this.getDataAtCell(row, col)
if (cellValue == null) return
formulaInput.value += String(cellValue)
},
}
}

View File

@@ -0,0 +1,532 @@
/**
* ElSegmented 切换对应的 DbHst 组件 settings 配置
* 分部分项 | 措施项目 | 其他项目 | 单位汇总
*/
import Handsontable from 'handsontable';
// Segmented 选项类型
export type SegmentedType = '分部分项' | '措施项目' | '其他项目' | '单位汇总'
// Segmented 选项列表
export const segmentedOptions: SegmentedType[] = ['分部分项', '措施项目', '其他项目', '单位汇总']
// 列配置类型
export interface ColumnConfig {
type: string
data: string
title: string
width?: number
renderer?: any
required?: boolean
autoWidth?: boolean
readOnly?: boolean
code?: boolean
}
// Settings 配置类型
export interface DbHstSettings {
data: any[]
dataSchema?: any
colWidths?: number | number[]
columns: ColumnConfig[]
rowHeaders?: boolean
nestedRows?: boolean
bindRowsWithHeaders?: boolean
contextMenu?: any
afterChange?: (changes: any, source: any) => void
}
// ContextMenuItem 类型
export interface ContextMenuItem {
key: string
name: string
callback?: (hotInstance: any) => void
separator?: boolean
}
export type DialogRendererCallbacks = {
onIconClick?: (rowData: any, row: number) => void
}
const { EventManager } = Handsontable
const { addClass, hasClass } = Handsontable.dom
export const createDialogRenderer = (callbacks?: DialogRendererCallbacks) => {
return (instance: any, TD: HTMLTableCellElement, row: number, col: number, prop: string, value: any, cellProperties: any) => {
const { rootDocument } = instance;
const ICON = rootDocument.createElement('DIV');
ICON.className = 'htCalculate';//htAutocompleteArrow
ICON.appendChild(rootDocument.createTextNode(String.fromCharCode(9660)));
Handsontable.renderers.TextRenderer.apply(this, [instance, TD, row, col, prop, value, cellProperties]);
if (!TD.firstChild) { // http://jsperf.com/empty-node-if-needed
// otherwise empty fields appear borderless in demo/renderers.html (IE)
TD.appendChild(rootDocument.createTextNode(String.fromCharCode(160))); // workaround for https://github.com/handsontable/handsontable/issues/1946
// this is faster than innerHTML. See: https://github.com/handsontable/handsontable/wiki/JavaScript-&-DOM-performance-tips
}
TD.insertBefore(ICON, TD.firstChild);
// addClass(TD, 'htAutocomplete');
addClass(ICON, ['!cursor-pointer'])
if (!instance.acArrowListener) {
const eventManager = new EventManager(instance);
instance.acArrowListener = function (event: MouseEvent) {
if (hasClass(event.target as HTMLElement, 'htCalculate')) {
const targetCell = (event.target as HTMLElement).closest('td')
if (targetCell) {
const coords = instance.getCoords(targetCell)
const cellMeta = instance.getCellMeta(coords.row, coords.col)//cellProperties
const cellValue = instance.getDataAtCell(coords.row, coords.col)
const cellProp = instance.colToProp(coords.col) // 正确获取当前单元格的 prop
let cellSource = (cellMeta as any).source || []
// const cellCallback = (cellMeta as { callback?: ChangeCallback }).callback
// console.log('click arrow', coords, cellMeta, cellValue, cellSource)
//activeContext.value = {instance, TD, row:coords.row, column:coords.col, prop:cellProp, value:cellValue, cellProperties:cellMeta}
const rowData = instance.getSourceDataAtRow?.(coords.row)
callbacks?.onIconClick?.(rowData, coords.row)
}
event.preventDefault()
event.stopPropagation()
}
}
eventManager.addEventListener(instance.rootElement, 'mousedown', instance.acArrowListener);
// We need to unbind the listener after the table has been destroyed
instance.addHookOnce('afterDestroy', () => {
eventManager.destroy();
});
}
}
}
// const dialogRenderer = createDialogRenderer()
// 分部分项 - 列配置
export const fenbufenxiangColumns: ColumnConfig[] = [
{ type: 'text', data: 'number', title: '序号', width: 80 },
{ type: 'text', data: 'name', title: '费用名称', width: 200 },
{ type: 'text', data: 'code', title: '费用代号', width: 80 },
{ type: 'text', data: 'cardinal', title: '计算基数', width: 120 },
]
// 措施项目 - 列配置
export const cuoshixiangmuColumns: ColumnConfig[] = [
{ type: 'text', data: 'number', title: '序号', width: 80 },
{ type: 'text', data: 'name', title: '费用名称', width: 200 },
{ type: 'text', data: 'code', title: '费用代号', width: 80 },
{ type: 'text', data: 'cardinal', title: '计算基数', width: 120 },
]
// 其他项目 - 列配置
export const qitaxiangmuColumns: ColumnConfig[] = [
{ type: 'text', data: 'number', title: '序号', width: 80 },
{ type: 'text', data: 'name', title: '费用名称', width: 200 },
{ type: 'text', data: 'code', title: '费用代号', width: 80 },
{ type: 'text', data: 'cardinal', title: '计算基数', width: 120 },
]
// 单位汇总 - 列配置
export const danweihuizongColumns: ColumnConfig[] = [
{ type: 'text', data: 'number', title: '序号', width: 80 },
{ type: 'text', data: 'name', title: '费用名称', width: 200 },
{ type: 'text', data: 'code', title: '费用代号', width: 80 },
{ type: 'text', data: 'cardinal', title: '计算基数', width: 120 },
]
// 根据类型获取列配置
export const getColumnsByType = (type: SegmentedType): ColumnConfig[] => {
const columnsMap: Record<SegmentedType, ColumnConfig[]> = {
'分部分项': fenbufenxiangColumns,
'措施项目': cuoshixiangmuColumns,
'其他项目': qitaxiangmuColumns,
'单位汇总': danweihuizongColumns,
}
return columnsMap[type] || fenbufenxiangColumns
}
// ===================== 独立的 contextMenu 配置 =====================
// 分部分项 - contextMenu
export const createFenbufenxiangContextMenu = () => ({
items: {
row_above: { name: '在上方插入行' },
row_below: { name: '在下方插入行' },
separator1: '---------',
remove_row: {
name: '删除行',
callback: function (this: any) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
if (rowData?.id) {
this.alter('remove_row', selectedRow, 1)
// deleteResourceMerged(rowData.id).then(res => {
// console.log('deleteResourceMerged', res)
// })
} else {
this.alter('remove_row', selectedRow, 1)
}
}
},
}
})
// 措施项目 - contextMenu
export const createCuoshixiangmuContextMenu = () => ({
items: {
row_above: { name: '在上方插入措施项' },
row_below: { name: '在下方插入措施项' },
separator1: '---------',
remove_row: {
name: '删除措施项',
callback: function (this: any) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
if (rowData?.id) {
this.alter('remove_row', selectedRow, 1)
// deleteResourceMerged(rowData.id).then(res => {
// console.log('deleteResourceMerged', res)
// })
} else {
this.alter('remove_row', selectedRow, 1)
}
}
},
}
})
// 其他项目 - contextMenu
export const createQitaxiangmuContextMenu = () => ({
items: {
row_above: { name: '在上方插入项目' },
row_below: { name: '在下方插入项目' },
separator1: '---------',
remove_row: {
name: '删除项目',
callback: function (this: any) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
if (rowData?.id) {
this.alter('remove_row', selectedRow, 1)
// deleteResourceMerged(rowData.id).then(res => {
// console.log('deleteResourceMerged', res)
// })
} else {
this.alter('remove_row', selectedRow, 1)
}
}
},
}
})
// 单位汇总 - contextMenu (只读,无编辑操作)
export const createDanweihuizongContextMenu = () => ({
items: {}
})
// 根据类型获取 contextMenu
export const getContextMenuByType = (type: SegmentedType) => {
const menuMap: Record<SegmentedType, () => any> = {
'分部分项': createFenbufenxiangContextMenu,
'措施项目': createCuoshixiangmuContextMenu,
'其他项目': createQitaxiangmuContextMenu,
'单位汇总': createDanweihuizongContextMenu,
}
return menuMap[type]?.()
}
// ===================== 独立的 afterChange 配置 =====================
// 分部分项 - afterChange
export const createFenbufenxiangAfterChange = (callbacks?: {
onCreate?: (rowData: any, row: number) => void
onUpdate?: (rowData: any, row: number) => void
}) => {
return function (this: any, changes: any, source: any) {
if (!changes || source === 'loadData' || source === 'updateId' || source === 'calcBaseDialog') return
const row = changes[0][0]
const rowData = this.getSourceDataAtRow(row)
console.log('分部分项 afterChange:', rowData)
if (rowData?.id == null) {
callbacks?.onCreate?.(rowData, row)
} else {
callbacks?.onUpdate?.(rowData, row)
}
}
}
// 措施项目 - afterChange
export const createCuoshixiangmuAfterChange = (callbacks?: {
onCreate?: (rowData: any, row: number) => void
onUpdate?: (rowData: any, row: number) => void
}) => {
return function (this: any, changes: any, source: any) {
if (!changes || source === 'loadData' || source === 'updateId' || source === 'calcBaseDialog') return
const row = changes[0][0]
const rowData = this.getSourceDataAtRow(row)
console.log('措施项目 afterChange:', rowData)
if (rowData?.id == null) {
callbacks?.onCreate?.(rowData, row)
} else {
callbacks?.onUpdate?.(rowData, row)
}
}
}
// 其他项目 - afterChange
export const createQitaxiangmuAfterChange = (callbacks?: {
onCreate?: (rowData: any, row: number) => void
onUpdate?: (rowData: any, row: number) => void
}) => {
return function (this: any, changes: any, source: any) {
if (!changes || source === 'loadData' || source === 'updateId' || source === 'calcBaseDialog') return
const row = changes[0][0]
const rowData = this.getSourceDataAtRow(row)
console.log('其他项目 afterChange:', rowData)
if (rowData?.id == null) {
callbacks?.onCreate?.(rowData, row)
} else {
callbacks?.onUpdate?.(rowData, row)
}
}
}
// 单位汇总 - afterChange (只读,不处理变更)
export const createDanweihuizongAfterChange = (
callbacks?: {
onCreate?: (rowData: any, row: number) => void
onUpdate?: (rowData: any, row: number) => void
}
) => {
return function (this: any, changes: any, source: any) {
if (!changes || source === 'loadData' || source === 'updateId' || source === 'calcBaseDialog') return
const row = changes[0][0]
const rowData = this.getSourceDataAtRow(row)
console.log('单位汇总 afterChange:', rowData)
if (rowData?.id == null) {
callbacks?.onCreate?.(rowData, row)
} else {
callbacks?.onUpdate?.(rowData, row)
}
}
}
// 根据类型获取 afterChange
export const getAfterChangeByType = (
type: SegmentedType,
callbacks?: {
onCreate?: (rowData: any, row: number) => void
onUpdate?: (rowData: any, row: number) => void
}
) => {
const afterChangeMap: Record<SegmentedType, () => any> = {
'分部分项': () => createFenbufenxiangAfterChange(callbacks),
'措施项目': () => createCuoshixiangmuAfterChange(callbacks),
'其他项目': () => createQitaxiangmuAfterChange(callbacks),
'单位汇总': () => createDanweihuizongAfterChange(callbacks),
}
return afterChangeMap[type]?.()
}
// ===================== 创建 DbHst settings 的工厂函数 =====================
export const createDbHstSettings = (
type: SegmentedType,
options?: {
initSchema?: (columns: ColumnConfig[]) => any
dialogRendererCallbacks?: DialogRendererCallbacks
afterChangeCallbacks?: {
onCreate?: (rowData: any, row: number) => void
onUpdate?: (rowData: any, row: number) => void
}
}
): DbHstSettings => {
const columns = getColumnsByType(type).map((col) => {
if (col.data !== 'cardinal') return col
if (!options?.dialogRendererCallbacks?.onIconClick) return col
return {
...col,
renderer: createDialogRenderer(options.dialogRendererCallbacks),
}
})
const settings: DbHstSettings = {
data: [],
// dataSchema: options?.initSchema?.(columns),
colWidths: 150,
columns: columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
contextMenu: false, // 禁用原生 contextMenu使用 DbHst 组件的 contextMenuItems
afterChange: getAfterChangeByType(type, options?.afterChangeCallbacks),
// 根据 source 字段设置单元格只读
cells(row: number, _col: number) {
const cellProperties: any = {}
const rowData = this.instance?.getSourceDataAtRow(row)
// 定额取费来源的数据设置为只读
if (rowData?.source === 'fee_item') {
cellProperties.readOnly = true
cellProperties.className = 'htDimmed' // 灰色背景表示只读
}
return cellProperties
},
} as any
return settings
}
// 获取所有类型的 settings 映射
export const getAllSettings = (
options?: {
initSchema?: (columns: ColumnConfig[]) => any
dialogRendererCallbacks?: DialogRendererCallbacks
afterChangeCallbacks?: {
onCreate?: (rowData: any, row: number) => void
onUpdate?: (rowData: any, row: number) => void
}
}
): Record<SegmentedType, DbHstSettings> => {
return {
'分部分项': createDbHstSettings('分部分项', options),
'措施项目': createDbHstSettings('措施项目', options),
'其他项目': createDbHstSettings('其他项目', options),
'单位汇总': createDbHstSettings('单位汇总', options),
}
}
// ===================== ContextMenuItems 配置 =====================
// 分部分项 - contextMenuItems
export const createFenbufenxiangContextMenuItems = (
options?: {
onAfterAdd?: (hotInstance: any) => void
}
): ContextMenuItem[] => [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
// // 等待 DOM 更新后重新渲染以应用验证样式
// nextTick(() => {
// hotInstance.render()
// })
}
},
]
// 措施项目 - contextMenuItems
export const createCuoshixiangmuContextMenuItems = (
options?: {
onAfterAdd?: (hotInstance: any) => void
}
): ContextMenuItem[] => [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
// // 等待 DOM 更新后重新渲染以应用验证样式
// nextTick(() => {
// hotInstance.render()
// })
}
},
]
// 其他项目 - contextMenuItems
export const createQitaxiangmuContextMenuItems = (
options?: {
onAfterAdd?: (hotInstance: any) => void
}
): ContextMenuItem[] => [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
// // 等待 DOM 更新后重新渲染以应用验证样式
// nextTick(() => {
// hotInstance.render()
// })
}
},
]
// 单位汇总 - contextMenuItems (只读,不提供编辑操作)
export const createDanweihuizongContextMenuItems = (options?: {
onAfterAdd?: (hotInstance: any) => void
}): ContextMenuItem[] => [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
// // 等待 DOM 更新后重新渲染以应用验证样式
// nextTick(() => {
// hotInstance.render()
// })
}
},
]
// 根据类型获取 contextMenuItems
export const getContextMenuItemsByType = (
type: SegmentedType,
options?: {
onAfterAdd?: (hotInstance: any) => void
}
): ContextMenuItem[] => {
const menuItemsMap: Record<SegmentedType, () => ContextMenuItem[]> = {
'分部分项': () => createFenbufenxiangContextMenuItems(options),
'措施项目': () => createCuoshixiangmuContextMenuItems(options),
'其他项目': () => createQitaxiangmuContextMenuItems(options),
'单位汇总': () => createDanweihuizongContextMenuItems(options),
}
return menuItemsMap[type]?.() || []
}
// ===================== 模拟数据生成 =====================
const generateMockData = (prefix: string, count: number = 5) => {
const data: any[] = []
for (let i = 1; i <= count; i++) {
data.push({
number: `${i}`,
name: `${prefix}${i}`,
unit: `UNIT${String(i).padStart(3, '0')}`,
cardinal: `基数${i}`,
})
}
return data
}
export const fenbufenxiangMockData = generateMockData('分部分项', 5)
export const cuoshixiangmuMockData = generateMockData('措施项目', 4)
export const qitaxiangmuMockData = generateMockData('其他项目', 3)
export const danweihuizongMockData = generateMockData('单位汇总', 6)
export const getMockDataByType = (type: SegmentedType): any[] => {
const mockDataMap: Record<SegmentedType, any[]> = {
'分部分项': fenbufenxiangMockData,
'措施项目': cuoshixiangmuMockData,
'其他项目': qitaxiangmuMockData,
'单位汇总': danweihuizongMockData,
}
return mockDataMap[type] || []
}

View File

@@ -0,0 +1,128 @@
import Handsontable from 'handsontable'
import { getRowData, getCellValueByProp } from '#/components/db-hst/command'
const { EventManager } = Handsontable
const { addClass, hasClass } = Handsontable.dom
//已定位问题根因:自定义 renderer 在隐藏列后因 DOM 复用产生脏渲染,不是单纯没刷新整表。
// baseNumber renderer- 只在清单节点显示图标
export const baseNumberRenderer = (
instance: any,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: string | number,
value: any,
cellProperties: any
) => {
// 先重绘 TD防止 DOM 复用时残留旧内容
Handsontable.renderers.TextRenderer.apply(this, [instance, TD, row, col, prop, value, cellProperties])
const rowData = getRowData(instance, row)
const isBoqNode = rowData?.nodeType === 'boq'
if (!isBoqNode) return
const { rootDocument } = instance
const ICON = rootDocument.createElement('DIV')
ICON.className = 'htMenuLine'
ICON.appendChild(rootDocument.createTextNode(String.fromCharCode(9660)))
if (!TD.firstChild) { TD.appendChild(rootDocument.createTextNode(String.fromCharCode(160))) }
const targetIcon = 'htBaseNumberIcon'
TD.insertBefore(ICON, TD.firstChild)
addClass(ICON, ['!cursor-pointer', targetIcon])
if (!instance[targetIcon + 'Listener']) {
const eventManager = new EventManager(instance)
instance[targetIcon + 'Listener'] = function (event: MouseEvent) {
if (hasClass(event.target as HTMLElement, targetIcon)) {
const targetCell = (event.target as HTMLElement).closest('td')
if (targetCell) {
const coords = instance.getCoords(targetCell)
const cellMeta = instance.getCellMeta(coords.row, coords.col)
if (cellMeta.readOnly != undefined && cellMeta.readOnly) return
if (cellMeta.editor != undefined && !cellMeta.editor) return
const { cellProp, cellValue, rowData } = getCellValueByProp(instance, coords.row, coords.col)
//执行自定义方法
if (instance.__onBaseNumber) {
instance.__onBaseNumber({
instance,
TD,
row: coords.row,
column: coords.col,
prop: cellProp,
value: cellValue,
cellProperties: cellMeta,
rowData,
})
}
}
event.preventDefault()
event.stopPropagation()
}
}
eventManager.addEventListener(instance.rootElement, 'mousedown', instance[targetIcon + 'Listener'], true)
instance.addHookOnce('afterDestroy', () => {
eventManager.destroy()
})
}
}
// 验证公式(参考后台 QuotaVariableSettingServiceImpl.validateCalcBase
export function validateFormula(formulaStr: string): { valid: boolean; error?: string } {
if (!formulaStr || formulaStr.trim() === '') {
return { valid: true } // 空公式是允许的
}
const trimmedFormula = formulaStr.trim()
// 验证公式语法:只允许字母、数字、运算符和括号
const validPattern = /^[A-Za-z0-9_+\-*/().\s]+$/
if (!validPattern.test(trimmedFormula)) {
return { valid: false, error: '公式只能包含字母、数字、运算符(+-*/)和括号' }
}
// 验证括号匹配
let parenCount = 0
for (const char of trimmedFormula) {
if (char === '(') parenCount++
if (char === ')') parenCount--
if (parenCount < 0) {
return { valid: false, error: '括号不匹配' }
}
}
if (parenCount !== 0) {
return { valid: false, error: '括号不匹配' }
}
// 验证公式不能以运算符或小数点结尾
if (/[+\-*/.]$/.test(trimmedFormula)) {
return { valid: false, error: '公式不能以运算符或小数点结尾' }
}
// 验证公式不能以运算符或小数点开头(除了负号)
if (/^[+*/.]/.test(trimmedFormula)) {
return { valid: false, error: '公式不能以运算符或小数点开头' }
}
// 验证不能有连续的运算符
if (/[+\-*/]{2,}/.test(trimmedFormula)) {
return { valid: false, error: '公式不能有连续的运算符' }
}
// 验证小数点格式
if (/\.{2,}/.test(trimmedFormula)) {
return { valid: false, error: '小数点格式不正确' }
}
// 验证小数点后面不能直接跟运算符或括号
if (/\.[+\-*/()]/.test(trimmedFormula)) {
return { valid: false, error: '小数点后面必须跟数字' }
}
// 注意:变量有效性检查已移至后端,前端只进行基础语法验证
return { valid: true }
}

View File

@@ -0,0 +1,172 @@
<script lang="ts" setup>
import { getVariableSettingList, getVariableSettingListByCompileTree, type VariableSettingVO } from '#/api/database/quota/variableSetting';
import { DbHst } from '#/components/db-hst';
import { ElButton, ElDialog, ElInput, ElMessage, ElTabPane, ElTabs } from 'element-plus';
import { nextTick, ref, watch } from 'vue';
import { validateFormula } from '.';
// Props
const props = defineProps<{
projectId?: string | number
}>()
// Emits
const emit = defineEmits<{
(e: 'success', data: any): void
(e: 'close'): void
}>()
// 内部状态
const visible = ref(false)
const compileTreeId = ref<string | number>('')
const division = ref<any>(null)
// 当前激活的标签页
const activeKey = ref('division')
// 公式输入框
const formula = ref('')
const hstRef = ref<any>()
// 加载状态
const loading = ref(false)
// 表格列配置
const guideColumns = [
{ type: 'numeric', data: 'sortOrder', title: '序号', editor: false, width: 50 },
{ type: 'text', data: 'code', title: '费用代号', editor: false, width: 150 },
{ type: 'text', data: 'name', title: '费用名称', editor: false, width: 200 },
{ type: 'text', data: 'summaryValue', title: '汇总值', editor: false, width: 150 },
]
const settings = {
data: [],
colWidths: 150,
columns: guideColumns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
outsideClickDeselects: false,
currentRowClassName: 'row-highlight',
beforeOnCellMouseDown(this: any, event: MouseEvent, coords: any, _TD: HTMLTableCellElement) {
if (event.detail === 1 && event.button === 0) {
const rowData = this.getSourceDataAtRow(coords.row)
if (rowData?.code) {
formula.value += String(rowData.code)
}
}
}
}
// 确定按钮
function handleConfirm() {
const validation = validateFormula(formula.value)
if (!validation.valid) {
ElMessage.error(validation.error || '公式验证失败')
return
}
// loading.value = true
// loading.value = false
division.value.instance.setDataAtRowProp([
[division.value.row, 'baseNumber', formula.value || ''],
[division.value.row, 'attributes', {
...division.value.rowData.attributes,
baseNumber: {
formula: formula.value || ''
}
}]
], 'update')
emit('success', { formula: formula.value })
visible.value = false
}
// 关闭弹窗
function close() {
visible.value = false
}
// 显示弹窗(暴露给父组件调用)
function show(selectedNode: any, params: any) {
console.log('show-baseNumber', selectedNode, params)
compileTreeId.value = selectedNode.parentId//获取定额专业ID
division.value = params
// formula.value = params.rowData?.attributes?.baseNumber?.formula || ''
visible.value = true
loadVariableSettings()
}
// 暴露方法
defineExpose({ show, close })
// 加载变量设置(一次性获取所有定额专业的数据)
async function loadVariableSettings() {
if (!compileTreeId.value) {
console.warn('compileTreeId is required')
return
}
try {
const divisionRowData = division.value.rowData
formula.value = divisionRowData.attributes?.baseNumber?.formula || ''
// 获取基数范围(从当前行的 attributes.baseNumberRange.selectedIds 获取)
// const baseNumberRangeIds = divisionRowData?.attributes?.baseNumberRange?.selectedIds || []
// 一次性获取所有变量设置(后端自动合并所有定额专业)
const data = await getVariableSettingList(String(compileTreeId.value), activeKey.value)
console.log('data', data)
const tableData = data.map((item, index) => ({
...item,
sortOrder: index + 1,
}))
setTimeout(() => {
hstRef.value.hotInstance.loadData(tableData)
}, 200);
} catch (error) {
console.error('加载变量设置失败:', error)
ElMessage.error('加载变量设置失败')
} finally {
loading.value = false
}
}
// 监听标签页切换
watch(() => activeKey.value, (val) => {
// console.log('activeKey.value',val)
nextTick(() => {
// refreshTableData()
loadVariableSettings()
})
})
</script>
<template>
<ElDialog :model-value="visible" destroy-on-close draggable @update:model-value="(val: boolean) => visible = val"
@closed="close" title="基数设置" width="60%">
<div class="flex flex-col h-[500px]">
<div><span class="text-red-500">*</span>提示: 点击表格中的费用代号可添加到公式中</div>
<ElInput type="text" size="large" placeholder="输入公式" v-model="formula" />
<!-- 类别标签页 -->
<ElTabs v-model="activeKey">
<ElTabPane label="分部分项" name="division" />
<ElTabPane label="措施项目" name="measure" />
<ElTabPane label="其他项目" name="other" />
<ElTabPane label="单位汇总" name="unit_summary" />
</ElTabs>
<div class="flex-1 overflow-hidden pt-[1px]">
<DbHst ref="hstRef" :settings="settings" v-show="visible" />
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<ElButton @click="close">取消</ElButton>
<ElButton type="primary" @click="handleConfirm" :loading="loading">确定</ElButton>
</div>
</template>
</ElDialog>
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,70 @@
import Handsontable from 'handsontable'
import { getRowData, getCellValueByProp } from '#/components/db-hst/command'
const { EventManager } = Handsontable
const { addClass, hasClass } = Handsontable.dom
// 基数范围渲染器(参考统一取费的范围字段)- 只在清单节点显示图标
export const baseNumberRangeRenderer = (
instance: any,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: string | number,
value: any,
cellProperties: any
) => {
// 先重绘 TD防止 DOM 复用时残留旧内容
Handsontable.renderers.TextRenderer.apply(this, [instance, TD, row, col, prop, value, cellProperties])
const rowData = getRowData(instance, row)
const isBoqNode = rowData?.nodeType === 'boq'
if (!isBoqNode) return
const { rootDocument } = instance
const ICON = rootDocument.createElement('DIV')
ICON.className = 'htMenuLine'
ICON.appendChild(rootDocument.createTextNode(String.fromCharCode(9660)))
if (!TD.firstChild) { TD.appendChild(rootDocument.createTextNode(String.fromCharCode(160))) }
const targetIcon = 'htBaseNumberRangeIcon'
TD.insertBefore(ICON, TD.firstChild)
addClass(ICON, ['!cursor-pointer',targetIcon])
if (!instance[targetIcon + 'Listener']) {
const eventManager = new EventManager(instance)
instance[targetIcon + 'Listener'] = function (event: MouseEvent) {
if (hasClass(event.target as HTMLElement, targetIcon)) {
const targetCell = (event.target as HTMLElement).closest('td')
if (targetCell) {
const coords = instance.getCoords(targetCell)
const cellMeta = instance.getCellMeta(coords.row, coords.col)
if (cellMeta.readOnly != undefined && cellMeta.readOnly) return
if (cellMeta.editor != undefined && !cellMeta.editor) return
const { cellProp, cellValue, rowData } = getCellValueByProp(instance, coords.row, coords.col)
if (instance.__onBaseNumberRange) {
instance.__onBaseNumberRange({
instance,
TD,
row: coords.row,
column: coords.col,
prop: cellProp,
value: cellValue,
cellProperties: cellMeta,
rowData,
})
}
}
event.preventDefault()
event.stopPropagation()
}
}
eventManager.addEventListener(instance.rootElement, 'mousedown', instance[targetIcon + 'Listener'], true)
instance.addHookOnce('afterDestroy', () => {
eventManager.destroy()
})
}
}

View File

@@ -0,0 +1,176 @@
<script lang="ts" setup>
import { DbHst } from '#/components/db-hst';
import { hstCheckboxChange } from '#/components/db-hst/checkbox';
import { ElButton, ElDialog, ElInput, ElMessage, ElTabPane, ElTabs, ElSegmented } from 'element-plus';
import { nextTick, ref, watch } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { getDivisionTemplateTree } from '#/api/database/interface/config';
// Emits
const emit = defineEmits<{
(e: 'success', data: any): void
(e: 'close'): void
}>()
// 内部状态
const visible = ref(false)
const hstRef = ref()
const catalogItemId = ref()
const division = ref()
const selectedIdsMap = ref<Record<string, string[]>>({
division: [],
measure: [],
})
const segmentedValue = ref('division')
const segmentedOptions = [
{ label: '分部分项', value: 'division' },
{ label: '措施项目', value: 'measure' },
]
// 表格列配置
const columns = [
{ type: 'db.nestedRows', data: 'sortOrder', title: '序号', editor: false, width: 50 },
{ type: 'text', data: 'code', title: '费用代号', editor: false, width: 150 },
{ type: 'text', data: 'name', title: '费用名称', editor: false, width: 200 },
{ type: 'text', data: 'summaryValue', title: '汇总值', editor: false, width: 150 },
{ type: 'checkbox', data: 'selected', title: '选择', className: 'htCenter', width: '60' },
]
const settings = {
data: [],
colWidths: 150,
columns: columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
outsideClickDeselects: false,
currentRowClassName: 'row-highlight',
// beforeOnCellMouseDown(this: any, event: MouseEvent, coords: any, _TD: HTMLTableCellElement) {
// if (event.detail === 1 && event.button === 0) {
// const rowData = this.getSourceDataAtRow(coords.row)
// }
// },
afterChange: hstCheckboxChange(hstRef),
}
// 确定按钮
function handleConfirm() {
const divisionRowData = division.value.rowData
const attributes = {
...divisionRowData.attributes,
baseNumberRange: {
division: { selectedIds: selectedIdsMap.value.division },
measure: { selectedIds: selectedIdsMap.value.measure },
}
}
const divisionCount = selectedIdsMap.value.division?.length || 0
const measureCount = selectedIdsMap.value.measure?.length || 0
const totalCount = divisionCount + measureCount
division.value.instance.setDataAtRowProp([
[division.value.row, 'baseNumberRange', totalCount > 0 ? `已选${totalCount}` : ''],
[division.value.row, 'attributes', attributes]
], 'update')
visible.value = false
}
// 关闭弹窗
function close() {
visible.value = false
}
// 显示弹窗(暴露给父组件调用)
function show(selectedNode: any,params: any) {
visible.value = true
division.value = params;
catalogItemId.value = params.rowData.catalogItemId
selectedIdsMap.value = {
division: params.rowData.attributes?.baseNumberRange?.division?.selectedIds || [],
measure: params.rowData.attributes?.baseNumberRange?.measure?.selectedIds || [],
}
console.log('show-baseNumberRange',selectedNode, params)
setTimeout(() => {
loadData(segmentedValue.value)
}, 200);
}
// 暴露方法
defineExpose({ show, close })
// 转换分部分项树数据为表格格式
function transformDivisionData(items: any[], parentId: string | number | null = null): any[] {
return items
.map((item) => {
return {
id: item.id,
parentId: parentId,
sortOrder: item.nodeType === 'boq' ? (item.sortOrder ?? '') : '',
code: item.code || '',
nodeType: getDictLabel(DICT_TYPE.DIVISION_CATEGORY, String(item.nodeType)),
rawNodeType: item.nodeType,
name: item.name || '',
selected: selectedIdsMap.value[segmentedValue.value]?.includes(item.id) || false,
// originalData: item,
// selected: division.value.rowData.attributes?.baseNumberRange?.selectedIds?.includes(item.id) || false,
__children: item.children ? transformDivisionData(item.children, item.id) : [],
};
});
}
async function loadData(tabType: string) {
//division.value.rowData.tabType
const res = await getDivisionTemplateTree(division.value.rowData.catalogItemId, tabType);
// const treeData = await getBoqDivisionTree(compileTreeId);
const data = transformDivisionData(res);
hstRef.value.hotInstance.__onCheckboxChange = handleCheckboxChange
hstRef.value.nestedRowsLoadData(data);
}
const handleCheckboxChange = (data: any) =>{
console.log('handleCheckboxChange', data)
selectedIdsMap.value[segmentedValue.value] = data
}
// 监听类别切换
watch(segmentedValue, (val) => {
console.log('val',val)
loadData(val)
})
</script>
<template>
<ElDialog
:model-value="visible"
destroy-on-close
draggable
@update:model-value="(val: boolean) => visible = val"
@closed="close"
title="基数范围"
width="60%"
>
<div class="flex flex-col h-[500px]">
<div class="flex flex-col items-start gap-4">
<ElSegmented class="base-number-range-settings-segmented" v-model="segmentedValue" :options="segmentedOptions" size="small" />
</div>
<DbHst ref="hstRef" :settings="settings" v-show="visible" />
</div>
<template #footer>
<div class="flex justify-end gap-2">
<ElButton @click="close">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确定</ElButton>
</div>
</template>
</ElDialog>
</template>
<style lang="scss">
.base-number-range-settings-segmented{
--el-border-radius-base: 0;
padding: 0;
}
</style>

View File

@@ -0,0 +1,64 @@
import Handsontable from 'handsontable'
import { getRowData, getCellValueByProp } from '#/components/db-hst/command'
const { EventManager } = Handsontable
const { addClass, hasClass } = Handsontable.dom
//已定位问题根因:自定义 renderer 在隐藏列后因 DOM 复用产生脏渲染,不是单纯没刷新整表。
export const rateRenderer = (instance: any, TD: HTMLTableCellElement, row: number, col: number, prop: string | number, value: any, cellProperties: any) => {
// 先重绘 TD防止 DOM 复用时残留旧内容
Handsontable.renderers.TextRenderer.apply(this, [instance, TD, row, col, prop, value, cellProperties])
const rowData = getRowData(instance, row)
const isBoqNode = rowData?.nodeType === 'boq'
if (!isBoqNode) return;
const { rootDocument } = instance;
const ICON = rootDocument.createElement('DIV');
ICON.className = 'htMenuLine';
ICON.appendChild(rootDocument.createTextNode(String.fromCharCode(9660)));
if (!TD.firstChild) { TD.appendChild(rootDocument.createTextNode(String.fromCharCode(160))); }
const targetIcon = 'htRateIcon'
TD.insertBefore(ICON, TD.firstChild);
addClass(ICON, ['!cursor-pointer', targetIcon]);
if (!instance[targetIcon + 'Listener']) {
const eventManager = new EventManager(instance);
instance[targetIcon + 'Listener'] = function (event: MouseEvent) {
if (hasClass(event.target as HTMLElement, targetIcon)) {
const targetCell = (event.target as HTMLElement).closest('td');
if (targetCell) {
const coords = instance.getCoords(targetCell);
const cellMeta = instance.getCellMeta(coords.row, coords.col)
if (cellMeta.readOnly != undefined && cellMeta.readOnly) return;
if (cellMeta.editor != undefined && !cellMeta.editor) return;
const { cellProp, cellValue, rowData } = getCellValueByProp(instance, coords.row, coords.col)
//执行自定义方法
if (instance.__onRate) {
instance.__onRate({
instance,
TD,
row: coords.row,
column: coords.col,
prop: cellProp,
value: cellValue,
cellProperties: cellMeta,
rowData,
})
}
}
event.preventDefault();
event.stopPropagation();
}
};
eventManager.addEventListener(instance.rootElement, 'mousedown', instance[targetIcon + 'Listener'], true);
instance.addHookOnce('afterDestroy', () => {
eventManager.destroy();
});
}
}

View File

@@ -0,0 +1,151 @@
<script lang="ts" setup>
import { DbTree } from '#/components/db-tree';
import { DbHst } from '#/components/db-hst';
import { ElButton, ElDialog, ElSplitter, ElSplitterPanel, ElCard, ElSelect } from 'element-plus';
import { nextTick, ref, watch } from 'vue';
import { getQuotaCatalogItemChildren } from '#/api/database/quota';
import { getDirectoryTree, getRateItemList } from '#/api/database/interface/cardinalRate';
// Emits
const emit = defineEmits<{
(e: 'success', data: any): void
(e: 'close'): void
}>()
// 内部状态
const visible = ref(false)
// 专业相关定义
const majors = ref<string | null>(null)
const majorsOptions = ref<any[]>([])
const treeData = ref<any[]>([])
const hstRef = ref<any>()
const selectedNode = ref<any>()
const division = ref<any>()
const selectedRow = ref<any>()
// 表格列配置
const columns = [
{ type: 'text', data: 'sortOrder', title: '序号', width: 60, editor: false },
{ type: 'text', data: 'name', title: '名称', editor: false },
{ type: 'text', data: 'rate', title: '费率', editor: false },
// { type: 'text', data: 'calcBase', title: '计算基数' },
{ type: 'text', data: 'remark', title: '备注', editor: false },
]
const settings = {
data: [],
colWidths: 150,
columns: columns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
outsideClickDeselects: false,
currentRowClassName: 'row-highlight',
beforeOnCellMouseDown(this: any, event: MouseEvent, coords: any, _TD: HTMLTableCellElement) {
if (event.detail === 1 && event.button === 0) {
const rowData = this.getSourceDataAtRow(coords.row)
if(rowData) selectedRow.value = rowData
}
},
}
// 确定按钮
function handleConfirm() {
// emit('success', { formula: formula.value })
// console.log('handleConfirm', selectedRow.value)
division.value.instance.setDataAtRowProp([
[division.value.row, 'rate', selectedRow.value.rate],
[division.value.row, 'remark', selectedRow.value.remark]
], 'update')
visible.value = false
}
// 关闭弹窗
function close() {
visible.value = false
}
// 显示弹窗(暴露给父组件调用)
function show(_selectedNode: any,params: any) {
visible.value = true
selectedNode.value = _selectedNode
division.value = params;
console.log('show-rate', selectedNode.value, params)
setTimeout(() => {
loadData()
}, 200);
}
// 暴露方法
defineExpose({ show, close })
// 加载变量设置(一次性获取所有定额专业的数据)
async function loadData() {
majors.value = null;
treeData.value = []
const options = await getQuotaCatalogItemChildren(selectedNode.value.parentId)
majorsOptions.value = options.filter(f=>f.nodeType === 'majors').map(item=>({
label: item.name,
value: String(item.id)
}))
if(majorsOptions.value.length > 0) {
majors.value = majorsOptions.value[0]?.value
handleSelectChange(majors.value)
}
hstRef.value.hotInstance.loadData([])
}
async function handleSelectChange(value: any) {
hstRef.value.hotInstance.loadData([])
const tree = await getDirectoryTree(value)
console.log('tree',tree)
treeData.value = convertCatalogTree(tree)
}
function convertCatalogTree(items: any[]): any[] {
return items.map(item => {
const node: any = {
id: String(item.id),
label: item.name,
}
if (item.children && item.children.length > 0) {
node.children = convertCatalogTree(item.children)
}
return node
})
}
// 处理节点点击
async function handleNodeClick(option: any) {
console.log('node click:', option)
// here you can add processing logic
const res = await getRateItemList(option.data.id)
hstRef.value.hotInstance.loadData(res)
}
</script>
<template>
<!-- 基数费率弹窗 -->
<ElDialog :model-value="visible" destroy-on-close draggable @update:model-value="(val: boolean) => visible = val"
@closed="close" title="基数费率" width="60%">
<div class="flex flex-col h-[500px]">
<ElSplitter>
<ElSplitterPanel size="30%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<ElSelect v-model="majors" :options="majorsOptions" size="small" @change="handleSelectChange"/>
<DbTree ref="dbTreeRef" :tree-data="treeData" :edit-on-dblclick="false" @node-click="handleNodeClick" :defaultExpandedLevel="2"/>
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel>
<div class="h-full w-full flex flex-col">
<DbHst ref="hstRef" :settings="settings"></DbHst>
</div>
</ElSplitterPanel>
</ElSplitter>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<ElButton @click="close">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确定</ElButton>
</div>
</template>
</ElDialog>
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { DbHst } from '#/components/db-hst';
import ColoumApply from '#/components/db-hst/component/ColoumApply.vue';
import BaseNumber from '../baseNumber/index.vue';
import BaseNumberRange from '../baseNumberRange/index.vue';
import Rate from '../rate/index.vue';
import { computed, onMounted, ref, watch } from 'vue';
import { getDictLabel } from '@vben/hooks';
import { DICT_TYPE } from '@vben/constants';
import { useHst } from './use-hst';
import { getWorkbenchHiddenFields, getDivisionTemplateTree } from '#/api/database/interface/config';
const props = defineProps<{
selectedNode?: any;
tabType: string;
}>();
const hstRef = ref();
const coloumApplyRef = ref();
const baseNumberRef = ref();
const baseNumberRangeRef = ref();
const rateRef = ref();
const unitFields = ref()
type TabType = 'division' | 'measure' | 'other' | 'unit_summary'
const tabHiddenFieldsKeyMap: Record<TabType, string> = {
division: 'divisionHiddenFields',
measure: 'measureHiddenFields',
other: 'otherHiddenFields',
unit_summary: 'summaryHiddenFields',
}
const hiddenField = computed(()=> unitFields.value[tabHiddenFieldsKeyMap[props.tabType as TabType]])
const { columns, hstInit, hstSettings, hstContextMenuItems } = useHst(hstRef);
/** recursive convert children to __children for nestedRows */
const convertToNestedData = (items: any[]): any[] => {
return items.map((item: any) => {
const divisionSelectedIds = item.attributes?.baseNumberRange?.division?.selectedIds || []
const measureSelectedIds = item.attributes?.baseNumberRange?.measure?.selectedIds || []
const totalCount = divisionSelectedIds.length + measureSelectedIds.length
const converted: any = {
...item,
category: item.nodeType ? getDictLabel(DICT_TYPE.DIVISION_CATEGORY, item.nodeType) : item.category,
baseNumber: item.attributes?.baseNumber?.formula || '',
baseNumberRange: totalCount > 0 ? `已选${totalCount}` : ''
};
converted.__children = (item.children && item.children.length > 0) ? convertToNestedData(item.children) : [];
return converted;
});
};
const loadAndFillData = async (catalogItemId: number) => {
//根据接口来显示隐藏settings.columns
unitFields.value = await getWorkbenchHiddenFields(catalogItemId);
// console.log('res',unitFields.value, props.tabType, hiddenField.value)
const res = await getDivisionTemplateTree(catalogItemId, props.tabType);
// // console.log('res', res)
const data = convertToNestedData(res)
console.log('data',data)
hstRef.value.nestedRowsLoadData(data)
hstRef.value.hotInstance.__onColumnApply = handleColumnApply//隐藏列头
hstRef.value.hotInstance.__onBaseNumber = handleBaseNumber
hstRef.value.hotInstance.__onBaseNumberRange = handleBaseNumberRange
hstRef.value.hotInstance.__onRate = handleRate
hstRef.value.tabType = props.tabType;
hstRef.value.catalogItemId = catalogItemId;
handleColumns(hiddenField.value)
};
onMounted(() => {
console.log('onMounted-division')
setTimeout(() => {
if (props.selectedNode) {
loadAndFillData(props.selectedNode.id);
}
}, 200);
});
watch(
() => props.selectedNode,
(val) => {
if (val) {
loadAndFillData(val.id);
}
},
);
const handleColumns = (hiddenFieldCodes: string[]) => {
const hotInstance = hstRef.value?.hotInstance
if (!hotInstance) return
// Get column indexes to hide based on original columns configuration
const hiddenColumnIndexes = columns
.map((col: any, index: number) => ({ col, index }))
.filter(({ col }) => hiddenFieldCodes.includes(col.data))
.map(({ index }) => index);
const hiddenColumnsPlugin = hotInstance.getPlugin('hiddenColumns');
if (hiddenColumnsPlugin) {
hotInstance.batchExecution(() => {
// First show all columns to reset state
hiddenColumnsPlugin.showColumns(hiddenColumnsPlugin.getHiddenColumns());
// Then hide the specified columns
if (hiddenColumnIndexes.length > 0) {
hiddenColumnsPlugin.hideColumns(hiddenColumnIndexes);
}
}, true)
hotInstance.refreshDimensions()
hotInstance.render()
}
}
const handleSuccessColumnApply = (data: any[]) => {
console.log('handleColumnApply', data);
// unitFields.value = data;
coloumApplyRef.value?.close();
handleColumns(data);
};
const handleColumnApply = () => {
coloumApplyRef.value?.show(hiddenField.value, props.selectedNode.id);
};
const handleBaseNumber = (params: any) => {
console.log('handleBaseNumber', params);
baseNumberRef.value?.show(props.selectedNode, params);
};
const handleBaseNumberSuccess = (data: any) => {
console.log('handleBaseNumberSuccess', data);
// TODO: 处理基数设置成功后的逻辑
};
const handleBaseNumberRange = (params: any) => {
console.log('handleBaseNumberRange', params);
baseNumberRangeRef.value?.show(props.selectedNode, params);
};
const handleBaseNumberRangeSuccess = (data: any) => {
console.log('handleBaseNumberRangeSuccess', data);
};
const handleRate = (params: any) => {
console.log('handleRate', params);
rateRef.value?.show(props.selectedNode,params);
};
const handleRateSuccess = (data: any) => {
console.log('handleRateSuccess', data);
};
</script>
<template>
<div class="h-full w-full flex flex-col">
<DbHst ref="hstRef" :settings="hstSettings" :contextMenuItems="hstContextMenuItems" />
<ColoumApply ref="coloumApplyRef" :success="handleSuccessColumnApply" />
<BaseNumber ref="baseNumberRef" @success="handleBaseNumberSuccess" />
<BaseNumberRange ref="baseNumberRangeRef" @success="handleBaseNumberRangeSuccess" />
<Rate ref="rateRef" @success="handleRateSuccess" />
</div>
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,144 @@
<!-- <script setup lang="ts">
import { DbHst } from '#/components/db-hst';
import { ElButton, ElDialog } from 'element-plus';
import { ref } from 'vue'
import { saveUnitTabRefs, getUnitDivisionTemplateTree } from '#/api/database/interface/config';
import { getDictLabel } from '@vben/hooks';
import { DICT_TYPE } from '@vben/constants';
const props = defineProps<{
success?: (data: any[]) => void
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const modelValue = ref(false)
const hstRef = ref()
const columns = [
{ type: 'db.nestedRows', data: 'sortOrder', title: '序号', editor: false },
{ type: 'text', data: 'code', title: '编码' },
{ type: 'text', data: 'category', title: '类别', className: 'htCenter', editor: false },
{ type: 'text', data: 'name', title: '名称' },
{ type: 'checkbox', data: 'selected', title: '选择', className: 'htCenter' },
];
/**
* 递归设置所有子节点的选中状态
*/
const setChildrenSelected = (node: any, selected: boolean): void => {
if (!node.__children?.length) return;
node.__children.forEach((child: any) => {
child.selected = selected;
setChildrenSelected(child, selected);
});
};
/**
* 递归检查并更新父节点的选中状态
* 只要有子节点选中,父节点就选中
*/
const updateParentSelected = (node: any, dataManager: any): void => {
const parent = dataManager.getRowParent(node);
if (!parent) return;
// 检查是否有子节点被选中
parent.selected = parent.__children?.some((child: any) => child.selected) ?? false;
// 递归向上更新父节点
updateParentSelected(parent, dataManager);
};
/**
* 处理 checkbox 变化事件
*/
const handleCheckboxChange = (changes: any[] | null, source: string): void => {
if (!changes || source === 'loadData') return;
const hotInstance = hstRef.value?.hotInstance;
if (!hotInstance) return;
const nestedRowsPlugin = hotInstance.getPlugin('nestedRows');
if (!nestedRowsPlugin?.enabled) return;
const dataManager = nestedRowsPlugin.dataManager;
for (const [row, prop, , newValue] of changes) {
if (prop !== 'selected') continue;
const dataObject = dataManager.getDataObject(row);
if (!dataObject) continue;
// 如果是父节点,递归设置所有子节点的选中状态
if (dataManager.isParent(row)) {
setChildrenSelected(dataObject, newValue);
}
// 向上更新父节点的选中状态
updateParentSelected(dataObject, dataManager);
}
hotInstance.render();
};
const settings = {
data: [],
columns,
afterChange: handleCheckboxChange,
}
const convertToNestedData = (items: any[]): any[] => {
return items.map((item: any) => {
const converted: any = {
...item,
category: item.nodeType ? getDictLabel(DICT_TYPE.DIVISION_CATEGORY, item.nodeType) : item.category,
};
converted.__children = (item.children && item.children.length > 0) ? convertToNestedData(item.children) : [];
return converted;
});
};
const catalogItemId = ref()
const tabType = ref()
const show = async (selectedNode: any, type: string) => {
modelValue.value = true;
catalogItemId.value = selectedNode.id
tabType.value = type
// console.log('selectedNode',selectedNode)
const res = await getUnitDivisionTemplateTree(selectedNode.id);
setTimeout(() => {
hstRef.value?.nestedRowsLoadData(convertToNestedData(res));
}, 200);
}
const close = () => {
modelValue.value = false
}
const handleConfirm = () => {
const hotInstance = hstRef.value?.hotInstance
const data = hotInstance?.getSourceData() ?? []
const res = data.filter((f:any)=>f.selected).map((item:any)=>item.id)
console.log('handleConfirm',res)
// props.success?.(result)
// modelValue.value = false
saveUnitTabRefs({catalogItemId: catalogItemId.value, tabType: tabType.value, templateNodeIds: res})
}
defineExpose({ show, close })
</script>
<template>
<ElDialog :model-value="modelValue" @update:model-value="close" title="引用-分部分项"
width="700px" :close-on-click-modal="false">
<div class="h-[400px] w-full flex flex-col">
<DbHst ref="hstRef" :settings="settings" />
</div>
<template #footer>
<ElButton @click="close">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确定</ElButton>
</template>
</ElDialog>
</template>
<style lang="scss"></style> -->

View File

@@ -0,0 +1,319 @@
import type { Ref } from 'vue';
import { workbenchColumns } from '../../configs';
import { ElMessage } from 'element-plus';
import { getDictLabel } from '@vben/hooks';
import { DICT_TYPE } from '@vben/constants';
import { createDivisionTemplate, deleteDivisionTemplate, updateDivisionTemplate } from '#/api/database/interface/config';
let contextMenuOnHeader = false
const columns: any[] = workbenchColumns.map((item: any) => ({
...item,
width: 120,
}));
const handleContextMenu = (hstRef: Ref<any>) => {
return {
items: {
// row_above: {},
// row_below: {},
// remove_row: {},
insert_division: {
name: '插入分部',
callback(this: any, key: string, selection: any[], clickEvent: MouseEvent) {
// const row = this.getSelectedLast?.()[0]
// const hotInstance = hstRef.value.hotInstance
const row = this.countRows()
console.log('row', row)
this.alter('insert_row_below', row, 1);
this.setDataAtRowProp([
[row, 'nodeType', 'division'],
[row, 'category', getDictLabel(DICT_TYPE.DIVISION_CATEGORY, 'division')],
[row, 'name', '新建分部'],
[row, 'tabType', hstRef.value.tabType],
[row, 'code', String(new Date().getTime())],
], 'insertRow')
}
},
insert_child_division: {
name: '插入子分部',
disabled(this: any): boolean {
// 只在分部行上可用
const selected = this.getSelectedLast?.()
const row = selected[0]
const rowData = this.getSourceDataAtRow(row)
return rowData?.nodeType !== 'division'
},
callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
const hotInstance = this
const selected = this.getSelectedLast?.()
const row = selected[0]
const rowData = this.getSourceDataAtRow(row)
// // 子分部的父节点就是当前选中的分部
const parentId = rowData?.id
// // 先调用API创建节点
const data = {
catalogItemId: hstRef.value.catalogItemId,
parentId: parentId,
nodeType: 'division',
name: '新建子分部',
tabType: hstRef.value.tabType,
code: String(new Date().getTime())
}
try {
const id = await createDivisionTemplate(data)
console.log('插入子分部: API创建成功ID:', data)
if (!id) return;
// // 创建成功后,作为选中分部的子节点插入
hstRef?.value.addChild().then((res: any) => {
// // 创建成功后,在表格中插入新行
hotInstance.setDataAtRowProp([
[res, 'id', id],
[res, 'nodeType', data.nodeType],
[res, 'category', getDictLabel(DICT_TYPE.DIVISION_CATEGORY, data.nodeType)],
[res, 'name', data.name],
[res, 'parentId', data.parentId],
[res, 'code', data.code],
], 'created')
})
} catch (err) {
console.error('插入子分部: API创建失败', err)
}
}
},
insert_boq: {
name: '插入清单',
callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
const hotInstance = hstRef.value.hotInstance
const selected = this.getSelectedLast?.()
const row = selected[0]
const rowData = this.getSourceDataAtRow(row)
// console.log('插入清单: 选中行', row, '节点类型', rowData?.nodeType, '节点ID', rowData?.id, '父节点ID', rowData?.parentId)
// 确定父节点ID
let parentId: any = null
const nodeType = rowData?.nodeType
if (nodeType === 'division') {
parentId = rowData?.id
} else if (nodeType === 'boq') {
parentId = rowData?.parentId
} else if (nodeType === 'quota') {
// const parentBoqData = findParentByType(this, row, 'boq')
// parentId = parentBoqData?.parentId
// operationType = 'below'
//this.alter('insert_row_below', row, 1)
} else {
console.warn('清单只能在根目录或分部节点下创建,当前节点类型:', rowData?.nodeType)
return
}
// 先调用API创建节点
const data = {
catalogItemId: hstRef.value.catalogItemId,
parentId: parentId,
nodeType: 'boq',
name: '新建清单',
tabType: hstRef.value.tabType,
code: String(new Date().getTime())
}
try {
const id = await createDivisionTemplate(data)
console.log('插入清单: API创建成功ID:', data)
if (!id) return;
hstRef?.value.addChild().then((res: any) => {
// 获取父节点对象,统计已有 boq 子节点数量
const plugin = this.getPlugin('nestedRows');
const parentObj = plugin.dataManager.getDataObject(row);
const children = parentObj?.__children || [];
const boqCount = children.filter((child: any) => child.nodeType === 'boq').length;
const sortOrder = boqCount + 1;
// 创建成功后,在表格中插入新行
hotInstance.setDataAtRowProp([
[res, 'sortOrder', sortOrder],
[res, 'id', id],
[res, 'nodeType', data.nodeType],
[res, 'category', getDictLabel(DICT_TYPE.DIVISION_CATEGORY, data.nodeType)],
[res, 'name', data.name],
[res, 'parentId', parentId],
[res, 'code', data.code],
], 'created')
})
} catch (err) {
console.error('插入清单: API创建失败', err)
}
}
},
separator1: '---------',
remove_row: {
name: '删除',
callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
const selected = this.getSelectedLast?.()
const row = selected[0]
const rowData = this.getSourceDataAtRow(row)
if (rowData.id) {
deleteDivisionTemplate(rowData.id)
}
this.alter('remove_row', row, 1);
}
},
col_show: {
name: '列头筛选',
callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
console.log("列头筛选")
if (hstRef.value.hotInstance.__onColumnApply) hstRef.value.hotInstance.__onColumnApply()
}
}
},
}
};
export const useHst = (hstRef: Ref<any>) => {
const hstSettings = {
data: [],
colWidths: 100,
columns,
colHeaders: true,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
hiddenColumns: {
copyPasteEnabled: true,
indicators: false,
},
contextMenu: handleContextMenu(hstRef),
outsideClickDeselects: false,
currentRowClassName: 'row-highlight',
afterChange(this: any, changes: any[], source: string) {
if (!changes || source === 'loadData' || source === 'updateId' || source === 'created') return;
const hasRealChange = changes.some((change: any) => {
const [, , oldValue, newValue] = change;
const isEmpty = (v: any) => v === null || v === undefined || v === '';
return oldValue !== newValue && !(isEmpty(oldValue) && isEmpty(newValue));
})
if (!hasRealChange) return;
const row = changes[0][0];
const rowData = this.getSourceDataAtRow(row);
const data = {
...rowData,
catalogItemId: hstRef?.value?.catalogItemId,
tabType: rowData?.tabType == undefined ? hstRef.value.tabType : rowData?.tabType,
code: rowData?.code == undefined ? String(new Date().getTime()) : rowData?.code,
}
console.log('data', data);
if (rowData.id == null) {
createDivisionTemplate(data).then(res => {
this.setDataAtRowProp(row, 'id', res, 'updateId')
ElMessage.success('新增成功')
}).catch(err => {
console.error('新增失败', err)
})
} else {
updateDivisionTemplate(data).then(res => {
console.log('updateUnitDivisionTemplate', res)
}).catch(err => {
console.error('更新失败', err)
})
}
},
beforeOnCellContextMenu(event: MouseEvent, coords: any) {
// coords.row < 0 表示右键点击的是列头
contextMenuOnHeader = coords.row < 0
},
beforeContextMenuSetItems(menuItems: any[]) {
const headerColumns = ['col_show']
// 必须原地修改数组,因为 Handsontable runHooks 不使用钩子返回值
const filtered = contextMenuOnHeader
? menuItems.filter(item => headerColumns.includes(item.key))
: menuItems.filter(item => !headerColumns.includes(item.key))
menuItems.splice(0, menuItems.length, ...filtered)
},
afterRenderer(this: any, TD: HTMLTableCellElement, row: number, col: number, prop: string, value: any, cellProperties: any) {
if (col == 0) return;
// afterRenderer 的 row 是 visual indexgetSourceDataAtRow 需要 physical index
const physicalRow = this.toPhysicalRow(row);
const rowData = this.getSourceDataAtRow(physicalRow);
let nodeType = rowData?.nodeType;
// 移除旧的 node-type-* class防止 TD 复用时 class 累积
const nodeTypeClasses = TD.className.match(/node-type-\S+/g);
if (nodeTypeClasses) {
nodeTypeClasses.forEach((cls: string) => TD.classList.remove(cls));
}
if (nodeType) {
const nodeTypeClass = `node-type-${nodeType}`;
TD.classList.add(nodeTypeClass);
// 单位工程root类型只允许编辑费用代号字段
if (nodeType === 'root') {
// 费用代号字段可编辑,其他字段只读
if (prop !== 'costCode') {
cellProperties.editor = false;
}
}
}
},
};
const hstContextMenuItems = [
{
key: 'insert',
name: '插入行',
callback: async (hotInstance: any) => {
if (!hstRef.value.catalogItemId) {
ElMessage.error('请选择左侧专业类别')
return
}
// // 先调用API创建节点
let data: any = {
catalogItemId: hstRef.value.catalogItemId,
nodeType: 'division',
name: '新建分部',
tabType: hstRef.value.tabType,
code: String(new Date().getTime())
}
try {
const id = await createDivisionTemplate(data)
console.log('插入根分部: API创建成功ID:', data)
if(!id) return;
const sourceData = hstRef.value.hotInstance.getSourceData()
if(sourceData.length == 0){
data.id = id
hstRef.value.nestedRowsLoadData([data])
}else{
const row = hotInstance.countRows()
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
hotInstance.setDataAtRowProp([
[row, 'id', id],
[row, 'nodeType', data.nodeType],
[row, 'category', getDictLabel(DICT_TYPE.DIVISION_CATEGORY, data.nodeType)],
[row, 'name', data.name],
[row, 'tabType', data.tabType],
[row, 'code', data.code],
], 'created')
}
} catch (err) {
console.error('插入子分部: API创建失败', err)
}
},
},
];
const hstInit = () => {
hstRef.value.nestedRowsLoadData([]);
};
return {
columns,
hstInit,
hstSettings,
hstContextMenuItems,
};
};

View File

@@ -0,0 +1,105 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions, getDictObj } from '@vben/hooks';
import { baseNumberRenderer } from './components/baseNumber/index';
import { baseNumberRangeRenderer } from './components/baseNumberRange/index';
import { rateRenderer } from './components/rate/index';
// 获取字典颜色css_class
export function getDictColor(rowData: any): string {
let nodeType = rowData.nodeType
if (nodeType === null || nodeType === undefined) return ''
if (nodeType == 'boq' && rowData.isSyncSource) {
nodeType = 'sync_source'
}
const dictData = getDictObj(DICT_TYPE.DIVISION_CATEGORY, nodeType)
return dictData?.cssClass || '#FFFFFF'
}
//分部分项 措施项目 其他项目
export const workbenchColumns = [
{ type: 'db.nestedRows', data: 'sortOrder', title: '序号', colorClass: (rowData: any) => getDictColor(rowData), },
{ type: 'text', data: 'sortOrderCustom', title: '序号-自定义', remark: '自定义序号' },//自定义
{ type: 'text', data: 'code', title: '编码' },
{ type: 'text', data: 'category', title: '类别', className: 'htCenter', editor: false },
{ type: 'checkbox', data: 'tempDelete', title: '临删', className: 'htCenter' },
{ type: 'text', data: 'name', title: '名称' },
{ type: 'text', data: 'feature', title: '项目特征' },
{ type: 'text', data: 'unit', title: '单位', editor: false, renderer: 'db-dropdown', source: getDictOptions(DICT_TYPE.MATERIAL_UNIT) },
{ type: 'numeric', data: 'quantity', title: '工程量' },
{ type: 'numeric', data: 'unitPrice', title: '单价' },
{ type: 'numeric', data: 'fixedPrice', title: '固定单价' },
{ type: 'text', data: 'baseNumber', title: '基数', renderer: baseNumberRenderer},
{ type: 'text', data: 'baseNumberRange', title: '基数范围', renderer: baseNumberRangeRenderer },
{ type: 'numeric', data: 'rate', title: '费率', renderer: rateRenderer },
{ type: 'numeric', data: 'totalPrice', title: '合价' },
{ type: 'numeric', data: 'indicator', title: '指标' },
{ type: 'numeric', data: 'compareQuantity', title: '对比量' },
{ type: 'numeric', data: 'comparePrice', title: '对比价' },
{ type: 'numeric', data: 'compareTotalPrice', title: '对比合价' },
{ type: 'text', data: 'costProfession', title: '取费专业' },
{ type: 'text', data: 'costCode', title: '费用代号' },
{ type: 'text', data: 'remark', title: '备注' },
{ type: 'numeric', data: 'laborUnitPrice', title: '人工费单价' },
{ type: 'numeric', data: 'materialUnitPrice', title: '材料费单价' },
{ type: 'numeric', data: 'machineUnitPrice', title: '机械费单价' },
{ type: 'numeric', data: 'managementUnitPrice', title: '管理费单价' },
{ type: 'numeric', data: 'profitUnitPrice', title: '利润单价' },
{ type: 'numeric', data: 'regulatoryUnitPrice', title: '规费单价' },
{ type: 'numeric', data: 'taxUnitPrice', title: '税金单价' },
{ type: 'numeric', data: 'laborCost', title: '人工费' },
{ type: 'numeric', data: 'materialCost', title: '材料费' },
{ type: 'numeric', data: 'machineCost', title: '机械费' },
{ type: 'numeric', data: 'measuresCost', title: '措施费' },
{ type: 'numeric', data: 'managementCost', title: '管理费' },
{ type: 'numeric', data: 'profit', title: '利润' },
{ type: 'numeric', data: 'regulatoryCost', title: '规费' },
{ type: 'numeric', data: 'tax', title: '税金' },
];
//子目工料机,清单工料机,市场主材设备
export const materialSummaryColumns = [
{ type: 'db.nestedRows', data: 'code', title: '编码' },
{ type: 'text', data: 'name', title: '名称' },
{ type: 'text', data: 'spec', title: '型号规格' },
{ type: 'text', data: 'unit', title: '单位' },
{ type: 'text', data: 'category', title: '类别' },
{ type: 'numeric', data: 'taxRate', title: '税率' },
{ type: 'numeric', data: 'basePriceExTax', title: '除税基价' },
{ type: 'numeric', data: 'basePriceInTax', title: '含税基价' },
{ type: 'numeric', data: 'compilePriceExTax', title: '除税编制价' },
{ type: 'numeric', data: 'compilePriceInTax', title: '含税编制价' },
{ type: 'numeric', data: 'quotaQuantity', title: '定额消耗量' },
{ type: 'numeric', data: 'adjustQuantity', title: '调整消耗量' },
{ type: 'numeric', data: 'usageQty', title: '用量' },
{ type: 'numeric', data: 'usageTotalExTax', title: '除税合价' },
{ type: 'numeric', data: 'usageTotalInTax', title: '含税合价' },
{ type: 'numeric', data: 'totalBasePriceExTax', title: '除税基价合价' },
{ type: 'numeric', data: 'totalBasePriceInTax', title: '含税基价合价' },
{ type: 'numeric', data: 'totalCompilePriceExTax', title: '除税编制价合价' },
{ type: 'numeric', data: 'totalCompilePriceInTax', title: '含税编制价合价' },
{ type: 'text', data: 'calcBase', title: '计算基数' },
{ type: 'text', data: 'adjustmentFormula', title: '调整公式' },
{ type: 'text', data: 'priceSourceText', title: '价格来源' },
]
//单位汇总
export const unitSummary = [
{ type: 'text', data: 'divisionalCost', title: '分部分项工程费', _id: 1 },
{ type: 'text', data: 'muckTransportCost', title: '余泥渣土运输与排放费用', _parentId: 1 },
{ type: 'text', data: 'measureProjectCost', title: '措施项目费', _id: 2 },
{ type: 'text', data: 'greenConstructionSafetyCost', title: '绿色施工安全防护措施费', _parentId: 2 },
{ type: 'text', data: 'otherMeasureCost', title: '其他措施费', _parentId: 2 },
{ type: 'text', data: 'otherProjectCost', title: '其他项目费', _id: 3 },
{ type: 'text', data: 'provisionalAmount', title: '暂列金额', _parentId: 3 },
{ type: 'text', data: 'provisionalEstimate', title: '暂估价', _parentId: 3 },
{ type: 'text', data: 'dayWork', title: '计日工', _parentId: 3 },
{ type: 'text', data: 'generalContractServiceCost', title: '总承包服务费', _parentId: 3 },
{ type: 'text', data: 'budgetLumpSumCost', title: '预算包干费', _parentId: 3 },
{ type: 'text', data: 'highQualityProjectCost', title: '工程优质费', _parentId: 3 },
{ type: 'text', data: 'estimateRangeDifference', title: '概算幅度差', _parentId: 3 },
{ type: 'text', data: 'claimCost', title: '索赔费用', _parentId: 3 },
{ type: 'text', data: 'onSiteVisaCost', title: '现场签证费用', _parentId: 3 },
{ type: 'text', data: 'otherCost', title: '其他费用', _parentId: 3 },
{ type: 'text', data: 'preTaxProjectCost', title: '税前工程造价', _id: 4 },
{ type: 'text', data: 'vatOutputTax', title: '增值税销项税额', _id: 5 },
{ type: 'text', data: 'taxIncludedTotalCost', title: '含税工程总造价', _id: 6 },
{ type: 'text', data: 'laborCost', title: '人工费', _id: 7 }
]

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import Reference from '../components/reference/index.vue';
const props = defineProps<{
selectedNode?: any;
}>();
</script>
<template>
<Reference ref="referenceRef" :selectedNode="props.selectedNode" tabType="division"/>
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,295 @@
// import type { Ref } from 'vue';
// import { workbenchColumns } from '../configs';
// import { ElMessage } from 'element-plus';
// import { getDictLabel } from '@vben/hooks';
// import { DICT_TYPE } from '@vben/constants';
// import { createDivisionTemplate, deleteDivisionTemplate, updateDivisionTemplate } from '#/api/database/interface/config';
// let contextMenuOnHeader = false
// const columns: any[] = workbenchColumns.map((item: any) => ({
// ...item,
// width: 120,
// }));
// const handleContextMenu = (hstRef: Ref<any>) => {
// return {
// items: {
// // row_above: {},
// // row_below: {},
// // remove_row: {},
// insert_division: {
// name: '插入分部',
// callback(this: any, key: string, selection: any[], clickEvent: MouseEvent) {
// // const row = this.getSelectedLast?.()[0]
// // const hotInstance = hstRef.value.hotInstance
// const row = this.countRows()
// console.log('row', row)
// this.alter('insert_row_below', row, 1);
// this.setDataAtRowProp([
// [row, 'nodeType', 'division'],
// [row, 'category', getDictLabel(DICT_TYPE.DIVISION_CATEGORY, 'division')],
// [row, 'name', '新建分部'],
// [row, 'tabType', 'division'],
// [row, 'code', String(new Date().getTime())],
// ], 'insertRow')
// }
// },
// insert_child_division: {
// name: '插入子分部',
// disabled(this: any): boolean {
// // 只在分部行上可用
// const selected = this.getSelectedLast?.()
// const row = selected[0]
// const rowData = this.getSourceDataAtRow(row)
// return rowData?.nodeType !== 'division'
// },
// callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
// const hotInstance = hstRef.value.hotInstance
// const selected = this.getSelectedLast?.()
// const row = selected[0]
// const rowData = this.getSourceDataAtRow(row)
// // // 子分部的父节点就是当前选中的分部
// const parentId = rowData?.id
// // // 先调用API创建节点
// const data = {
// catalogItemId: hstRef.value.catalogItemId,
// parentId: parentId,
// nodeType: 'division',
// name: '新建子分部',
// tabType: 'division',
// code: String(new Date().getTime())
// }
// try {
// const id = await createDivisionTemplate(data)
// console.log('插入子分部: API创建成功ID:', data)
// // // 创建成功后,作为选中分部的子节点插入
// hstRef?.value.addChild().then((res: any) => {
// // // 创建成功后,在表格中插入新行
// hotInstance.setDataAtRowProp([
// [res, 'id', id],
// [res, 'nodeType', data.nodeType],
// [res, 'category', getDictLabel(DICT_TYPE.DIVISION_CATEGORY, data.nodeType)],
// [res, 'name', data.name],
// [res, 'parentId', data.parentId],
// [res, 'code', data.code],
// ], 'created')
// })
// } catch (err) {
// console.error('插入子分部: API创建失败', err)
// }
// }
// },
// insert_boq: {
// name: '插入清单',
// callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
// const hotInstance = hstRef.value.hotInstance
// const selected = this.getSelectedLast?.()
// const row = selected[0]
// const rowData = this.getSourceDataAtRow(row)
// // console.log('插入清单: 选中行', row, '节点类型', rowData?.nodeType, '节点ID', rowData?.id, '父节点ID', rowData?.parentId)
// // 确定父节点ID
// let parentId: any = null
// const nodeType = rowData?.nodeType
// if (nodeType === 'division') {
// parentId = rowData?.id
// } else if (nodeType === 'boq') {
// parentId = rowData?.parentId
// } else if (nodeType === 'quota') {
// // const parentBoqData = findParentByType(this, row, 'boq')
// // parentId = parentBoqData?.parentId
// // operationType = 'below'
// //this.alter('insert_row_below', row, 1)
// } else {
// console.warn('清单只能在根目录或分部节点下创建,当前节点类型:', rowData?.nodeType)
// return
// }
// // 先调用API创建节点
// const data = {
// catalogItemId: hstRef.value.catalogItemId,
// parentId: parentId,
// nodeType: 'boq',
// name: '新建清单',
// tabType: 'division',
// code: String(new Date().getTime())
// }
// try {
// const id = await createDivisionTemplate(data)
// console.log('插入清单: API创建成功ID:', data)
// if (!id) return;
// hstRef?.value.addChild().then((res: any) => {
// // 获取父节点对象,统计已有 boq 子节点数量
// const plugin = this.getPlugin('nestedRows');
// const parentObj = plugin.dataManager.getDataObject(row);
// const children = parentObj?.__children || [];
// const boqCount = children.filter((child: any) => child.nodeType === 'boq').length;
// const sortOrder = boqCount + 1;
// // 创建成功后,在表格中插入新行
// hotInstance.setDataAtRowProp([
// [res, 'sortOrder', sortOrder],
// [res, 'id', id],
// [res, 'nodeType', data.nodeType],
// [res, 'category', getDictLabel(DICT_TYPE.DIVISION_CATEGORY, data.nodeType)],
// [res, 'name', data.name],
// [res, 'parentId', parentId],
// [res, 'code', data.code],
// ], 'created')
// })
// } catch (err) {
// console.error('插入清单: API创建失败', err)
// }
// }
// },
// separator1: '---------',
// remove_row: {
// name: '删除',
// callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
// const selected = this.getSelectedLast?.()
// const row = selected[0]
// const rowData = this.getSourceDataAtRow(row)
// if (rowData.id) {
// deleteDivisionTemplate(rowData.id)
// }
// this.alter('remove_row', row, 1);
// }
// },
// col_show: {
// name: '列头筛选',
// callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
// console.log("列头筛选")
// if (hstRef.value.hotInstance.__onColumnApply) hstRef.value.hotInstance.__onColumnApply()
// }
// }
// },
// }
// };
// export const useHst = (hstRef: Ref<any>) => {
// const hstSettings = {
// data: [],
// colWidths: 100,
// columns,
// colHeaders: true,
// rowHeaders: false,
// nestedRows: false,
// bindRowsWithHeaders: true,
// hiddenColumns: {
// copyPasteEnabled: true,
// indicators: false,
// },
// contextMenu: handleContextMenu(hstRef),
// outsideClickDeselects: false,
// currentRowClassName: 'row-highlight',
// afterChange(this: any, changes: any[], source: string) {
// if (!changes || source === 'loadData' || source === 'updateId' || source === 'created') return;
// const hasRealChange = changes.some((change: any) => {
// const [, , oldValue, newValue] = change;
// const isEmpty = (v: any) => v === null || v === undefined || v === '';
// return oldValue !== newValue && !(isEmpty(oldValue) && isEmpty(newValue));
// })
// if (!hasRealChange) return;
// const row = changes[0][0];
// const rowData = this.getSourceDataAtRow(row);
// const data = {
// ...rowData,
// catalogItemId: hstRef?.value?.catalogItemId,
// tabType: rowData?.tabType == undefined ? 'division' : rowData?.tabType,
// code: rowData?.code == undefined ? String(new Date().getTime()) : rowData?.code,
// }
// console.log('data', data);
// if (rowData.id == null) {
// createDivisionTemplate(data).then(res => {
// this.setDataAtRowProp(row, 'id', res, 'updateId')
// ElMessage.success('新增成功')
// }).catch(err => {
// console.error('新增失败', err)
// })
// } else {
// updateDivisionTemplate(data).then(res => {
// console.log('updateUnitDivisionTemplate', res)
// }).catch(err => {
// console.error('更新失败', err)
// })
// }
// },
// beforeOnCellContextMenu(event: MouseEvent, coords: any) {
// // coords.row < 0 表示右键点击的是列头
// contextMenuOnHeader = coords.row < 0
// },
// beforeContextMenuSetItems(menuItems: any[]) {
// const headerColumns = ['col_show']
// // 必须原地修改数组,因为 Handsontable runHooks 不使用钩子返回值
// const filtered = contextMenuOnHeader
// ? menuItems.filter(item => headerColumns.includes(item.key))
// : menuItems.filter(item => !headerColumns.includes(item.key))
// menuItems.splice(0, menuItems.length, ...filtered)
// }
// };
// const hstContextMenuItems = [
// {
// key: 'insert',
// name: '插入行',
// callback: async (hotInstance: any) => {
// // hstRef.value.nestedRowsLoadData([
// // ...hotInstance.getSourceData(),
// // {nodeType:'division',category:'分部', name: '新建分部'}
// // ]);
// // const row = hotInstance.countRows()
// // hotInstance.alter('insert_row_below', row, 1);
// // hotInstance.setDataAtRowProp([
// // [row, 'nodeType', 'division'],
// // [row, 'category', '分部'],
// // [row, 'name', '新建分部'],
// // ], 'insertRow')
// if (!hstRef.value.catalogItemId) {
// ElMessage.error('请选择左侧专业类别')
// return
// }
// // // 先调用API创建节点
// const data = {
// catalogItemId: hstRef.value.catalogItemId,
// nodeType: 'division',
// name: '新建分部',
// tabType: 'division',
// code: String(new Date().getTime())
// }
// try {
// const id = await createDivisionTemplate(data)
// console.log('插入根分部: API创建成功ID:', data)
// const row = hotInstance.countRows()
// // // 创建成功后,在表格中插入新行
// hotInstance.setDataAtRowProp([
// [row, 'id', id],
// [row, 'nodeType', data.nodeType],
// [row, 'category', getDictLabel(DICT_TYPE.DIVISION_CATEGORY, data.nodeType)],
// [row, 'name', data.name],
// [row, 'code', data.code],
// ], 'created')
// } catch (err) {
// console.error('插入子分部: API创建失败', err)
// }
// },
// },
// ];
// const hstInit = () => {
// hstRef.value.nestedRowsLoadData([]);
// };
// return {
// columns,
// hstInit,
// hstSettings,
// hstContextMenuItems,
// };
// };

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { DbTree } from '#/components/db-tree';
import { LazyLoad } from '#/components/lazy-load';
import { Page } from '@vben/common-ui';
import { ElCard, ElSplitter, ElSplitterPanel, ElTabPane, ElTabs } from 'element-plus';
import { onMounted, ref } from 'vue';
import Workbench from './workbench/index.vue';
import { useTree } from './use-tree';
import Materials from './materials/index.vue';
import Division from './division/index.vue';
import Measure from './measure/index.vue';
import Other from './other/index.vue';
import Summary from './summary/index.vue';
const dbTreeRef = ref();
const activeTab = ref('workbench');
const {
treeData,
selectedNode,
rootMenus,
nodeMenus,
loadCategoryTree,
handleTreeNodeEdit,
handleTreeNodeClick,
} = useTree(dbTreeRef);
onMounted(() => {
loadCategoryTree();
});
</script>
<template>
<Page auto-content-height>
<ElSplitter>
<ElSplitterPanel size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbTree
ref="dbTreeRef"
:tree-data="treeData"
:draggable="false"
:default-expanded-level="4"
:root-menus="rootMenus"
:node-menus="nodeMenus"
@node-edit="handleTreeNodeEdit"
@node-click="handleTreeNodeClick"
/>
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel>
<LazyLoad :delay="50">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<ElTabs v-model="activeTab" type="border-card" class="h-full w-full configs-tabs">
<ElTabPane label="工作台字段" name="workbench" class="h-full w-full" >
<Workbench v-if="activeTab === 'workbench'" :selected-node="selectedNode" />
</ElTabPane>
<ElTabPane label="工料机字段" name="materials" class="h-full">
<Materials v-if="activeTab === 'materials'" :selected-node="selectedNode" />
</ElTabPane>
<ElTabPane label="分部分项" name="division" class="h-full">
<Division v-if="activeTab === 'division'" :selected-node="selectedNode" />
</ElTabPane>
<ElTabPane label="措施项目" name="measure" class="h-full">
<Measure v-if="activeTab === 'measure'" :selected-node="selectedNode" />
</ElTabPane>
<ElTabPane label="其他项目" name="other" class="h-full">
<Other v-if="activeTab === 'other'" :selected-node="selectedNode" />
</ElTabPane>
<ElTabPane label="单位汇总" name="summary" class="h-full">
<Summary v-if="activeTab === 'summary'" :selected-node="selectedNode" />
</ElTabPane>
</ElTabs>
</ElCard>
</LazyLoad>
</ElSplitterPanel>
</ElSplitter>
</Page>
</template>
<style lang="scss">
.configs-tabs {
.el-tabs__header {
margin: 0;
}
.el-tabs__content {
padding: 0;
}
}
</style>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { DbHst } from '#/components/db-hst';
import { onMounted, ref, watch } from 'vue';
import { materialSummaryColumns } from '../configs';
import { useHst } from './use-hst';
import { getResourceFieldList, batchResourceField } from '#/api/database/interface/config';
const props = defineProps<{
selectedNode?: any;
}>();
const hstRef = ref();
const { hstInit, hstSettings, hstContextMenuItems } = useHst(hstRef);
const loadAndFillData = async (catalogItemId: number) => {
let res = await getResourceFieldList(catalogItemId);
if (res.length === 0) {
// TODO: 批量创建【工料机字段】后重新获取
const list: any[] = materialSummaryColumns.map((item: any, index: number) => ({
seqNo: index+1,
fieldName: item.title,
fieldCode: item.data,
visible: false,
remark: item.remark || '',
catalogItemId: catalogItemId
}));
// console.log('list',list)
const batch = await batchResourceField(list)
res = await getResourceFieldList(catalogItemId);
}
hstRef.value.catalogItemId = catalogItemId;
hstRef.value.hotInstance.loadData(res);
};
onMounted(() => {
setTimeout(() => {
if (props.selectedNode) {
loadAndFillData(props.selectedNode.id);
}
}, 200);
});
watch(
() => props.selectedNode,
(val) => {
if (val) {
loadAndFillData(val.id);
}
},
);
</script>
<template>
<div class="h-full w-full flex flex-col">
<DbHst ref="hstRef" :settings="hstSettings" :contextMenuItems="hstContextMenuItems" />
</div>
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,115 @@
import type { Ref } from 'vue';
import { createResourceField, deleteResourceField, updateResourceField } from '#/api/database/interface/config';
import { ElMessage } from 'element-plus';
let contextMenuOnHeader = false
const columns = [
{ type: 'text', data: 'seqNo', title: '序号', width: 60, className: 'htCenter' },
{ type: 'text', data: 'fieldName', title: '字段名称' },
{ type: 'text', data: 'fieldCode', title: '字段' },
{ type: 'checkbox', data: 'visible', title: '隐藏', className: 'htCenter' },
{ type: 'text', data: 'remark', title: '备注' },
];
const contextMenuSettings = {
items: {
row_above: {},
row_below: {},
remove_row: {
callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
const selected = this.getSelectedLast?.()
const row = selected[0]
const rowData = this.getSourceDataAtRow(row)
if(rowData.id){
deleteResourceField(rowData.id)
}
this.alter('remove_row', row, 1);
}
},
col_field_en: {
name(this: any): any{
const hiddenColumnsPlugin = this.getPlugin('hiddenColumns');
return ( hiddenColumnsPlugin.isHidden(2)?'显示':'隐藏')+'字段-英文'
},
callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
const hiddenColumnsPlugin = this.getPlugin('hiddenColumns');
hiddenColumnsPlugin.isHidden(2) ? hiddenColumnsPlugin.showColumns([2]) : hiddenColumnsPlugin.hideColumns([2])
this.render();
}
}
},
};
export const useHst = (hstRef: Ref<any>) => {
const hstSettings = {
data: [],
colWidths: 150,
columns,
colHeaders: true,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
hiddenColumns: {
columns: [2]
},
contextMenu: contextMenuSettings,
outsideClickDeselects: false,
currentRowClassName: 'row-highlight',
afterChange(this: any, changes: any[], source: string) {
if (!changes || source === 'loadData' || source === 'updateId') return;
const hasRealChange = changes.some((change: any) => {
const [, , oldValue, newValue] = change;
return oldValue !== newValue && newValue != '';
})
if (!hasRealChange) return;
const row = changes[0][0];
const rowData = this.getSourceDataAtRow(row);
const data = {...rowData, catalogItemId: hstRef?.value?.catalogItemId}
if (rowData.id == null) {
createResourceField(data).then(res => {
this.setDataAtRowProp(row, 'id', res, 'updateId')
ElMessage.success('新增成功')
}).catch(err => {
console.error('新增失败', err)
})
} else {
updateResourceField(data).then(res => {
console.log('updateUnitResourceField', res)
}).catch(err => {
console.error('更新失败', err)
})
}
},
beforeOnCellContextMenu(event: MouseEvent, coords: any) {
contextMenuOnHeader = coords.row < 0
},
beforeContextMenuSetItems(menuItems: any[]) {
const headerColumns = ['col_field_en']
const filtered = contextMenuOnHeader
? menuItems.filter(item => headerColumns.includes(item.key))
: menuItems.filter(item => !headerColumns.includes(item.key))
menuItems.splice(0, menuItems.length, ...filtered)
}
};
const hstContextMenuItems = [
{
key: 'insert',
name: '插入新字段',
callback: (hotInstance: any) => {
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1);
},
},
];
const hstInit = async () => {
}
return {
hstInit,
hstSettings,
hstContextMenuItems,
};
};

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import Reference from '../components/reference/index.vue';
const props = defineProps<{
selectedNode?: any;
}>();
</script>
<template>
<Reference ref="referenceRef" :selectedNode="props.selectedNode" tabType="measure" />
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,145 @@
// import type { Ref } from 'vue';
// import { workbenchColumns } from '../configs';
// import { saveUnitTabRefs } from '#/api/database/interface/config';
// const columns: any[] = [
// { type: 'checkbox', data: 'selected', title: '选择', className: 'htCenter', width: '60' },
// ...workbenchColumns.map((item: any) => ({
// ...item,
// width: 100,
// }))
// ]
// const handleContextMenu = (hstRef: Ref<any>) => {
// return {
// items: {
// // row_above: {},
// // row_below: {},
// // remove_row: {},
// col_show: {
// name: '列头筛选',
// callback: async function (key: string, selection: any[], clickEvent: MouseEvent) {
// console.log("列头筛选")
// if(hstRef.value.hotInstance.__onColumnApply) hstRef.value.hotInstance.__onColumnApply()
// }
// }
// },
// }
// };
// export const useHst = (hstRef: Ref<any>) => {
// /**
// * 递归设置所有子节点的选中状态
// */
// const setChildrenSelected = (node: any, selected: boolean): void => {
// if (!node.__children?.length) return;
// node.__children.forEach((child: any) => {
// child.selected = selected;
// setChildrenSelected(child, selected);
// });
// };
// /**
// * 递归检查并更新父节点的选中状态
// * 只要有子节点选中,父节点就选中
// */
// const updateParentSelected = (node: any, dataManager: any): void => {
// const parent = dataManager.getRowParent(node);
// if (!parent) return;
// // 检查是否有子节点被选中
// parent.selected = parent.__children?.some((child: any) => child.selected) ?? false;
// // 递归向上更新父节点
// updateParentSelected(parent, dataManager);
// };
// /**
// * 处理 checkbox 变化事件
// */
// const handleCheckboxChange = (changes: any[] | null, source: string): void => {
// if (!changes || source === 'loadData') return;
// const hotInstance = hstRef.value?.hotInstance;
// if (!hotInstance) return;
// const nestedRowsPlugin = hotInstance.getPlugin('nestedRows');
// if (!nestedRowsPlugin?.enabled) return;
// const dataManager = nestedRowsPlugin.dataManager;
// for (const [row, prop, , newValue] of changes) {
// if (prop !== 'selected') continue;
// const dataObject = dataManager.getDataObject(row);
// if (!dataObject) continue;
// // 如果是父节点,递归设置所有子节点的选中状态
// if (dataManager.isParent(row)) {
// setChildrenSelected(dataObject, newValue);
// }
// // 向上更新父节点的选中状态
// updateParentSelected(dataObject, dataManager);
// }
// hotInstance.render();
// const data = hotInstance?.getSourceData() ?? []
// const res = data.filter((f:any)=>f.selected).map((item:any)=>item.id)
// if(res.length > 0){
// saveUnitTabRefs({catalogItemId: hstRef.value?.catalogItemId, tabType: hstRef.value?.tabType, templateNodeIds: res})
// }
// console.log(res)
// };
// const hstSettings = {
// data: [],
// colWidths: 100,
// columns,
// colHeaders: true,
// rowHeaders: false,
// nestedRows: false,
// bindRowsWithHeaders: true,
// hiddenColumns: {
// copyPasteEnabled: true,
// indicators: false,
// },
// contextMenu: handleContextMenu(hstRef),
// // contextMenu: contextMenuSettings,
// outsideClickDeselects: false,
// currentRowClassName: 'row-highlight',
// afterChange: handleCheckboxChange,
// // afterChange(changes: any[], source: string) {
// // if (!changes || source === 'loadData' || source === 'updateId') return;
// // const row = changes[0][0];
// // const rowData = this.getSourceDataAtRow(row);
// // console.log('measure afterChange', rowData);
// // },
// };
// const hstContextMenuItems: any[] = [
// // {
// // key: 'insert',
// // name: '引用-分部分项',
// // callback: (hotInstance: any) => {
// // // hotInstance.alter('insert_row_below', hotInstance.countRows(), 1);
// // if(hstRef.value.hotInstance.__onReference) hstRef.value.hotInstance.__onReference()
// // },
// // },
// ];
// const hstInit = () => {
// hstRef.value.hotInstance.loadData([]);
// };
// return {
// columns,
// hstInit,
// hstSettings,
// hstContextMenuItems,
// };
// };

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import Reference from '../components/reference/index.vue';
const props = defineProps<{
selectedNode?: any;
}>();
</script>
<template>
<Reference ref="referenceRef" :selectedNode="props.selectedNode" tabType="other"/>
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,63 @@
// import type { Ref } from 'vue';
// import { workbenchColumns } from '../configs';
// const columns: any[] = workbenchColumns.map((item: any) => ({
// type: 'text',
// data: item.data,
// title: item.title,
// width: 100,
// }));
// const contextMenuSettings = {
// items: {
// row_above: {},
// row_below: {},
// remove_row: {},
// },
// };
// export const useHst = (hstRef: Ref<any>) => {
// const hstSettings = {
// data: [],
// colWidths: 100,
// columns,
// rowHeaders: false,
// nestedRows: false,
// bindRowsWithHeaders: true,
// hiddenColumns: {
// copyPasteEnabled: true,
// indicators: false,
// },
// contextMenu: contextMenuSettings,
// outsideClickDeselects: false,
// currentRowClassName: 'row-highlight',
// afterChange(changes: any[], source: string) {
// if (!changes || source === 'loadData' || source === 'updateId') return;
// const row = changes[0][0];
// const rowData = this.getSourceDataAtRow(row);
// console.log('other afterChange', rowData);
// },
// };
// const hstContextMenuItems = [
// {
// key: 'insert',
// name: '插入行',
// callback: (hotInstance: any) => {
// hotInstance.alter('insert_row_below', hotInstance.countRows(), 1);
// },
// },
// ];
// const hstInit = () => {
// hstRef.value.hotInstance.loadData([]);
// };
// return {
// hstInit,
// hstSettings,
// hstContextMenuItems,
// };
// };

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import Reference from '../components/reference/index.vue';
const props = defineProps<{
selectedNode?: any;
}>();
</script>
<template>
<Reference ref="referenceRef" :selectedNode="props.selectedNode" tabType="unit_summary"/>
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,89 @@
// import type { Ref } from 'vue';
// import { unitSummary, workbenchColumns } from '../configs';
// // const columns = [
// // { type: 'db.nestedRows', data: 'number', title: '序号', width: 160 },
// // { type: 'text', data: 'name', title: '名称', width: 200 },
// // { type: 'text', data: 'foundation', title: '计算基础'},
// // { type: 'numeric', data: 'rate', title: '费率'},
// // { type: 'numeric', data: 'total', title: '合价'},
// // ];
// const columns: any[] = workbenchColumns.map((item: any) => ({
// type: item.type,
// data: item.data,
// title: item.title,
// width: 100,
// }));
// const contextMenuSettings = {
// items: {
// row_above: {},
// row_below: {},
// remove_row: {},
// },
// };
// export const useHst = (hstRef: Ref<any>) => {
// const hstSettings = {
// data: [],
// colWidths: 150,
// columns,
// rowHeaders: false,
// nestedRows: false,
// bindRowsWithHeaders: true,
// hiddenColumns: {
// copyPasteEnabled: true,
// indicators: false,
// },
// contextMenu: contextMenuSettings,
// outsideClickDeselects: false,
// currentRowClassName: 'row-highlight',
// afterChange(changes: any[], source: string) {
// if (!changes || source === 'loadData' || source === 'updateId') return;
// const row = changes[0][0];
// const rowData = this.getSourceDataAtRow(row);
// console.log('supplement afterChange', rowData);
// },
// };
// const hstContextMenuItems = [
// {
// key: 'insert',
// name: '插入行',
// callback: (hotInstance: any) => {
// hotInstance.alter('insert_row_below', hotInstance.countRows(), 1);
// },
// },
// ];
// const hstInit = () => {
// // const toRow = (item: any, num: number | string) => ({
// // number: num, name: item.title, data: item.data, foundation: '', rate: null, total: null,
// // });
// // const childrenMap = unitSummary
// // .filter((c: any) => c._parentId != null)
// // .reduce((m: Map<number, any[]>, c: any) => m.set(c._parentId, [...(m.get(c._parentId) || []), c]), new Map<number, any[]>());
// // let i = 0;
// // const data = unitSummary
// // .filter((p: any) => p._id != null)
// // .map((p: any) => {
// // const parentNum = ++i;
// // const row: any = toRow(p, parentNum);
// // const children = childrenMap.get(p._id);
// // if (children?.length) row.__children = children.map((c: any, ci: number) => toRow(c, `${parentNum}.${ci + 1}`));
// // return row;
// // });
// hstRef.value.nestedRowsLoadData([]);
// };
// return {
// hstInit,
// hstSettings,
// hstContextMenuItems,
// };
// };

View File

@@ -0,0 +1,166 @@
// import {
// createCatalogNode,
// deleteCatalogNode,
// type CardinalRateApi,
// } from '#/api/database/interface/cardinalRate';
import {
createQuotaCatalogItem,
deleteQuotaCatalogItem
} from '#/api/database/quota/index';
import { ElMessage } from 'element-plus';
type MenuCallback = (command: string, node: any, data: any, treeInstance: any) => Promise<void>;
type MenuItem = {
key: string;
label: string;
divided?: boolean;
callback: MenuCallback;
};
type NodeConfig = {
nodeType: string;
name: string;
codePrefix: string;
};
// 生成唯一代码
const generateCode = (prefix: string): string => `${prefix}-${Date.now()}`;
// 节点配置
const ROOT_CONFIG: NodeConfig = { nodeType: 'root', name: '根节点', codePrefix: 'ROOT' };
const PROVINCE_CONFIG: NodeConfig = { nodeType: 'province', name: '省市', codePrefix: 'PROVINCE' };
const CONTENT_CONFIG: NodeConfig = { nodeType: 'content', name: '清单', codePrefix: 'CONTENT' };
const FIELDS_MAJORS_CONFIG: NodeConfig = { nodeType: 'fields_majors', name: '专业类别', codePrefix: 'FIELDS_MAJORS' };// 就是字段配置
// 创建菜单项
const createMenuItem = (key: string, label: string, callback: MenuCallback, divided?: boolean): MenuItem => ({
key,
label,
callback,
...(divided && { divided }),
});
// 添加根节点回调
const createAddRootCallback = (config: NodeConfig): MenuCallback => {
return async (_command, _node, _data, treeInstance) => {
const source = {
code: generateCode(config.codePrefix),
name: config.name,
nodeType: config.nodeType,
sortOrder: treeInstance.getData().length + 1,
attributes: {},
};
const res = await createQuotaCatalogItem(source);
treeInstance.addData({
id: String(res.id),
label: source.name,
children: [],
...source,
});
ElMessage.success('添加成功');
};
};
// 添加子节点回调
const createAddChildCallback = (config: NodeConfig): MenuCallback => {
return async (_command, _node, data, treeInstance) => {
const source = {
parentId: data.id,
code: generateCode(config.codePrefix),
name: config.name,
nodeType: config.nodeType,
sortOrder: (data.children?.length || 0) + 1,
attributes: {},
};
const res = await createQuotaCatalogItem(source);
treeInstance.addData({
id: String(res),
label: source.name,
children: [],
...source,
});
ElMessage.success('添加成功');
};
};
// 插入节点回调
const createInsertCallback = (config: NodeConfig, position: 'above' | 'below'): MenuCallback => {
return async (_command, node, data, treeInstance) => {
const source = {
parentId: node.parent?.data?.id || null,
code: generateCode(config.codePrefix),
name: config.name,
nodeType: config.nodeType,
sortOrder: data.sortOrder || 1,
attributes: {},
};
const res = await createQuotaCatalogItem(source);
const insertMethod = position === 'above' ? 'insertAbove' : 'insertBelow';
treeInstance[insertMethod]({
id: String(res),
label: source.name,
children: [],
...source,
});
ElMessage.success('插入成功');
};
};
// 删除节点回调
const createDeleteCallback = (): MenuCallback => {
return async (_command, _node, data, treeInstance) => {
await deleteQuotaCatalogItem(data.id);
treeInstance.removeData();
ElMessage.success('删除成功');
};
};
// 根节点菜单 - 空树时显示
export const rootMenus = [
// {
// key: 'add-root',
// label: '添加根节点',
// callback: createAddRootCallback(ROOT_CONFIG),
// },
];
// 节点层级菜单
export const nodeMenus = [
// {
// level: 1, // 根节点层级
// items: [
// createMenuItem('add-province', '添加省市', createAddChildCallback(PROVINCE_CONFIG)),
// createMenuItem('remove-root', '删除', createDeleteCallback(), true),
// ],
// },
// {
// level: 2, // 省市节点层级
// items: [
// createMenuItem('add-province-above', '上方添加省市', createInsertCallback(PROVINCE_CONFIG, 'above')),
// createMenuItem('add-province-below', '下方添加省市', createInsertCallback(PROVINCE_CONFIG, 'below')),
// createMenuItem('add-content', '添加清单', createAddChildCallback(CONTENT_CONFIG)),
// createMenuItem('remove-province', '删除', createDeleteCallback(), true),
// ],
// },
{
level: 3, // 内容节点层级(叶子节点)
items: [
// createMenuItem('add-content-above', '上方添加清单', createInsertCallback(CONTENT_CONFIG, 'above')),
// createMenuItem('add-content-below', '下方添加清单', createInsertCallback(CONTENT_CONFIG, 'below')),
createMenuItem('add-fields-majors', '添加专业类别', createAddChildCallback(FIELDS_MAJORS_CONFIG)),
// createMenuItem('remove-content', '删除', createDeleteCallback(), true),
],
},
{
level: 4, // 内容节点层级(叶子节点)
items: [
createMenuItem('add-fields-majors-above', '上方添加专业类别', createInsertCallback(FIELDS_MAJORS_CONFIG, 'above')),
createMenuItem('add-fields-majors-below', '下方添加专业类别', createInsertCallback(FIELDS_MAJORS_CONFIG, 'below')),
createMenuItem('remove-fields-majors', '删除', createDeleteCallback(), true),
],
},
];

View File

@@ -0,0 +1,77 @@
import type { Ref } from 'vue';
import { getQuotaCatalogItemTree, updateQuotaCatalogItem } from '#/api/database/quota/index';
import { ElMessage } from 'element-plus';
import { ref } from 'vue';
import { nodeMenus, rootMenus } from './tree';
const transformTreeData = (data: any[]): any[] => {
if (!data || !Array.isArray(data)) {
console.warn('transformTreeData: 数据不是数组', data);
return [];
}
return data.map(item => {
const treeNode = {
...item,
id: String(item.id),
label: item.name || item.label || '未命名',
};
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
treeNode.children = transformTreeData(item.children);
}
return treeNode;
});
};
export const useTree = (_dbTreeRef: Ref<any>) => {
const treeData = ref<any[]>([]);
const selectedNode = ref<any>(null);
const loadCategoryTree = async () => {
try {
const res = await getQuotaCatalogItemTree({ exclude: 'rate_mode,majors' });
console.log('定额专业树原始数据:', res);
treeData.value = transformTreeData(res);
console.log('定额专业树转换后数据:', treeData.value);
} catch (error) {
console.error('加载定额专业树失败:', error);
}
};
const handleTreeNodeEdit = async (payload: any) => {
const { data, newLabel } = payload;
console.log('节点编辑保存:', payload);
await updateQuotaCatalogItem({
id: data.id,
code: data.code,
name: newLabel,
});
ElMessage.success('更新成功');
};
const handleTreeNodeClick = (payload: any) => {
const { data } = payload;
console.log('节点点击:', payload);
if (data.nodeType === 'fields_majors') {
selectedNode.value = { ...data };//为了每次watch都能监听到不管new/old
} else {
selectedNode.value = null;
}
};
return {
treeData,
selectedNode,
rootMenus,
nodeMenus,
loadCategoryTree,
handleTreeNodeEdit,
handleTreeNodeClick,
};
};

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { DbHst } from '#/components/db-hst';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useHst } from './use-hst';
import { getFieldConfigList, batchCreateFieldConfig } from '#/api/database/interface/config';
import { workbenchColumns } from '../configs';
const props = defineProps<{
selectedNode?: any;
}>();
const hstRef = ref();
const { hstInit, hstSettings, hstContextMenuItems } = useHst(hstRef);
const loadAndFillData = async (catalogItemId: number) => {
let res = await getFieldConfigList(catalogItemId);
if (res.length === 0) {
// TODO: 批量创建【工作台字段】后重新获取
//批量创建默认【工作台字段】
const list: any[] = workbenchColumns.map((item: any, index: number) => ({
seqNo: index+1,
fieldName: item.title,
fieldCode: item.data,
divisionsHidden: false,
measureHidden: false,
otherHidden: false,
summaryHidden: false,
remark: item.remark || '',
catalogItemId: catalogItemId
}));
const batch = await batchCreateFieldConfig(list)
// console.log('list',list, batch)
//再次获取
res = await getFieldConfigList(catalogItemId);
}
hstRef.value.catalogItemId = catalogItemId;
hstRef.value.hotInstance.loadData(res);
};
onMounted(() => {
setTimeout(() => {
if (props.selectedNode) {
loadAndFillData(props.selectedNode.id);
}
}, 200);
});
watch(
() => props.selectedNode,
(val) => {
if (val) {
loadAndFillData(val.id);
}
},
);
</script>
<template>
<div class="h-full w-full flex flex-col">
<DbHst ref="hstRef" :settings="hstSettings" :contextMenuItems="hstContextMenuItems" />
</div>
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,134 @@
import type { Ref } from 'vue';
import { createFieldConfig, deleteFieldConfig, updateFieldConfig } from '#/api/database/interface/config';
import { ElMessage } from 'element-plus';
let contextMenuOnHeader = false
const columns = [
{ type: 'text', data: 'seqNo', title: '序号', width: 60, className: 'htCenter' },
{ type: 'text', data: 'fieldName', title: '字段名称' },
{ type: 'text', data: 'fieldCode', title: '字段' },
{ type: 'checkbox', data: 'divisionHidden', title: '分部分项隐藏', className: 'htCenter' },
{ type: 'checkbox', data: 'measureHidden', title: '措施项目隐藏', className: 'htCenter' },
{ type: 'checkbox', data: 'otherHidden', title: '其他项目隐藏', className: 'htCenter' },
{ type: 'checkbox', data: 'summaryHidden', title: '汇总分析隐藏', className: 'htCenter' },
{ type: 'text', data: 'remark', title: '备注' },
];
const contextMenuSettings = {
items: {
row_above: {},
row_below: {},
remove_row: {
callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
const selected = this.getSelectedLast?.()
const row = selected[0]
const rowData = this.getSourceDataAtRow(row)
console.log(rowData)
if(rowData.id){
deleteFieldConfig(rowData.id)
}
this.alter('remove_row', row, 1);
}
},
col_field_en: {
name(this: any): any{
const hiddenColumnsPlugin = this.getPlugin('hiddenColumns');
return ( hiddenColumnsPlugin.isHidden(2)?'显示':'隐藏')+'字段-英文'
},
callback: async function (this: any, key: string, selection: any[], clickEvent: MouseEvent) {
// console.log("字段-英文")
// hstRef.value.handleColumnApply(data)
const hiddenColumnsPlugin = this.getPlugin('hiddenColumns');
// console.log('hiddenColumnsPlugin.isHidden(2)',hiddenColumnsPlugin.isHidden(2))
hiddenColumnsPlugin.isHidden(2) ? hiddenColumnsPlugin.showColumns([2]) : hiddenColumnsPlugin.hideColumns([2])
this.render();
}
}
},
};
export const useHst = (hstRef: Ref<any>) => {
const hstSettings = {
data: [],
colWidths: 100,
columns,
colHeaders: true,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
hiddenColumns: {
// copyPasteEnabled: true,
// indicators: true,
columns: [2]
},
contextMenu: contextMenuSettings,
outsideClickDeselects: false,
currentRowClassName: 'row-highlight',
afterChange(this: any, changes: any[], source: string) {
if (!changes || source === 'loadData' || source === 'updateId') return;
const hasRealChange = changes.some((change: any) => {
const [, , oldValue, newValue] = change;
const isEmpty = (v: any) => v === null || v === undefined || v === '';
return oldValue !== newValue && !(isEmpty(oldValue) && isEmpty(newValue));
})
if (!hasRealChange) return;
const row = changes[0][0];
const rowData = this.getSourceDataAtRow(row);
const data = {...rowData, catalogItemId: hstRef?.value?.catalogItemId}
console.log('data', data);
if (rowData.id == null) {
createFieldConfig(data).then(res => {
this.setDataAtRowProp(row, 'id', res, 'updateId')
ElMessage.success('新增成功')
}).catch(err => {
console.error('新增失败', err)
})
} else {
updateFieldConfig(data).then(res => {
console.log('updateResourceItems', res)
}).catch(err => {
console.error('更新失败', err)
})
}
},
beforeOnCellContextMenu(event: MouseEvent, coords: any) {
// coords.row < 0 表示右键点击的是列头
contextMenuOnHeader = coords.row < 0
},
beforeContextMenuSetItems(menuItems: any[]) {
const headerColumns = ['col_field_en']
// 必须原地修改数组,因为 Handsontable runHooks 不使用钩子返回值
const filtered = contextMenuOnHeader
? menuItems.filter(item => headerColumns.includes(item.key))
: menuItems.filter(item => !headerColumns.includes(item.key))
menuItems.splice(0, menuItems.length, ...filtered)
}
};
const hstContextMenuItems = [
{
key: 'insert',
name: '插入新字段',
callback: (hotInstance: any) => {
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1);
},
},
];
const hstInit = async () => {
// const data: any[] = workbenchColumns.map((item: any, index: number) => ({
// ...{ number: index + 1, fieldName: item.title, fieldCode: item.data, divisionsHidden: false, measureHidden: false, otherHidden: false, summaryHidden: false, remark: item.remark || '' }
// }));
// // console.log('data', data);
// hstRef.value.hotInstance.loadData(data);
}
return {
hstInit,
hstSettings,
hstContextMenuItems,
};
};

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch,computed, readonly } from 'vue'
import { Page } from '@vben/common-ui';
import { ElSplitter,ElSplitterPanel,ElCard,ElTabs,ElTabPane } from 'element-plus';
import { DbTree } from '#/components/db-tree';
// 导入子组件
import VariableSettings from './unit/VariableSettings.vue'
type Tree = { id: string; label: string; children?: Tree[] }
const categoryTreeData = ref<Tree[]>([
{
id: '1',
label: '行业总类',
children: [
{
id: '2',
label: '广东',
children: [
{ id: '3', label: '行业1' },
{ id: '4', label: '行业2' },
{ id: '5', label: '行业3' }
]
}
]
},
{
id: '11',
label: '行业2',
children: [
{
id: '12',
label: '广西',
children: [
{ id: '13', label: '行业5' },
{ id: '14', label: '行业6' },
{ id: '15', label: '行业7' }
]
}
]
}
])
const categoryHandleSelect = (payload: { node: any, data: any, event: MouseEvent }) => {
const { node, data, event } = payload
console.log('categoryhandleSelect',node, data, event)
}
const detailHandleSelect = (node: Tree) => {
// if (topHstRef.value && typeof topHstRef.value.loadData === 'function') {
// // console.log('hstData.value',hstData.value)
// // topHstRef.value.loadData(topHstData.value)
// }
}
onMounted(() => {
})
onUnmounted(() => {
})
</script>
<template>
<Page auto-content-height>
<ElSplitter>
<ElSplitterPanel size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbTree
ref="detailTreeRef"
:tree-data="categoryTreeData"
@node-click="categoryHandleSelect"
/>
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full">
<VariableSettings />
</ElCard>
</ElSplitterPanel>
</ElSplitter>
</Page>
</template>
<style lang="scss">
.el-tabs--border-card>.el-tabs__content {
padding: 0 !important;
}
</style>