工料机、定额基价、定额费率、定额取费

This commit is contained in:
2026-01-03 14:59:45 +08:00
parent e974bf361d
commit 618bb6699e
65 changed files with 13251 additions and 2624 deletions

View File

@@ -4,7 +4,9 @@ VITE_PORT=5777
VITE_BASE=/ VITE_BASE=/
# 请求路径 # 请求路径
VITE_BASE_URL=http://127.0.0.1:48080 #VITE_BASE_URL=http://127.0.0.1:48080
VITE_BASE_URL=http://test.yihuiyong.cn:48080
# 接口地址 # 接口地址
VITE_GLOB_API_URL=/admin-api VITE_GLOB_API_URL=/admin-api
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务 # 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务

View File

@@ -0,0 +1,238 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MaterialsApi {
/** 工料机-分类 */
// export interface Catalogs {
// createTime: number,
// updateTime: number,
// creator: string,
// updater: string,
// deleted: number,
// tenantId: number,
// id?: number,
// type: string
// versionNo: string,
// name: string
// }
export interface Catalogs {
id?: number,
parentId: number,
code: string,
name: string,
nodeType: string,
sortOrder: number,
attributes: CatalogItemAttributes,
children?: Catalogs[]
}
// export interface Acea {
// id?: number,
// parentId: number,
// code: string,
// name: string,
// nodeType: string,
// sortOrder: number,
// attributes: object,
// children:Acea[]
// }
export interface AddCategories {
id?: number,
parentId: number,
code?: string,
name: string,
nodeType: string, // "specialty"
sortOrder: number,
}
/** 工料机项属性 */
export interface CatalogItemAttributes {
spec?: string;
grade?: string;
standard?: string;
[key: string]: any;
}
/** 工料机项 */
export interface CatalogItems {
id: number;
catalogItemId: number;
name: string;
categoryId: number;
categoryName: string;
type: string;
unit: string;
taxRate: number;
taxExclBasePrice: number | null;
taxInclBasePrice: number | null;
taxExclCompilePrice: number | null;
taxInclCompilePrice: number | null;
calcBase: string | null;
isMerged: number | null;
attributes: CatalogItemAttributes;
createTime: number;
updateTime: number;
categoryTreeId: number | null;
categoryTreeName: string | null;
}
export interface ResourceItems {
createTime: number;
updateTime: number;
creator: string | null;
updater: string | null;
deleted: number;
tenantId: number;
id: number;
code: string,
name: string,
spec?: string;//attributes?.spec
unit: string;
taxRate: number;
//类别ID- ResourceItemDo.categoryId
categoryName: string;//→从 yhy_resource_category.name 获取
taxExclBasePrice: string;
taxInclBasePrice: string;
taxExclCompilePrice: string;
taxInclCompilePrice: string;
attributes: CatalogItemAttributes;
// 虚拟字段(计算合价)
taxExclBaseTotal?: number;
taxInclBaseTotal?: number;
taxExclCompileTotal?: number;
taxInclCompileTotal?: number;
}
export interface ResourceCategories {
code: string;
name: string;
sortOrder: number;
taxExclBaseCode: string;
taxInclBaseCode: string;
taxExclCompileCode: string;
taxInclCompileCode: string;
attributes: {
description?: string;
[key: string]: any;
};
}
}
//【第一层】资源库版本
// export function getCatalogsList() {
// return requestClient.get<MaterialsApi.Catalogs[]>('/core/resource/catalogs');
// }
//【第二层】工料机机类树
export function getCatalogsTreeList() {
return requestClient.get<MaterialsApi.Catalogs[]>('/core/resource/category-tree/tree');
}
export function getCategoriesTree(id: any) {
return requestClient.get<MaterialsApi.Catalogs[]>(`/core/resource/category-tree/${id}/categories`);
}
export function createCategoriesTree(data: MaterialsApi.AddCategories) {
return requestClient.post('/core/resource/category-tree', data);
}
export function updateCategoriesTree(data:MaterialsApi.AddCategories) {
return requestClient.put('/core/resource/category-tree', data);
}
export function deleteCategoriesTree(id: number) {
return requestClient.delete(`core/resource/category-tree/${id}`);
}
//6.6 添加树节点与类别的关联
export function createCategoryTreeMapping(data: any) {
return requestClient.post('/core/resource/category-tree/mapping', data);
}
export function deleteCategoryTreeMapping(categoryTreeId: any, categoryId: any) {
return requestClient.delete(`/core/resource/category-tree/mapping/${categoryTreeId}/${categoryId}`);
}
//【第三层】工料机目录树
export function getCatalogsTreeItems(id: any) {
return requestClient.get<MaterialsApi.Catalogs[]>(`/core/resource/catalog-items/${id}/tree`);
}
// 2.2 创建工料机目录树节点
export function createCatalogItem(data: {
categoryTreeId: number | string;
code: string;
name: string;
parentId?: number | string | null;
sortOrder?: number;
attributes?: any;
}) {
return requestClient.post<number | string>('/core/resource/catalog-items', data);
}
// 2.3 更新工料机目录树节点
export function updateCatalogItem(data: {
id: number | string;
categoryTreeId?: number | string;
code?: string;
name?: string;
unit?: string;
parentId?: number | string | null;
parentPath?: string[];
sortOrder?: number;
attributes?: any;
}) {
return requestClient.put('/core/resource/catalog-items', data);
}
// 2.4 删除工料机目录树节点
export function deleteCatalogItem(id: number | string) {
return requestClient.delete(`/core/resource/catalog-items/${id}`);
}
// 2.5 获取目录树节点允许的类别列表
export function getCatalogItemsAllowedList(id: number) {
return requestClient.get<any[]>(`/core/resource/catalog-items/${id}/allowed-categories`);
}
// 2.6 交换两个目录树节点的排序
export function swapCatalogItemSort(nodeId1: number | string, nodeId2: number | string) {
return requestClient.put('/core/resource/catalog-items/swap-sort', {
nodeId1,
nodeId2
});
}
// 【第四层】工料机项
export function getCatalogItemsPage(params: PageParam) {
return requestClient.get<PageResult<MaterialsApi.CatalogItems>>(
'core/resource/items',
{ params },
);
}
//3.4 创建工料机项
export function createResourceItems(data: any) {
return requestClient.post('/core/resource/items', data);
}
//3.6 更新工料机项
export function updateResourceItems(data: any) {
return requestClient.put('/core/resource/items', data);
}
//3.7 删除工料机项
export function deleteResourceItems(id: number) {
return requestClient.delete(`/core/resource/items/${id}`);
}
//【第五层】复合工料机价格
export function getResourceItemsPage(params: any) {
return requestClient.get<PageResult<MaterialsApi.ResourceItems>>(
'/core/resource/merged/page',
{ params },
);
}
//5.1 创建复合工料机
export function createResourceMerged(data: any) {
return requestClient.post('/core/resource/merged/create', data);
}
//5.2 更新复合工料机
export function updateResourceMerged(data: any) {
return requestClient.put('/core/resource/merged/update', data);
}
// 5.3 删除复合工料机
export function deleteResourceMerged(id: number) {
return requestClient.delete(`/core/resource/merged/delete?id=${id}`);
}

View File

@@ -0,0 +1,45 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MaterialsApi {
/** 工料机-根节点 */
export interface Root {
id?: number;
code: string;
name: string;
sortOrder: number;
taxExclBaseCode: string;
taxInclBaseCode: string;
taxExclCompileCode: string;
taxInclCompileCode: string;
attributes?: {
description?: string;
[key: string]: any;
};
createTime?: number;
updateTime?: number;
creator?: string;
updater?: string;
deleted?: number;
tenantId?: number;
}
}
//工料机类别字典(辅助数据)
export function getCategoriesList() {
return requestClient.get<MaterialsApi.Root[]>('/core/resource/categories');
}
//5.3 创建类别
export function createCategories(data: any) {
return requestClient.post<MaterialsApi.Root>(`/core/resource/categories`,data);
}
//5.4 更新类别
export function updateCategories(data: MaterialsApi.Root) {
return requestClient.put<MaterialsApi.Root>(`/core/resource/categories`,data);
}
//5.5 删除类别
export function deleteCategories(id: number) {
return requestClient.delete(`/core/resource/categories/${id}`);
}

View File

@@ -0,0 +1,22 @@
import { requestClient } from '#/api/request';
/** 3.8 查看分部分项工程费列表 */
export function getQuotaFeeItemListWithRate(id: any) {
return requestClient.get<any>(`/core/quota/fee/item/list-with-rate?catalogItemId=${id}`);
}
/** 获取工料机类别字典(含价格代码) */
export function getCategoriesByCatalogItem(catalogItemId: number | string) {
return requestClient.get<any>(`/core/quota/catalog-item/${catalogItemId}/categories`);
}
/** 更新取费项 */
export function updateQuotaFeeItem(data: any) {
return requestClient.put<any>('/core/quota/fee/item/update', data);
}
/** 创建取费项 */
export function createQuotaFeeItem(data: any) {
return requestClient.post<any>('/core/quota/fee/item/create', data);
}

View File

@@ -0,0 +1,537 @@
import { requestClient } from '#/api/request';
export namespace QuotaApi {
/** 定额目录条目属性 */
export interface CatalogItemAttributes {
node_type?: string; // 'specialty'
specialty_locked?: boolean;
remark?: string;
[key: string]: any;
}
/** 定额专业节点(第一层) */
export interface CatalogItem {
id: number;
code: string;
name: string;
parentId?: number | null; // 支持树形结构
sortOrder: number;
categoryTreeId?: number | null; // 绑定的工料机专业ID
attributes: CatalogItemAttributes;
children?: CatalogItem[]; // 子节点(树形结构)
}
/** 定额子目树节点属性 */
export interface CatalogTreeAttributes {
content_type?: string; // 'directory' | 'content'
remark?: string;
[key: string]: any;
}
/** 定额子目树节点(第二层) */
export interface CatalogTreeNode {
id: number | string; // 支持大整数,使用字符串
catalogItemId: number; // 关联第一层定额专业
code: string;
name: string;
parentId: number | string | null; // 支持大整数,使用字符串
sortOrder: number;
unit?: string;
attributes: CatalogTreeAttributes;
children?: CatalogTreeNode[];
}
/** 定额子目 */
export interface QuotaItem {
id: number;
catalogItemId: number;
unit: string;
basePrice: number;
attributes: any;
resources?: QuotaResource[];
createTime: number;
updateTime: number;
}
/** 定额工料机组成 */
export interface QuotaResource {
id: number;
quotaItemId: number;
resourceItemId: number;
resourceCode: string;
resourceName: string;
resourceUnit: string;
resourcePrice: number;
dosage: number;
attributes: any;
isMerged: any;
mergedItems: any;
}
/** 创建定额目录条目参数 */
export interface CreateCatalogItemParams {
code: string;
name: string;
parentId?: number | null;
sortOrder?: number;
unit?: string;
attributes?: CatalogItemAttributes;
}
/** 更新定额目录条目参数 */
export interface UpdateCatalogItemParams {
id: number;
code?: string;
name?: string;
sortOrder?: number;
unit?: string;
}
/** 绑定工料机专业参数 */
export interface BindSpecialtyParams {
catalogItemId: number | string; // 支持雪花ID字符串
categoryTreeId: number | string; // 支持雪花ID字符串
}
}
// ==================== 定额专业管理(第一层) ====================
/** 1.4 获取定额专业节点详情 */
export function getQuotaCatalogItem(id: number | string) {
return requestClient.get<QuotaApi.CatalogItem>(
`/core/quota/catalog-item/get?id=${id}`
);
}
/** 1.2 获取定额专业树结构 */
export function getQuotaCatalogItemTree(params?: { exclude?: string }) {
return requestClient.get<QuotaApi.CatalogItem[]>(
'/core/quota/catalog-item/tree',
{ params }
);
}
/** 1.5 获取定额专业节点列表 */
export function getQuotaCatalogItemList() {
return requestClient.get<QuotaApi.CatalogItem[]>(
'/core/quota/catalog-item/list'
);
}
/** 1.1 创建定额专业节点 */
export function createQuotaCatalogItem(data: QuotaApi.CreateCatalogItemParams) {
return requestClient.post('/core/quota/catalog-item/create', data);
}
/** 1.2 更新定额专业节点 */
export function updateQuotaCatalogItem(data: QuotaApi.UpdateCatalogItemParams) {
return requestClient.put('/core/quota/catalog-item/update', data);
}
/** 1.3 删除定额专业节点 */
export function deleteQuotaCatalogItem(id: number | string) {
return requestClient.delete(`/core/quota/catalog-item/delete?id=${id}`);
}
/** 1.6 绑定工料机专业 */
export function bindQuotaSpecialty(data: QuotaApi.BindSpecialtyParams) {
return requestClient.post('/core/quota/catalog-item/bind-specialty', data);
}
// ==================== 定额子目树管理(第二层) ====================
/** 2.4 获取定额子目树节点详情 */
export function getQuotaCatalogTreeNode(id: number | string) {
return requestClient.get<QuotaApi.CatalogTreeNode>(
`/core/quota/catalog-tree/get?id=${id}`
);
}
/** 2.5 获取定额子目树节点列表 */
export function getQuotaCatalogTreeList(catalogItemId: number | string) {
return requestClient.get<QuotaApi.CatalogTreeNode[]>(
'/core/quota/catalog-tree/list',
{ params: { catalogItemId } }
);
}
/** 2.6 获取定额子目树结构 */
export function getQuotaCatalogTree(catalogItemId: number | string) {
return requestClient.get<QuotaApi.CatalogTreeNode[]>(
'/core/quota/catalog-tree/tree',
{ params: { catalogItemId } }
);
}
/** 2.1 创建定额子目树节点 */
export function createQuotaCatalogTreeNode(data: {
catalogItemId: number;
parentId?: number | string | null; // 支持大整数,使用字符串
code: string;
name: string;
unit?: string;
contentType?: 'directory' | 'content';
sortOrder?: number;
attributes?: any;
}) {
return requestClient.post<number | string>('/core/quota/catalog-tree/create', data);
}
/** 2.2 更新定额子目树节点 */
export function updateQuotaCatalogTreeNode(data: {
id: number;
catalogItemId?: number;
parentId?: number | null;
code?: string;
name?: string;
unit?: string;
contentType?: 'directory' | 'content';
sortOrder?: number;
attributes?: any;
}) {
return requestClient.put('/core/quota/catalog-tree/update', data);
}
/** 2.3 删除定额子目树节点 */
export function deleteQuotaCatalogTreeNode(id: number | string) {
return requestClient.delete(`/core/quota/catalog-tree/delete?id=${id}`);
}
/** 2.7 交换两个节点的排序 */
export function swapQuotaCatalogTreeSort(nodeId1: number | string, nodeId2: number | string) {
return requestClient.put('/core/quota/catalog-tree/swap-sort', {
nodeId1,
nodeId2
});
}
/** 2.8 根据费率模式节点查询子目录树 */
export function getQuotaCatalogTreeByRateModeNode(rateModeNodeId: number | string) {
return requestClient.get<QuotaApi.CatalogTreeNode[]>(
'/core/quota/catalog-tree/list-by-rate-mode-node',
{ params: { rateModeNodeId } }
);
}
// ==================== 定额子目管理(第四层) ====================
/** 2.4 获取定额子目详情 */
export function getQuotaItem(id: number) {
return requestClient.get<QuotaApi.QuotaItem>(
`/core/quota/item/get?id=${id}`
);
}
/** 2.5 获取定额子目列表 */
export function getQuotaItemList(catalogItemId: number | string) {
return requestClient.get<QuotaApi.QuotaItem[]>(
'/core/quota/item/list',
{ params: { catalogItemId } }
);
}
/** 2.1 创建定额子目 */
export function createQuotaItem(data: any) {
return requestClient.post('/core/quota/item/create', data);
}
/** 2.2 更新定额子目 */
export function updateQuotaItem(data: any) {
return requestClient.put('/core/quota/item/update', data);
}
/** 2.3 删除定额子目 */
export function deleteQuotaItem(id: number) {
return requestClient.delete(`/core/quota/item/delete?id=${id}`);
}
/** 2.6 计算定额基价 */
export function calculateQuotaPrice(id: number) {
return requestClient.post(`/core/quota/item/calculate-price?id=${id}`);
}
// ==================== 定额工料机组成管理 ====================
/** 3.4 获取定额工料机组成列表 */
export function getQuotaResourceList(quotaItemId: number) {
return requestClient.get<QuotaApi.QuotaResource[]>(
'/core/quota/resource/list',
{ params: { quotaItemId } }
);
}
/** 3.5 获取可选的工料机列表(已过滤范围,支持模糊查询) */
export function getAvailableResourceList(quotaItemId: number, params?: { code?: string; name?: string; spec?: string }) {
return requestClient.get<any[]>(
'/core/quota/resource/available-list',
{ params: { quotaItemId, ...params } }
);
}
/** 3.1 添加定额工料机组成 */
export function createQuotaResource(data: any) {
return requestClient.post('/core/quota/resource/create', data);
}
/** 3.2 更新定额工料机组成 */
export function updateQuotaResource(data: any) {
return requestClient.put('/core/quota/resource/update', data);
}
/** 3.3 删除定额工料机组成 */
export function deleteQuotaResource(id: number) {
return requestClient.delete(`/core/quota/resource/delete?id=${id}`);
}
// ==================== 定额调整设置管理(第五层) ====================
/** 5.1 创建调整设置 */
export function createAdjustmentSetting(data: any) {
return requestClient.post('/core/quota/adjustment-setting/create', data);
}
/** 5.2 更新调整设置 */
export function updateAdjustmentSetting(data: any) {
return requestClient.put('/core/quota/adjustment-setting/update', data);
}
/** 5.3 删除调整设置 */
export function deleteAdjustmentSetting(id: number) {
return requestClient.delete(`/core/quota/adjustment-setting/delete?id=${id}`);
}
/** 5.4 获取调整设置详情 */
export function getAdjustmentSetting(id: number) {
return requestClient.get<any>(`/core/quota/adjustment-setting/get?id=${id}`);
}
/** 5.5 获取调整设置列表 */
export function getAdjustmentSettingList(quotaItemId: number) {
return requestClient.get<any[]>(
'/core/quota/adjustment-setting/list',
{ params: { quotaItemId } }
);
}
/** 5.6 交换排序 */
export function swapAdjustmentSettingSort(id1: number, id2: number) {
return requestClient.post('/core/quota/adjustment-setting/swap-sort', {
id1,
id2
});
}
// ==================== 定额调整明细管理(第六层) ====================
/** 6.1 创建调整明细 */
export function createAdjustmentDetail(data: any) {
return requestClient.post('/core/quota/adjustment-detail/create', data);
}
/** 6.2 更新调整明细 */
export function updateAdjustmentDetail(data: any) {
return requestClient.put('/core/quota/adjustment-detail/update', data);
}
/** 6.3 删除调整明细 */
export function deleteAdjustmentDetail(id: number) {
return requestClient.delete(`/core/quota/adjustment-detail/delete?id=${id}`);
}
/** 6.4 获取调整明细详情 */
export function getAdjustmentDetail(id: number) {
return requestClient.get<any>(`/core/quota/adjustment-detail/get?id=${id}`);
}
/** 6.5 获取调整明细列表 */
export function getAdjustmentDetailList(quotaItemId: number) {
return requestClient.get<any[]>(
'/core/quota/adjustment-detail/list',
{ params: { quotaItemId } }
);
}
/** 6.6 交换排序 */
export function swapAdjustmentDetailSort(id1: number, id2: number) {
return requestClient.post('/core/quota/adjustment-detail/swap-sort', {
id1,
id2
});
}
/** 6.8 获取调整设置与明细的组合列表 */
export function getAdjustmentCombinedList(quotaItemId: number) {
return requestClient.get<any[]>(
'/core/quota/adjustment-detail/combined-list',
{ params: { quotaItemId } }
);
}
// ==================== 定额费率管理 ====================
/** 7.1 创建费率模式节点 */
export function createRateModeNode(data: {
parentId: number | string;
code: string;
name: string;
sortOrder?: number;
}) {
return requestClient.post<number | string>(
'/core/quota/rate/item/create-mode-node',
data
);
}
/** 3.8 查看分部分项工程费列表 */
export function getQuotaRateItemTree(id: any) {
return requestClient.get<any>(`/core/quota/rate/item/tree?catalogItemId=${id}`);
}
/** 7.2 创建费率项 */
export function createRateItem(data: any) {
return requestClient.post('/core/quota/rate/item/create', data);
}
/** 7.3 更新费率项 */
export function updateRateItem(data: any) {
return requestClient.put('/core/quota/rate/item/update', data);
}
/** 7.4 删除费率项 */
export function deleteRateItem(id: number | string) {
return requestClient.delete(`/core/quota/rate/item/delete?id=${id}`);
}
/** 7.5 获取费率项详情 */
export function getRateItem(id: number | string) {
return requestClient.get<any>(`/core/quota/rate/item/get?id=${id}`);
}
/** 7.6 配置动态取值规则 */
export function configValueRules(data: {
rateItemId: number | string;
tiers?: Array<{
seq: number;
threshold: number;
compareType: string;
fieldValues: Record<string, number>;
}>;
increments?: Array<{
seq: number;
step: number;
baseThreshold: number;
fieldIncrements: Record<string, number>;
}>;
}) {
return requestClient.post('/core/quota/rate/item/config-value-rules', data);
}
/** 7.7 计算动态字段值 */
export function calculateDynamicFields(rateItemId: number | string, baseValue: number) {
return requestClient.get<Record<number, number>>(
'/core/quota/rate/item/calculate-fields',
{ params: { rateItemId, baseValue } }
);
}
/** 7.8 交换费率项排序 */
export function swapRateItemSort(nodeId1: number | string, nodeId2: number | string) {
return requestClient.post('/core/quota/rate/item/swap-sort', {
nodeId1,
nodeId2
});
}
/** 7.9 移动费率项到指定位置 */
export function moveRateItemNode(nodeId: number | string, targetNodeId: number | string) {
return requestClient.post('/core/quota/rate/item/move-node', {
nodeId1: nodeId,
nodeId2: targetNodeId
});
}
// ==================== 定额费率字段管理 ====================
/** 8.1 保存费率字段 */
export function saveRateField(data: {
id?: number | string;
catalogItemId: number | string;
rateItemId?: number | string;
fieldIndex: number;
fieldValue?: number;
fieldLabelId?: number | string;
bindingIds?: Array<number | string>;
}) {
return requestClient.post('/core/quota/rate/field/save', data);
}
/** 8.2 更新字段绑定 */
export function updateFieldBinding(
catalogItemId: number | string,
fieldIndex: number,
bindingIds: Array<number | string>
) {
return requestClient.post('/core/quota/rate/field/update-binding', bindingIds, {
params: { catalogItemId, fieldIndex }
});
}
/** 8.3 获取费率字段列表 */
export function getRateFieldList(catalogItemId: number | string) {
return requestClient.get<any[]>(
'/core/quota/rate/field/list',
{ params: { catalogItemId } }
);
}
// ==================== 字段标签字典管理 ====================
/** 9.1 创建字段标签 */
export function createFieldLabel(data: {
catalogItemId: number | string;
labelName: string;
}) {
return requestClient.post<number>('/core/quota/rate-field-label/create', data);
}
/** 9.2 更新字段标签 */
export function updateFieldLabel(data: {
id: number | string;
catalogItemId: number | string;
labelName: string;
}) {
return requestClient.put<boolean>('/core/quota/rate-field-label/update', data);
}
/** 9.3 删除字段标签 */
export function deleteFieldLabel(id: number | string) {
return requestClient.delete('/core/quota/rate-field-label/delete', {
params: { id }
});
}
/** 9.4 获取字段标签详情 */
export function getFieldLabel(id: number | string) {
return requestClient.get('/core/quota/rate-field-label/get', {
params: { id }
});
}
/** 9.5 获取字段标签列表 */
export function getFieldLabelList(catalogItemId: number | string) {
return requestClient.get<Array<{
id: number;
catalogItemId: number;
labelName: string;
sortOrder: number;
}>>('/core/quota/rate-field-label/list', {
params: { catalogItemId }
});
}
/** 9.6 交换字段标签排序 */
export function swapFieldLabelSort(labelId1: number | string, labelId2: number | string) {
return requestClient.post('/core/quota/rate-field-label/swap-sort', null, {
params: { labelId1, labelId2 }
});
}

View File

@@ -0,0 +1 @@
export { default as DbCalc } from './index.vue';

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { ElButton, ElDialog, ElInput } from 'element-plus'
import Handsontable from 'handsontable'
import 'handsontable/dist/handsontable.full.css'
import { computed, nextTick, ref, watch } from 'vue'
interface TableColumn {
prop: string
label: string
}
interface Props {
modelValue: boolean
currentValue?: string
tableData?: any[]
tableColumn?: TableColumn[]
tableProp?: string // 指定点击单元格时获取哪个字段的值
mode?: 'resource' | 'fee' // 模式resource=工料机返回类别IDfee=定额取费(返回对象)
}
interface ConfirmResult {
formula: string
variables: Record<string, number | { categoryId: number; priceField: string }>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', result: ConfirmResult): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
currentValue: '',
tableData: () => [],
tableColumn: () => [],
tableProp: 'code', // 默认使用 code 字段
mode: 'resource' // 默认为工料机模式
})
const emit = defineEmits<Emits>()
// Handsontable 相关
const tableContainer = ref<HTMLElement | null>(null)
let hotInstance: Handsontable | null = null
// 计算基数弹窗数据 - 使用 computed 从 props 获取
const calcTableData = computed(() => props.tableData || [])
// 运算符号按钮
const operators = ['+', '-', '*', '/', '(', ')']
// 当前编辑的值
const editValue = ref<string>('')
// 监听 props.currentValue 变化
watch(() => props.currentValue, (newVal) => {
editValue.value = newVal || ''
}, { immediate: true })
// 监听弹窗关闭,清空编辑值
watch(() => props.modelValue, (newVal) => {
if (!newVal) {
editValue.value = ''
if (hotInstance) {
hotInstance.destroy()
hotInstance = null
}
} else {
editValue.value = props.currentValue || ''
// 弹窗打开时初始化表格
nextTick(() => {
initHandsontable()
})
}
})
// 初始化 Handsontable
const initHandsontable = () => {
if (!tableContainer.value || hotInstance) return
// 转换列配置
const columns = props.tableColumn.map(col => ({
data: col.prop,
title: col.label,
readOnly: true
}))
console.log('=== Handsontable 初始化 ===')
console.log('表格数据:', calcTableData.value)
console.log('列配置:', columns)
console.log('原始列配置:', props.tableColumn)
hotInstance = new Handsontable(tableContainer.value, {
data: calcTableData.value,
columns: columns,
colHeaders: true,
rowHeaders: false,
height: 300,
licenseKey: 'non-commercial-and-evaluation',
readOnly: true,
afterOnCellMouseDown: (_event: MouseEvent, coords: { row: number; col: number }) => {
if (coords.row >= 0 && coords.col >= 0) {
const rowData = hotInstance?.getSourceDataAtRow(coords.row) as any
const colProp = props.tableColumn[coords.col]?.prop
// 跳过序号列和类别列(不允许点击)
if (colProp === 'id' || colProp === 'name') {
return
}
// 使用 tableProp 指定的字段获取值
const targetProp = props.tableProp
const value = rowData?.[targetProp]
if (value !== undefined && value !== null) {
const cellValue = Array.isArray(value) ? value.join('') : String(value)
editValue.value += cellValue
}
}
}
})
}
// 添加运算符
const addOperator = (op: string) => {
editValue.value += op
}
// 关闭弹窗
const handleClose = () => {
emit('update:modelValue', false)
}
// 提取公式中使用的变量,构建 variables 对象
const extractVariables = (formula: string): Record<string, number | { categoryId: number; priceField: string }> => {
const variables: Record<string, number | { categoryId: number; priceField: string }> = {}
if (!formula || !props.tableData || props.tableData.length === 0) {
return variables
}
// 创建正则表达式,将运算符和数字替换为空格,用于分割
const operatorPattern = operators.map(op => {
// 转义特殊字符
return op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}).join('|')
// 移除运算符、数字、小数点和空格,剩下的就是变量代码
const cleanFormula = formula.replace(new RegExp(`(${operatorPattern}|\\d+\\.?\\d*|\\s+)`, 'g'), ' ')
// 分割出所有可能的变量代码
const potentialCodes = cleanFormula.split(/\s+/).filter(code => code.trim() !== '')
// 价格字段类型映射
const priceFieldMap: Record<string, string> = {
'taxExclBaseCode': 'tax_excl_base_price',
'taxInclBaseCode': 'tax_incl_base_price',
'taxExclCompileCode': 'tax_excl_compile_price',
'taxInclCompileCode': 'tax_incl_compile_price'
}
// 遍历 tableData检查公式中是否包含该项的所有价格代码
props.tableData.forEach((item: any) => {
// 检查所有可能的价格代码字段
const priceCodeFields = ['taxExclBaseCode', 'taxInclBaseCode', 'taxExclCompileCode', 'taxInclCompileCode', 'code']
priceCodeFields.forEach(field => {
const codeValue = item[field]
if (codeValue && potentialCodes.includes(codeValue)) {
if (item.id !== undefined) {
// 根据模式返回不同格式
if (props.mode === 'resource') {
// 工料机模式直接返回类别ID
variables[codeValue] = item.id
} else {
// 定额取费模式:返回对象 { categoryId, priceField }
const priceFieldType = priceFieldMap[field] || 'tax_excl_base_price'
variables[codeValue] = {
categoryId: item.id,
priceField: priceFieldType
}
}
}
}
})
})
return variables
}
// 保存计算数据
const handleConfirm = () => {
const variables = extractVariables(editValue.value)
const result: ConfirmResult = {
formula: editValue.value,
variables: variables
}
// 只触发 confirm 事件,不自动关闭弹窗
// 让父组件在保存成功后再关闭弹窗
emit('confirm', result)
// 移除自动关闭逻辑
// emit('update:modelValue', false)
}
</script>
<template>
<ElDialog
:model-value="modelValue"
@update:model-value="emit('update:modelValue', $event)"
title="计算基数设置"
width="800px"
:close-on-click-modal="false"
>
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 10px; color: #909399; font-size: 13px;">
限数字:0-9及小数点运算符号:+-*/()英文括号代码
</div>
<ElInput
size="large"
v-model="editValue"
style="width: 100%;"
placeholder="点击表格单元格或运算符按钮添加内容"
clearable
/>
</div>
<div style="margin-bottom: 15px;">
<span style="margin-right: 10px;">运算符号:</span>
<ElButton
v-for="op in operators"
:key="op"
size="large"
@click="addOperator(op)"
style="margin-right: 5px; font-size: 20px;"
>
{{ op }}
</ElButton>
</div>
<div ref="tableContainer" style="width: 100%; margin-bottom: 15px;"></div>
<template #footer>
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确定</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,75 @@
# 右键菜单组件使用说明
## 功能特性
### 全局菜单管理
- 当打开一个右键菜单时,会自动关闭其他已打开的菜单
- 支持多个菜单组件实例共存,互不干扰
- 自动清理资源,防止内存泄漏
- **Handsontable 内置右键菜单与自定义菜单互斥**
## 组件集成
### 1. db-hst 组件Handsontable
- 使用自定义右键菜单组件 `contextmenu.vue`(空白区域右键)
- 使用 Handsontable 内置 `contextMenu`(单元格右键)
- 两种菜单互斥,打开一个会自动关闭另一个
### 2. db-tree 组件Element Plus Tree
使用 Element Plus Dropdown 实现的右键菜单
## 工作原理
### contextMenuManager全局菜单管理器
- 维护所有活动菜单的关闭回调函数
- 当新菜单打开时,通知管理器关闭其他菜单
- 组件卸载时自动取消注册
### 使用流程
1. 组件挂载时,向管理器注册关闭回调
2. 打开菜单前,调用 `contextMenuManager.notifyOpening()`
3. 管理器关闭其他菜单,保留当前菜单
4. 组件卸载时,自动取消注册
### db-hst 组件特殊处理
-`afterOnCellContextMenu` 钩子中关闭自定义菜单
- 确保 Handsontable 内置菜单打开时,自定义菜单被关闭
## 示例代码
### 在组件中使用
```typescript
// 在组件中使用
import { contextMenuManager } from './contextMenuManager'
// 注册菜单
const unregister = contextMenuManager.register(hideContextMenu)
// 打开菜单时
const showMenu = () => {
contextMenuManager.notifyOpening(hideContextMenu)
// 显示菜单逻辑
}
// 组件卸载时
onUnmounted(() => {
unregister()
})
```
### Handsontable 配置
```typescript
const settings = {
// ... 其他配置
afterOnCellContextMenu: () => {
// 关闭自定义菜单
contextMenuRef.value?.hideContextMenu?.()
}
}
```
## 注意事项
- 确保每个菜单组件都正确注册和取消注册
- 打开菜单前必须调用 `notifyOpening()`
- 关闭回调函数应该是幂等的(可以多次调用)
- Handsontable 的 `contextMenu` 配置与自定义菜单会自动互斥

View File

@@ -0,0 +1,41 @@
import {ref } from 'vue'
// 边框样式配置
export const borderStyle = {
width: 2,
color: '#0000ff',
style: 'solid'
}
// 构建当前选中行的边框配置
export const selectCellBorderStyle = (_this:any, row: any) => {
const hot = _this as any
// 构建当前选中行的边框配置
if (row < 0) return
const colCount = hot.countCols()
if (colCount <= 0) return
const customBorders = hot.getPlugin('customBorders')
customBorders.clearBorders()
customBorders.setBorders([[row, 0, row, colCount - 1]], { top: borderStyle, bottom: borderStyle })
customBorders.setBorders([[row, 0, row, 0]], { start: borderStyle })
customBorders.setBorders([[row, colCount - 1, row, colCount - 1]], { end: borderStyle })
}
// 构建当前选中行的背景色配置
export const selectedVisualRowRange = ref<{ from: number; to: number } | null>(null)
export const setSelectedVisualRows = (row: number, row2: number) => {
if (row < 0 || row2 < 0) {
selectedVisualRowRange.value = null
return
}
const from = Math.min(row, row2)
const to = Math.max(row, row2)
selectedVisualRowRange.value = { from, to }
}
export const isVisualRowSelected = (visualRowIndex: number) => {
const range = selectedVisualRowRange.value
if (!range) return false
return visualRowIndex >= range.from && visualRowIndex <= range.to
}

View File

@@ -0,0 +1,56 @@
/**
* 全局右键菜单管理器
* 用于管理多个右键菜单实例,确保同一时间只有一个菜单处于打开状态
*/
type MenuCloseCallback = () => void
class ContextMenuManager {
private activeMenus: Set<MenuCloseCallback> = new Set()
/**
* 注册一个菜单实例
* @param closeCallback 关闭菜单的回调函数
* @returns 取消注册的函数
*/
register(closeCallback: MenuCloseCallback): () => void {
this.activeMenus.add(closeCallback)
// 返回取消注册的函数
return () => {
this.activeMenus.delete(closeCallback)
}
}
/**
* 关闭所有已注册的菜单
*/
closeAll(): void {
this.activeMenus.forEach(callback => {
try {
callback()
} catch (error) {
console.error('关闭菜单时出错:', error)
}
})
}
/**
* 通知即将打开新菜单,关闭其他所有菜单
* @param currentCloseCallback 当前菜单的关闭回调(不会被关闭)
*/
notifyOpening(currentCloseCallback: MenuCloseCallback): void {
this.activeMenus.forEach(callback => {
if (callback !== currentCloseCallback) {
try {
callback()
} catch (error) {
console.error('关闭其他菜单时出错:', error)
}
}
})
}
}
// 导出单例实例
export const contextMenuManager = new ContextMenuManager()

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, computed } from 'vue'
import { contextMenuManager } from './contextMenuManager'
// 接收父组件传递的 props
const props = defineProps<{
hotTableComponent?: any
menuItems?: Array<{
key: string
name: string
callback?: (hotInstance: any) => void
separator?: boolean
}>
}>()
// 自定义右键菜单状态
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
row: -1, // 记录右键点击的行索引
col: -1 // 记录右键点击的列索引
})
// 默认菜单项
const defaultMenuItems = [
{
key: 'addRow',
name: '新增一行',
callback: (hotInstance: any) => {
const rowCount = hotInstance.countRows()
hotInstance.alter('insert_row_below', rowCount - 1, 1)
}
}
]
// 合并默认菜单和自定义菜单
const menuItems = computed(() => {
return props.menuItems && props.menuItems.length > 0
? props.menuItems
: defaultMenuItems
})
// 获取 hotInstance
const getHotInstance = () => {
return props.hotTableComponent?.hotInstance
}
// 处理右键菜单
const handleContextMenu = (event: MouseEvent) => {
// 如果没有菜单项,不处理右键菜单
if (!props.menuItems || props.menuItems.length === 0) {
return
}
const target = event.target as HTMLElement
// 判断是否点击在空白区域(非单元格区域)
// 检查是否点击在 td 或 th 元素上
const isCell = target.closest('td') || target.closest('th')
if (!isCell) {
// 点击在空白区域,显示自定义菜单
event.preventDefault()
// 菜单尺寸(需要与 CSS 中的实际尺寸匹配)
const menuWidth = 180
const menuItemHeight = 32 // 单个菜单项高度padding + font-size
const menuPadding = 8 // 菜单上下 padding
const menuHeight = menuItems.value.length * menuItemHeight + menuPadding
// 获取容器元素Handsontable 的容器)
const container = target.closest('.handsontable') as HTMLElement || target.closest('[class*="hot-"]') as HTMLElement
// 如果找到容器,使用容器边界;否则使用视口边界
let viewportWidth = window.innerWidth
let viewportHeight = window.innerHeight
let offsetX = 0
let offsetY = 0
if (container) {
const rect = container.getBoundingClientRect()
viewportWidth = rect.right
viewportHeight = rect.bottom
offsetX = rect.left
offsetY = rect.top
}
// 计算菜单位置,确保不超出边界
let x = event.clientX
let y = event.clientY
// 如果右侧空间不足,向左显示
if (x + menuWidth > viewportWidth) {
x = Math.max(offsetX + 10, viewportWidth - menuWidth - 10)
}
// 如果底部空间不足,向上显示
if (y + menuHeight > viewportHeight) {
y = Math.max(offsetY + 10, viewportHeight - menuHeight - 10)
}
// 确保不会超出左边界和上边界
x = Math.max(offsetX + 10, x)
y = Math.max(offsetY + 10, y)
// 使用统一的显示方法
showContextMenu(x, y)
}
}
// 隐藏右键菜单
const hideContextMenu = () => {
contextMenu.visible = false
}
// 显示右键菜单(在打开前先关闭其他菜单)
const showContextMenu = (x: number, y: number) => {
// 通知管理器即将打开新菜单,关闭其他菜单
contextMenuManager.notifyOpening(hideContextMenu)
contextMenu.visible = true
contextMenu.x = x
contextMenu.y = y
}
// 菜单项点击处理
const handleMenuAction = (item: any) => {
console.log('菜单操作:', item.key)
const hotInstance = getHotInstance()
if (!hotInstance) {
console.warn('hotInstance 未初始化')
hideContextMenu()
return
}
// 执行自定义回调
if (item.callback && typeof item.callback === 'function') {
item.callback(hotInstance)
}
hideContextMenu()
}
// 暴露方法给父组件
defineExpose({
handleContextMenu,
hideContextMenu
})
// 取消注册函数
let unregister: (() => void) | null = null
onMounted(() => {
// 注册到全局菜单管理器
unregister = contextMenuManager.register(hideContextMenu)
// 监听全局点击事件,隐藏菜单
document.addEventListener('click', hideContextMenu)
})
onUnmounted(() => {
// 取消注册
if (unregister) {
unregister()
}
document.removeEventListener('click', hideContextMenu)
})
</script>
<template>
<!-- 自定义右键菜单 -->
<div
v-if="contextMenu.visible"
class="custom-context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop
>
<template v-for="(item, index) in menuItems" :key="item.key || index">
<!-- 分隔线 -->
<div v-if="'separator' in item && item.separator" class="menu-separator"></div>
<!-- 菜单项 -->
<div v-else class="menu-item" @click="handleMenuAction(item)">
<span>{{ item.name }}</span>
</div>
</template>
</div>
</template>
<style lang="css">
/* 自定义右键菜单样式 */
.custom-context-menu {
position: fixed;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10000;
min-width: 180px;
padding: 4px 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Helvetica Neue, Arial, sans-serif;
}
.menu-item {
display: flex;
align-items: center;
padding: 4px 12px;
cursor: pointer;
transition: background-color 0.2s;
user-select: none;
}
.menu-item span{
font-size: 14px;
margin-inline: calc(2* 4px);
}
.menu-item:hover {
background-color: #f5f5f5;
}
.menu-separator {
height: 1px;
background-color: #e8e8e8;
margin: 4px 0;
}
</style>

View File

@@ -13,22 +13,38 @@ const closeDropdown = () => {
const openDropdown = ( const openDropdown = (
td: HTMLTableCellElement, td: HTMLTableCellElement,
value: unknown, value: unknown,
source: string[], source: any[],
onSelect: (opt: string) => void, onSelect: (opt: string) => void,
isOptionDisabled?: (opt: string) => boolean, isOptionDisabled?: (opt: string) => boolean,
onAfterSelect?: (oldValue: unknown, newValue: string, optData?: any) => void,
) => { ) => {
closeDropdown() closeDropdown()
const menu = document.createElement('div') const menu = document.createElement('div')
menu.className = 'ht-dropdown-menu' menu.className = 'ht-dropdown-menu'
const frag = document.createDocumentFragment() const frag = document.createDocumentFragment()
for (const opt of source) { for (const opt of source) {
// 支持对象格式 {value, label} 和字符串格式
const optValue = typeof opt === 'object' && opt !== null ? opt.value : opt
const optLabel = typeof opt === 'object' && opt !== null ? opt.label : opt
const item = document.createElement('div') const item = document.createElement('div')
item.className = 'ht-dropdown-item' item.className = 'ht-dropdown-item'
item.textContent = opt item.textContent = optLabel ?? ''
if (String(value) === String(opt)) item.classList.add('is-selected') if (String(value) === String(optValue)) item.classList.add('is-selected')
const disabled = isDisabled.value && isOptionDisabled?.(opt) === true const disabled = isDisabled.value && isOptionDisabled?.(String(optValue)) === true
if (disabled) { item.classList.add('is-disabled'); item.setAttribute('aria-disabled', 'true') } if (disabled) { item.classList.add('is-disabled'); item.setAttribute('aria-disabled', 'true') }
item.onclick = (e) => { e.stopPropagation(); if (disabled) return; onSelect(opt); closeDropdown() } item.onclick = (e) => {
e.stopPropagation();
if (disabled) return;
onSelect(String(optValue));
// 调用选择后的回调,传递旧值、新值和完整数据
if (onAfterSelect) {
const optData = typeof opt === 'object' && opt !== null ? opt.data : undefined
onAfterSelect(value, String(optValue), optData)
}
closeDropdown()
}
frag.appendChild(item) frag.appendChild(item)
} }
menu.appendChild(frag) menu.appendChild(frag)
@@ -51,23 +67,71 @@ const openDropdown = (
currentOnDocClick = (ev: MouseEvent) => { const target = ev.target as Node; if (currentDropdownEl && !currentDropdownEl.contains(target)) closeDropdown() } currentOnDocClick = (ev: MouseEvent) => { const target = ev.target as Node; if (currentDropdownEl && !currentDropdownEl.contains(target)) closeDropdown() }
document.addEventListener('click', currentOnDocClick, true) document.addEventListener('click', currentOnDocClick, true)
} }
export function handlerDropdownRenderer(instance: any, td: HTMLTableCellElement, row: number, column: number, prop: string | number, value: any, cellProperties: any) { export function handlerDropdownRenderer(instance: any, td: HTMLTableCellElement, row: number, column: number, prop: string | number, value: any, cellProperties: any) {
td.innerHTML = '' td.innerHTML = ''
// 检查是否需要设置验证背景色
const cellMeta = instance.getCellMeta(row, column)
const isValid = cellMeta?.valid !== false
// 如果单元格被标记为无效,设置红色背景
if (!isValid) {
td.style.backgroundColor = '#ffbeba' // 淡红色背景
} else {
td.style.backgroundColor = '' // 清除背景色
}
const wrapper = document.createElement('div') const wrapper = document.createElement('div')
wrapper.className = 'ht-cell-dropdown' wrapper.className = 'ht-cell-dropdown'
const valueEl = document.createElement('span') const valueEl = document.createElement('span')
valueEl.className = 'ht-cell-value' valueEl.className = 'ht-cell-value'
valueEl.textContent = value ?? ''
// 基于列配置启用"唯一选择"禁用逻辑
isDisabled.value = Boolean(cellProperties?.isOnlySelect)
//TODO 暂时性,后面要删除
// 如果 isOnlySelect 为 true设置当前单元格为只读
const isReadOnly = isDisabled.value && value !== null && value !== undefined && String(value).trim() !== ''
if (isReadOnly) {
instance.setCellMeta(row, column, 'readOnly', true)
// 添加只读样式
wrapper.classList.add('is-readonly')
td.style.cursor = 'not-allowed'
td.style.opacity = '0.6'
} else {
instance.setCellMeta(row, column, 'readOnly', false)
td.style.cursor = 'pointer'
td.style.opacity = '1'
}
const source: any[] = Array.isArray(cellProperties?.source)
? cellProperties.source
: Array.isArray(cellProperties?.customDropdownSource)
? cellProperties.customDropdownSource
: []
// 根据 value 查找对应的 label 显示
let displayText = value ?? ''
if (value !== null && value !== undefined) {
const matchedOption = source.find(opt => {
const optValue = typeof opt === 'object' && opt !== null ? opt.value : opt
return String(optValue) === String(value)
})
if (matchedOption) {
displayText = typeof matchedOption === 'object' && matchedOption !== null
? matchedOption.label
: matchedOption
}
}
valueEl.textContent = displayText
const caretEl = document.createElement('span') const caretEl = document.createElement('span')
caretEl.className = 'ht-cell-caret' caretEl.className = 'ht-cell-caret'
wrapper.appendChild(valueEl) wrapper.appendChild(valueEl)
wrapper.appendChild(caretEl) wrapper.appendChild(caretEl)
td.appendChild(wrapper) td.appendChild(wrapper)
const source: string[] = Array.isArray(cellProperties?.source)
? cellProperties.source
: Array.isArray(cellProperties?.customDropdownSource)
? cellProperties.customDropdownSource
: []
let disabledSet = new Set<string>() let disabledSet = new Set<string>()
if (isDisabled.value) { if (isDisabled.value) {
const colValues = instance.getSourceDataAtCol(column) as unknown[] const colValues = instance.getSourceDataAtCol(column) as unknown[]
@@ -75,6 +139,32 @@ export function handlerDropdownRenderer(instance: any, td: HTMLTableCellElement,
disabledSet = new Set((Array.isArray(colValues) ? colValues : []).map(v => String(v))) disabledSet = new Set((Array.isArray(colValues) ? colValues : []).map(v => String(v)))
disabledSet.delete(currentStr) disabledSet.delete(currentStr)
} }
wrapper.onclick = (e) => { e.stopPropagation(); openDropdown(td, value, source, (opt) => instance.setDataAtCell(row, column, opt), (opt) => isDisabled.value && disabledSet.has(String(opt))) }
//在 dropdown 的点击事件中阻止冒泡(推荐)
wrapper.onclick = (e) => {
e.stopPropagation()
e.preventDefault()
//TODO 暂时性,后面要删除
// 如果是只读状态,不打开下拉框
if (isReadOnly) {
return
}
const onAfterSelect = cellProperties?.onAfterSelect
openDropdown(
td,
value,
source,
(opt) => instance.setDataAtCell(row, column, opt),
(opt) => isDisabled.value && disabledSet.has(String(opt)),
onAfterSelect ? (oldValue, newValue, optData) => onAfterSelect(instance, row, column, oldValue, newValue, optData) : undefined
)
}
// 阻止 mousedown 事件冒泡,防止触发 beforeOnCellMouseDown
wrapper.onmousedown = (e) => {
e.stopPropagation()
}
return td return td
} }

View File

@@ -6,16 +6,28 @@ import { HotTable } from '@handsontable/vue3'
import { registerLanguageDictionary, zhCN } from 'handsontable/i18n' import { registerLanguageDictionary, zhCN } from 'handsontable/i18n'
import { registerAllModules } from 'handsontable/registry' import { registerAllModules } from 'handsontable/registry'
import 'handsontable/styles/handsontable.css' import 'handsontable/styles/handsontable.css'
import 'handsontable/styles/ht-theme-main.css' // import 'handsontable/styles/ht-theme-main.css'
import 'handsontable/styles/ht-theme-classic.css';
registerAllModules() registerAllModules()
registerLanguageDictionary(zhCN) registerLanguageDictionary(zhCN)
import { handlerDropdownRenderer } from './dropdown' import { handlerDropdownRenderer } from './dropdown'
import { handlerTableRenderer } from './table' import { handlerTableRenderer } from './table'
import { handlerDuplicateCodeRenderer } from './text'
import { computeCodeColWidth,codeRenderer } from './tree' import { computeCodeColWidth,codeRenderer } from './tree'
import ContextMenu from './contextmenu.vue'
import { handleRowOperation } from '#/components/db-hst/tree'
// import { sourceDataObject } from './mockData' // import { sourceDataObject } from './mockData'
// const language = ref('zh-CN') // const language = ref('zh-CN')
defineOptions({ name: 'DbHst' }); defineOptions({ name: 'DbHst' });
const componentProps = defineProps<{ settings?: any }>() const componentProps = defineProps<{
settings?: any
contextMenuItems?: Array<{
key: string
name: string
callback?: (hotInstance: any) => void
separator?: boolean
}>
}>()
// 导入和注册插件和单元格类型 // 导入和注册插件和单元格类型
// import { registerCellType, NumericCellType } from 'handsontable/cellTypes'; // import { registerCellType, NumericCellType } from 'handsontable/cellTypes';
// import { registerPlugin, UndoRedo } from 'handsontable/plugins'; // import { registerPlugin, UndoRedo } from 'handsontable/plugins';
@@ -24,13 +36,15 @@ const componentProps = defineProps<{ settings?: any }>()
// const tableHeight = computed(() => componentProps.height ?? 0) // const tableHeight = computed(() => componentProps.height ?? 0)
// const defaultData = ref<any[][]>(Array.from({ length: 30 }, () => Array(componentProps.columns?.length ?? 0).fill(''))) // const defaultData = ref<any[][]>(Array.from({ length: 30 }, () => Array(componentProps.columns?.length ?? 0).fill('')))
const hotTableComponent = ref<any>(null) const hotTableComponent = ref<any>(null)
const selectedRow = ref<number | null>(null) // 记录当前选中的行
const codeColWidth = ref<number>(120) const codeColWidth = ref<number>(120)
// const colHeaders = ref<string[]>([]) // const colHeaders = ref<string[]>([])
let defaultSettings = { let defaultSettings = {
themeName: 'ht-theme-main', // themeName: 'ht-theme-main',
themeName: 'ht-theme-classic',
language: 'zh-CN', language: 'zh-CN',
// data: sourceDataObject, // data: sourceDataObject,
// colWidths: [100, 120, 100, 100, 100, 100], // colWidths: [100, 120, 100, 100, 100, 100],
@@ -40,7 +54,7 @@ let defaultSettings = {
// return (index + 1) * 40; // return (index + 1) * 40;
// }, // },
// colWidths: undefined, // colWidths: undefined,
rowHeights: '23px', // 固定行高 rowHeights: 23, // 固定行高
wordWrap: false,// 禁止单元格内容自动换行 wordWrap: false,// 禁止单元格内容自动换行
//manualColumnMove: true, //manualColumnMove: true,
@@ -90,25 +104,69 @@ let defaultSettings = {
// return cellProperties; // return cellProperties;
// }, // },
modifyColWidth: (width: number, col: number) => { // afterSelection(row1: number, _col1: number, _row2: number, _col2: number) {
const hot = hotInstance.value // const hot = this as any
if (!hot) return width // if (selectedRow.value !== null && selectedRow.value !== row1) {
const codeCol = hot.propToCol('code') // const colCount = hot.countCols()
// console.log('modifyColWidth',codeCol,width) // for (let c = 0; c < colCount; c++) {
return col === codeCol ? (codeColWidth.value ?? width) : width // const meta = hot.getCellMeta(selectedRow.value, c)
}, // const classes = (meta.className || '').split(' ').filter(Boolean)
afterChange: (changes: any, source: string) => { // const idx = classes.indexOf('row-highlight')
if (!changes || !hotInstance.value) return // if (idx !== -1) classes.splice(idx, 1)
if (source !== 'edit' && source !== 'Autofill' && source !== 'UndoRedo') return // hot.setCellMeta(selectedRow.value, c, 'className', classes.join(' '))
const hot = hotInstance.value // }
const codeCol = hot.propToCol('code') // }
const hasCodeEdit = changes.some((c: any) => c && (c[1] === 'code' || c[1] === codeCol))
// console.log('afterChange',changes,hasCodeEdit, codeCol) // selectedRow.value = row1
if (!hasCodeEdit) return
codeColWidth.value = computeCodeColWidth(hot) // const colCount = hot.countCols()
// console.log('afterChange',codeColWidth.value) // for (let c = 0; c < colCount; c++) {
hot.render() // const meta = hot.getCellMeta(row1, c)
// console.log('afterChange',codeColWidth.value) // const classes = (meta.className || '').split(' ').filter(Boolean)
// if (!classes.includes('row-highlight')) classes.push('row-highlight')
// hot.setCellMeta(row1, c, 'className', classes.join(' '))
// }
// hot.render()
// },
//afterDeselect() {
// const hot = this as any
// if (selectedRow.value === null) return
// const colCount = hot.countCols()
// for (let c = 0; c < colCount; c++) {
// const meta = hot.getCellMeta(selectedRow.value, c)
// const classes = (meta.className || '').split(' ').filter(Boolean)
// const idx = classes.indexOf('row-highlight')
// if (idx !== -1) classes.splice(idx, 1)
// hot.setCellMeta(selectedRow.value, c, 'className', classes.join(' '))
// }
// selectedRow.value = null
// hot.render()
//},
// modifyColWidth(width: number, col: number) {
// const hot = hotInstance.value
// if (!hot) return width
// const codeCol = hot.propToCol('code')
// // console.log('modifyColWidth',codeCol,width)
// return col === codeCol ? (codeColWidth.value ?? width) : width
// },
// afterChange(changes: any, source: string) {
// if (!changes || !hotInstance.value) return
// if (source !== 'edit' && source !== 'Autofill' && source !== 'UndoRedo') return
// const hot = hotInstance.value
// const codeCol = hot.propToCol('code')
// const hasCodeEdit = changes.some((c: any) => c && (c[1] === 'code' || c[1] === codeCol))
// // console.log('afterChange',changes,hasCodeEdit, codeCol)
// if (!hasCodeEdit) return
// codeColWidth.value = computeCodeColWidth(hot)
// // console.log('afterChange',codeColWidth.value)
// hot.render()
// // console.log('afterChange',codeColWidth.value)
// },
afterOnCellContextMenu: () => {
// Handsontable 内置右键菜单打开后,关闭自定义菜单
contextMenuRef.value?.hideContextMenu?.()
}, },
} }
// 合并外部 settings 和默认配置 // 合并外部 settings 和默认配置
@@ -116,6 +174,12 @@ let hotSettings = {}
// 保留必要的回调函数 // 保留必要的回调函数
const hotInstance = ref<any>(null) const hotInstance = ref<any>(null)
const contextMenuRef = ref<any>(null)
// 处理右键菜单事件
const handleContextMenu = (event: MouseEvent) => {
contextMenuRef.value?.handleContextMenu(event)
}
onMounted(() => { onMounted(() => {
hotInstance.value = hotTableComponent.value?.hotInstance hotInstance.value = hotTableComponent.value?.hotInstance
@@ -127,15 +191,17 @@ watch(
() => componentProps.settings, () => componentProps.settings,
(newSettings) => { (newSettings) => {
if (!newSettings) return if (!newSettings) return
const merged = { const merged = {
...defaultSettings, ...defaultSettings,
...newSettings, ...newSettings,
} }
Object.assign(hotSettings, merged) Object.assign(hotSettings, merged)
hotSettings = merged hotSettings = merged
// console.log(merged) // console.log(merged)
}, },
{ immediate: true } { immediate: true,deep:true }
) )
const loadData = (rows: any[][]) => { const loadData = (rows: any[][]) => {
@@ -143,12 +209,51 @@ const loadData = (rows: any[][]) => {
if (!hotInstance.value) return if (!hotInstance.value) return
// hotInstance.value.loadData(rows.length === 0?defaultData.value:rows) // hotInstance.value.loadData(rows.length === 0?defaultData.value:rows)
hotInstance.value.loadData(rows) hotInstance.value.loadData(rows)
console.log('Source Data:', hotInstance.value.getSourceData()); //console.log('Source Data:', hotInstance.value.getSourceData());
} }
const updateCodeColWidth = () => { const updateCodeColWidth = () => {
if (!hotInstance.value) return if (!hotInstance.value) return
codeColWidth.value = computeCodeColWidth(hotInstance.value) const newWidth = computeCodeColWidth(hotInstance.value)
// 如果宽度没有变化,不需要更新
if (newWidth === codeColWidth.value) return
codeColWidth.value = newWidth
// 查找配置了 code: true 的列
const currentSettings = hotInstance.value.getSettings()
const columns = currentSettings.columns || []
const codeColIndex = columns.findIndex((col: any) => col.code === true)
if (codeColIndex !== null && codeColIndex >= 0) {
// 获取当前列数
const colCount = hotInstance.value.countCols()
const currentColWidths = currentSettings.colWidths
// 构建新的列宽数组
const newColWidths: number[] = []
for (let i = 0; i < colCount; i++) {
if (i === codeColIndex) {
newColWidths[i] = codeColWidth.value
} else {
// 获取其他列的当前宽度
if (Array.isArray(currentColWidths)) {
newColWidths[i] = currentColWidths[i] || 100
} else if (typeof currentColWidths === 'function') {
newColWidths[i] = 100
} else {
newColWidths[i] = currentColWidths || 100
}
}
}
console.log(newColWidths)
// 更新列宽
hotInstance.value.updateSettings({
colWidths: newColWidths
})
}
hotInstance.value.render() hotInstance.value.render()
} }
defineExpose({ loadData, hotTableComponent, hotInstance, updateCodeColWidth, codeColWidth }) defineExpose({ loadData, hotTableComponent, hotInstance, updateCodeColWidth, codeColWidth })
@@ -156,11 +261,15 @@ defineExpose({ loadData, hotTableComponent, hotInstance, updateCodeColWidth, cod
Handsontable.renderers.registerRenderer("db-table", handlerTableRenderer); Handsontable.renderers.registerRenderer("db-table", handlerTableRenderer);
Handsontable.renderers.registerRenderer("db-dropdown", handlerDropdownRenderer); Handsontable.renderers.registerRenderer("db-dropdown", handlerDropdownRenderer);
Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRenderer);
</script> </script>
<template> <template>
<hot-table ref="hotTableComponent" :settings="hotSettings"></hot-table> <div @contextmenu="handleContextMenu">
<hot-table ref="hotTableComponent" :settings="hotSettings"></hot-table>
<ContextMenu ref="contextMenuRef" :hotTableComponent="hotTableComponent" :menuItems="componentProps.contextMenuItems" />
</div>
<!-- <div id="hot-dialog-container" style="display:none"> <!-- <div id="hot-dialog-container" style="display:none">
<div class="ht-dialog-content"> <div class="ht-dialog-content">
<h3>执行操作</h3> <h3>执行操作</h3>
@@ -214,14 +323,14 @@ Handsontable.renderers.registerRenderer("db-dropdown", handlerDropdownRenderer);
.ht_master .wtHolder::-webkit-scrollbar-thumb:hover { .ht_master .wtHolder::-webkit-scrollbar-thumb:hover {
background-color: #555; background-color: #555;
} */ } */
/* 滚动条width */
.ht_master .wtHolder{ .ht_master .wtHolder{
/* overflow: hidden !important; */ /* overflow: hidden !important; */
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #a6a8ac #ecf0f1; scrollbar-color: #a6a8ac #ecf0f1;
} }
/* dropdown */ /* dropdown */
.ht-cell-dropdown { display: flex; align-items: center; justify-content: space-between; width: 100%; position: relative; box-sizing: border-box; height: 100%; } .ht-cell-dropdown { display: flex; align-items: center; justify-content: center; width: 100%; position: relative; box-sizing: border-box; height: 100%; }
.ht-cell-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ht-cell-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ht-cell-value:empty::after { content: "\200b"; } .ht-cell-value:empty::after { content: "\200b"; }
.ht-cell-caret { position: absolute; right: 0; top: 50%; transform: translateY(-50%); width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #979797; } .ht-cell-caret { position: absolute; right: 0; top: 50%; transform: translateY(-50%); width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #979797; }
@@ -283,66 +392,86 @@ Handsontable.renderers.registerRenderer("db-dropdown", handlerDropdownRenderer);
.hot-dropdown-search-input { width: 100%; padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 12px; outline: none; transition: border-color 0.2s; } .hot-dropdown-search-input { width: 100%; padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 12px; outline: none; transition: border-color 0.2s; }
.hot-dropdown-search-input:focus { border-color: #3b82f6; } .hot-dropdown-search-input:focus { border-color: #3b82f6; }
.hot-dropdown-table-wrapper { overflow: auto; flex: 1; } .hot-dropdown-table-wrapper { overflow: auto; flex: 1; }
.hot-dropdown-table { width: 100%; border-collapse: collapse; font-size: 12px; } .hot-dropdown-table { width: 100%; border-collapse: collapse; font-size: 12px; text-align: center; }
.hot-dropdown-table thead th { position: sticky; top: 0; background: #f9fafb; font-weight: 600; color: #374151; padding: 8px; border-bottom: 1px solid #e5e7eb; text-align: left; } .hot-dropdown-table thead th { position: sticky; top: 0; background: #f9fafb; font-weight: 600; color: #374151; padding: 8px; border-bottom: 1px solid #e5e7eb; text-align: center; }
.hot-dropdown-table tbody td { padding: 8px; border-bottom: 1px solid #f3f4f6; color: #374151; } .hot-dropdown-table tbody td { padding: 8px; border-bottom: 1px solid #f3f4f6; color: #374151; }
.hot-dropdown-row { cursor: pointer; } .hot-dropdown-row { cursor: pointer; }
.hot-dropdown-row:hover { background: #f3f4f6; } .hot-dropdown-row:hover { background: #f3f4f6; }
/** 指引线line */
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty{ /* 整行高亮样式 */
position: relative; .row-highlight {
display: inline-block; background-color: #e9ecfc !important; /* 浅蓝色背景 */
width: 5px;
height: 1px;
order: -2;
} }
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:last-child {
padding-left: calc(var(--ht-icon-size) + 5px); /* 确保 Handsontable 右键菜单在 ElDialog 之上 - 必须是全局样式 */
.handsontable.htDropdownMenu:not(.htGhostTable),
.handsontable.htContextMenu:not(.htGhostTable),
.handsontable.htFiltersConditionsMenu:not(.htGhostTable) {
z-index: 9999 !important;
} }
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty::before{
content: ''; .ht-id-cell {
position: absolute; position: relative !important;
top: -13px; z-index: 3 !important;
height: 26px; overflow: visible !important;
width: 1px;
border-left: 1px solid #ababab;
} }
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty::after{
content: ''; .ht-id-icon {
position: absolute; position: absolute;
top: 0px; top: 0px;
width: 16px; right: -14px;
height: 1px; width: 14px;
border-top: 1px solid #ababab; height: 14px;
} display: none;
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty{ cursor: pointer;
padding-left: 7px; z-index: 4;
} }
/* 最后一个 ht_nestingLevel_emptyrowHeader 前面的那个) */ .ht-id-cell.current .ht-id-icon,
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty + .rowHeader { .ht-id-cell.area .ht-id-icon {
/* 通过相邻选择器反向选择 */ display: inline-flex;
padding-left: 10px !important
}
/* 选择后面还有 ht_nestingLevel_empty 的元素(不是最后一个) */
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:has(+ .ht_nestingLevel_empty)::before {
/* 你的样式 */
/* height: 0px; */
}
/* 选择后面还有 ht_nestingLevel_empty 的元素(不是最后一个) */
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:has(+ .ht_nestingLevel_empty)::after {
/* 你的样式 */
width: 0px !important;
} }
/* 或者用这个:选择后面不是 ht_nestingLevel_empty 的那个 */ .handsontable {
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:not(:has(+ .ht_nestingLevel_empty))::before { --ht-tree-line-color: #7c7c7c;
/* height: 13px; */ --ht-tree-line-style: solid;
--ht-tree-line-width: 1px;
--ht-tree-indent: 14px;
}
/** 新树形连接线 */
.handsontable .ht_treeCell {
display: flex;
align-items: stretch;
height: 100%;
} }
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:not(:has(+ .ht_nestingLevel_empty))::after { .handsontable .ht_treeIndentLayer {
/* 你的特殊样式 */ flex: 0 0 auto;
height: 100%;
} }
.handsontable .ht_treeContent {
display: flex;
align-items: center;
gap: 6px;
}
.handsontable .ht_treeToggleSpacer {
display: inline-block;
width: var(--ht-icon-size);
height: var(--ht-icon-size);
flex: 0 0 auto;
}
.handsontable .ht_treeContent {
min-width: 0;
}
/* 整行高亮样式 */
.handsontable td.ht_rowHighlight:not(.current):not(.area) {
background-color: #e9ecfc;
}
</style> </style>

View File

@@ -0,0 +1,490 @@
import { useDebounceFn } from '@vueuse/core'
import { ref } from 'vue'
export type TreeNode = Record<string, unknown> & {
__id?: string
__children?: TreeNode[]
level?: string | null
}
type FlatRowMeta = {
id: string
hasChildren: boolean
lineKey: string
path: string
node: TreeNode
siblings: TreeNode[]
indexWithinParent: number
depth: number
}
type FlatTreeIndex = {
root: TreeNode[]
rows: TreeNode[]
metaByRow: FlatRowMeta[]
}
type TreeLinePaint = {
key: string
width: string
backgroundImage: string
backgroundSize: string
backgroundPosition: string
}
type UseParentChildLineOptions = {
getHotInstance: () => any
}
export const useParentChildLineNestedRowsFalse = (options: UseParentChildLineOptions) => {
const treeLinePaintCache = new Map<string, TreeLinePaint>()
const collapsedNodeIds = ref(new Set<string>())
const sourceTreeData = ref<TreeNode[]>([])
const flatIndex = ref<FlatTreeIndex | null>(null)
let nextNodeId = 1
const maybeBumpNextNodeId = (id: string) => {
if (!/^\d+$/.test(id)) return
const num = Number(id)
if (!Number.isSafeInteger(num)) return
if (num >= nextNodeId) nextNodeId = num + 1
}
const getNodeId = (node: TreeNode) => {
if (node.__id) {
maybeBumpNextNodeId(node.__id)
return node.__id
}
const id = String(nextNodeId++)
node.__id = id
return id
}
const normalizeNode = (node: TreeNode): TreeNode => {
getNodeId(node)
if (!Array.isArray(node.__children)) node.__children = []
return node
}
const buildFlatTreeIndex = (root: TreeNode[], collapsedIds: Set<string>): FlatTreeIndex => {
const rows: TreeNode[] = []
const metaByRow: FlatRowMeta[] = []
const visit = (list: TreeNode[], depth: number, flagsStr: string, basePath: string) => {
for (let index = 0; index < list.length; index++) {
const node = normalizeNode(list[index])
const id = getNodeId(node)
const isLast = index === list.length - 1
const children = node.__children ?? []
const hasChildren = children.length > 0
const path = depth === 0 ? String(index) : `${basePath}-${index + 1}`
const lineKey = depth <= 0 ? '0' : `${depth}|${isLast ? 1 : 0}|${flagsStr}`
rows.push(node)
metaByRow.push({
id,
depth,
hasChildren,
lineKey,
path,
node,
siblings: list,
indexWithinParent: index,
})
if (hasChildren && !collapsedIds.has(id)) {
const nextFlagsStr = depth >= 1 ? `${flagsStr}${isLast ? '0' : '1'}` : flagsStr
visit(children, depth + 1, nextFlagsStr, path)
}
}
}
visit(root, 0, '', '')
return { root, rows, metaByRow }
}
const getTreeLinePaint = (key: string) => {
const cached = treeLinePaintCache.get(key)
if (cached) return cached
if (key === '0') {
const empty = { key, width: '0px', backgroundImage: '', backgroundSize: '', backgroundPosition: '' }
treeLinePaintCache.set(key, empty)
return empty
}
const firstSep = key.indexOf('|')
const secondSep = key.indexOf('|', firstSep + 1)
const level = Number(key.slice(0, firstSep))
const isLast = key.slice(firstSep + 1, secondSep) === '1'
const flagsStr = key.slice(secondSep + 1)
const color = 'var(--ht-tree-line-color)'
const width = 'var(--ht-tree-line-width)'
const indent = 'var(--ht-tree-indent)'
const images: string[] = []
const sizes: string[] = []
const positions: string[] = []
for (let i = 0; i < flagsStr.length; i++) {
if (flagsStr.charCodeAt(i) !== 49) continue
images.push(`linear-gradient(${color}, ${color})`)
sizes.push(`${width} 100%`)
positions.push(`calc(${indent} * ${i} + (${indent} / 2)) 0`)
}
const selfDepth = level - 1
images.push(`linear-gradient(${color}, ${color})`)
sizes.push(`${width} ${isLast ? '50%' : '100%'}`)
positions.push(`calc(${indent} * ${selfDepth} + (${indent} / 2)) 0`)
images.push(`linear-gradient(to right, ${color}, ${color})`)
sizes.push(`calc(${indent} / 2) ${width}`)
positions.push(`calc(${indent} * ${selfDepth} + (${indent} / 2)) 50%`)
const paint = {
key,
width: `calc(var(--ht-tree-indent) * ${level})`,
backgroundImage: images.join(', '),
backgroundSize: sizes.join(', '),
backgroundPosition: positions.join(', '),
}
if (treeLinePaintCache.size > 5000) treeLinePaintCache.clear()
treeLinePaintCache.set(key, paint)
return paint
}
const getTreeCellDom = (TD: HTMLTableCellElement) => {
const currentTreeCell = TD.firstElementChild as HTMLElement | null
if (currentTreeCell && currentTreeCell.classList.contains('ht_treeCell')) {
const indentLayer = currentTreeCell.firstElementChild as HTMLElement
const content = currentTreeCell.lastElementChild as HTMLElement
return {
treeCell: currentTreeCell,
indentLayer,
content,
toggleEl: content.firstElementChild as HTMLElement,
textEl: content.lastElementChild as HTMLElement,
}
}
const treeCell = document.createElement('div')
treeCell.className = 'ht_treeCell'
treeCell.style.pointerEvents = 'none'
const indentLayer = document.createElement('span')
indentLayer.className = 'ht_treeIndentLayer'
// indentLayer 不需要交互,让事件穿透
//indentLayer.style.pointerEvents = 'none'
const content = document.createElement('div')
content.className = 'ht_treeContent'
// content 需要响应点击(折叠/展开按钮),但允许右键菜单冒泡
//content.style.pointerEvents = 'none'
const toggleSpacer = document.createElement('span')
toggleSpacer.className = 'ht_treeToggleSpacer'
const text = document.createElement('span')
text.className = 'rowHeader'
content.appendChild(toggleSpacer)
content.appendChild(text)
treeCell.appendChild(indentLayer)
treeCell.appendChild(content)
TD.replaceChildren(treeCell)
return {
treeCell,
indentLayer,
content,
toggleEl: toggleSpacer,
textEl: text,
}
}
const rebuildDataFromTree = (hotOverride?: any) => {
const index = buildFlatTreeIndex(sourceTreeData.value, collapsedNodeIds.value)
flatIndex.value = index
const hot = hotOverride ?? options.getHotInstance()
if (hot) hot.loadData(index.rows)
}
const toggleNodeCollapsed = (nodeId: string) => {
if (collapsedNodeIds.value.has(nodeId)) collapsedNodeIds.value.delete(nodeId)
else collapsedNodeIds.value.add(nodeId)
rebuildDataFromTree()
}
const normalizeTreeData = (input: unknown): TreeNode[] => {
const root = Array.isArray(input)
? (input as TreeNode[])
: input && typeof input === 'object'
? [input as TreeNode]
: []
const stack: unknown[] = [...root]
while (stack.length) {
const current = stack.pop()
if (!current || typeof current !== 'object') continue
const node = current as TreeNode
const maybeChildren = (node as any).__children
if (!Array.isArray(maybeChildren)) {
const altChildren = (node as any).children
node.__children = Array.isArray(altChildren) ? (altChildren as TreeNode[]) : []
} else {
node.__children = maybeChildren as TreeNode[]
}
for (let i = node.__children.length - 1; i >= 0; i--) {
const child = node.__children[i]
if (!child || typeof child !== 'object') node.__children.splice(i, 1)
}
normalizeNode(node)
for (let i = 0; i < node.__children.length; i++) stack.push(node.__children[i])
}
return root
}
const load = (data: unknown) => {
collapsedNodeIds.value.clear()
treeLinePaintCache.clear()
nextNodeId = 1
sourceTreeData.value = normalizeTreeData(data)
rebuildDataFromTree()
}
const createNode = (dataSchema: any): TreeNode => {
const node = {
...dataSchema,
__children: [],
} as TreeNode
normalizeNode(node)
return node
}
const getSelectedVisualRowRange = (hot: any): { startRow: number; amount: number } | null => {
const sel = hot.getSelectedLast?.() ?? hot.getSelected?.()?.[0]
if (!sel) return null
const [r1, , r2] = sel as [number, number, number, number]
const startRow = Math.min(r1, r2)
const endRow = Math.max(r1, r2)
return { startRow, amount: endRow - startRow + 1 }
}
const collectDescendantIds = (node: TreeNode): string[] => {
const ids: string[] = []
const stack: TreeNode[] = [...(node.__children ?? [])]
while (stack.length) {
const current = stack.pop()!
if (current.__id) ids.push(current.__id)
if (Array.isArray(current.__children) && current.__children.length) stack.push(...current.__children)
}
return ids
}
const handleRowOperation = (hot: any, type: 'above' | 'below' | 'child'| 'append'|'delete') => {
if (!hot) return
const index = flatIndex.value
if (!index) return
if (type === 'append') {
index.root.push(createNode(hot.getSettings().dataSchema))
rebuildDataFromTree(hot)
return
}
if (type === 'delete') {
const range = getSelectedVisualRowRange(hot)
if (!range) return
const selected = index.metaByRow.slice(range.startRow, range.startRow + range.amount)
const uniqueById = new Map<string, FlatRowMeta>()
for (const meta of selected) uniqueById.set(meta.id, meta)
const metas = [...uniqueById.values()]
metas.sort((a, b) => b.depth - a.depth)
const collapsed = collapsedNodeIds.value
const bySiblings = new Map<TreeNode[], FlatRowMeta[]>()
for (const meta of metas) {
const list = bySiblings.get(meta.siblings)
if (list) list.push(meta)
else bySiblings.set(meta.siblings, [meta])
}
for (const meta of metas) {
for (const descendantId of collectDescendantIds(meta.node)) collapsed.delete(descendantId)
collapsed.delete(meta.id)
}
for (const [siblings, list] of bySiblings.entries()) {
const sorted = [...list].sort((a, b) => b.indexWithinParent - a.indexWithinParent)
for (const meta of sorted) siblings.splice(meta.indexWithinParent, 1)
}
rebuildDataFromTree(hot)
return
}
const selection = hot.getSelectedLast?.() ?? hot.getSelected?.()?.[0]
if (!selection) return
const [r1] = selection as [number, number, number, number]
const meta = index.metaByRow[r1]
if (!meta) return
if (type === 'child') {
meta.node.__children ??= []
meta.node.__children.push(createNode(hot.getSettings().dataSchema))
collapsedNodeIds.value.delete(meta.id)
rebuildDataFromTree(hot)
return
}
meta.siblings.splice(type === 'above' ? meta.indexWithinParent : meta.indexWithinParent + 1, 0, createNode(hot.getSettings().dataSchema))
rebuildDataFromTree(hot)
}
const codeRenderer = (
hot: any,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any
) => {
// 检查是否需要设置验证背景色
const cellMeta = hot.getCellMeta(row, col)
const isValid = cellMeta?.valid !== false
// 如果单元格被标记为无效,设置红色背景
if (!isValid) {
TD.style.backgroundColor = '#ffbeba' // 淡红色背景
} else {
//TD.style.backgroundColor = cellProperties.className == "row-highlight"?'#e9ecfc':'' // 清除背景色
}
const meta = flatIndex.value?.metaByRow?.[row]
const key = meta?.lineKey ?? '0'
const { indentLayer, content, toggleEl, textEl } = getTreeCellDom(TD)
if (indentLayer.dataset.paintKey !== key) {
indentLayer.dataset.paintKey = key
const paint = getTreeLinePaint(key)
indentLayer.style.width = paint.width
indentLayer.style.backgroundImage = paint.backgroundImage
indentLayer.style.backgroundSize = paint.backgroundSize
indentLayer.style.backgroundPosition = paint.backgroundPosition
indentLayer.style.backgroundRepeat = paint.backgroundImage ? 'no-repeat' : ''
}
if (meta?.hasChildren && meta?.id) {
const isCollapsed = collapsedNodeIds.value.has(meta.id)
const needsButton = toggleEl.tagName !== 'DIV' || !toggleEl.classList.contains('ht_nestingButton')
const btn = needsButton ? document.createElement('div') : toggleEl
btn.className = `ht_nestingButton ${isCollapsed ? 'ht_nestingExpand' : 'ht_nestingCollapse'}`
btn.dataset.nodeId = meta.id
// 确保按钮可以响应点击事件
btn.style.pointerEvents = 'auto'
if (!(btn as any).__htTreeToggleBound) {
;(btn as any).__htTreeToggleBound = true
btn.addEventListener('mousedown', (ev) => {
ev.stopPropagation()
ev.preventDefault()
const nodeId = (ev.currentTarget as HTMLElement | null)?.dataset.nodeId
if (!nodeId) return
toggleNodeCollapsed(nodeId)
})
}
if (needsButton) content.replaceChild(btn, toggleEl)
} else {
const needsSpacer = toggleEl.tagName !== 'SPAN' || !toggleEl.classList.contains('ht_treeToggleSpacer')
if (needsSpacer) {
const spacer = document.createElement('span')
spacer.className = 'ht_treeToggleSpacer'
content.replaceChild(spacer, toggleEl)
}
}
textEl.textContent = value == null ? '' : String(value)
return TD
}
const levelRenderer = (
hot: any,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any
) => {
TD.textContent = flatIndex.value?.metaByRow?.[row]?.path ?? ''
return TD
}
const initSchema = (columns: any[]) => {
let rowSchema: any = {__id:null, level: null, __children: []}
// 根据 columns 的 data 字段生成对象结构
columns.forEach((col: any) => {
if (col.data && col.data !== 'level' && col.data !== '__children'&& col.data !== '__id') {
rowSchema[col.data] = null
}
})
return rowSchema
}
//把最后一次选中的行重新选回来,保持高亮
const highlightDeselect = (_this: any, coords: any) => {
if (coords !== null) queueMicrotask(() => _this.selectCell(coords?.row, coords?.col))
}
return {
load,
initSchema,
highlightDeselect,
codeRenderer,
levelRenderer,
handleRowOperation,
flatIndex,
}
}
///当设置 manualColumnResize: true 时,双击列边界触发自动适应列宽的方法是 autoColumnSize 插件。
function applyDblClickAutoFit (hot: any, visualCol: number) {
const autoFit = useDebounceFn(()=>{
if (!hot || !Number.isInteger(visualCol)) return
const manualColumnResize = hot.getPlugin?.('manualColumnResize')
if (!manualColumnResize?.setManualSize) return
const baseWidth = hot.getColWidth?.(visualCol) ?? 0
const hookedWidth = hot.runHooks?.('beforeColumnResize', baseWidth, visualCol, true)
const newWidth = typeof hookedWidth === 'number' ? hookedWidth : baseWidth
manualColumnResize.setManualSize(visualCol, newWidth)
manualColumnResize.saveManualColumnWidths?.()
hot.runHooks?.('afterColumnResize', newWidth, visualCol, true)
hot.view?.adjustElementsSize?.()
hot.render?.()
},300)
autoFit()
}
export function applyAutoFitColum (hot: any, changes: any[]) {
const columns = hot.getSettings?.().columns
for (const change of changes) {
const [, prop, oldValue, newValue] = change
if (!columns || !Array.isArray(columns)) continue
const visualCol = columns.findIndex((col) => col?.autoWidth === true && col?.data === prop)
if (visualCol < 0) continue
if (oldValue === newValue) continue
applyDblClickAutoFit(hot, visualCol)
console.log('scheduleDblClickAutoFit')
break
}
}

View File

@@ -0,0 +1,169 @@
import Handsontable from 'handsontable'
import type { Ref } from 'vue'
import { onMounted, onUnmounted } from 'vue'
type SelectRenderDeps = {
visible: Ref<boolean>
buttonRef: Ref<HTMLElement | null | undefined>
}
// 设置全局点击监听器的 Hook
export const usePopoverClickOutside = (
visible: Ref<boolean>,
onClickOutside: () => void
) => {
const handler = (event: PointerEvent) => {
visible.value = false
onClickOutside()
}
onMounted(() => {
document.addEventListener('pointerdown', handler, true)
})
onUnmounted(() => {
document.removeEventListener('pointerdown', handler, true)
})
return handler
}
const debounce = <T extends (...args: any[]) => void>(func: T, wait: number) => {
let timeout: ReturnType<typeof setTimeout> | undefined
return function (this: unknown, ...args: Parameters<T>) {
const context = this
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
func.apply(context, args)
}, wait)
}
}
export const createPopoverCellRenderer = ({ visible, buttonRef }: SelectRenderDeps) => {
const openPopover = (container: HTMLElement, virtualEl: HTMLElement) => {
buttonRef.value = virtualEl
visible.value = true
}
return (
instance: Handsontable,
td: HTMLTableCellElement,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any
) => {
const rowData = instance.getSourceDataAtRow(row)
//parent存在并ture时或者没有parent对象时
if((rowData.hasOwnProperty('parent') && rowData?.parent) || !rowData.hasOwnProperty('parent')){
td.innerHTML = ''
const container = document.createElement('div')
container.style.display = 'flex'
container.style.alignItems = 'center'
container.style.justifyContent = 'space-between'
container.style.width = '100%'
container.style.height = '100%'
const input = document.createElement('input')
input.type = 'text'
input.value = value || ''
input.style.flex = '1'
input.style.border = 'none'
input.style.outline = 'none'
input.style.background = 'transparent'
input.style.padding = '0'
input.style.width = '100%'
input.style.fontSize = 'inherit'
input.style.fontFamily = 'inherit'
input.setAttribute('data-cell-row', row.toString())
input.setAttribute('data-cell-col', col.toString())
input.addEventListener('keydown', (e) => {
e.stopPropagation()
})
input.addEventListener('keyup', (e) => {
e.stopPropagation()
})
input.addEventListener('keypress', (e) => {
e.stopPropagation()
})
input.addEventListener('mousedown', (e) => {
e.stopPropagation()
})
input.addEventListener('click', (e) => {
e.stopPropagation()
openPopover(container, input)
})
const debouncedInputHandler = debounce((newValue: string) => {
console.log(`输入变化(防抖后) - 行: ${row}, 列: ${col}, 新值: ${newValue}`)
openPopover(container, input)
if (cellProperties.inputCallback && typeof cellProperties.inputCallback === 'function') {
const rowData = (instance as any).getSourceDataAtRow(row)
cellProperties.inputCallback(row, col, prop, newValue, rowData, instance, td)
}
}, 300)
input.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement
const newValue = target.value
console.log(`输入中 - 行: ${row}, 列: ${col}, 新值: ${newValue}`)
debouncedInputHandler(newValue)
})
input.addEventListener('focus', () => {
console.log(`输入框获得焦点 - 行: ${row}, 列: ${col}`)
openPopover(container, input)
if (cellProperties.focusCallback && typeof cellProperties.focusCallback === 'function') {
const rowData = (instance as any).getSourceDataAtRow(row)
cellProperties.focusCallback(row, col, prop, input.value, rowData, instance, td)
}
})
input.addEventListener('blur', () => {
console.log(`输入框失焦 - 行: ${row}, 列: ${col}, 最终值: ${input.value}`)
})
const searchIcon = document.createElement('span')
searchIcon.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>`
searchIcon.style.cursor = 'pointer'
searchIcon.style.marginLeft = '5px'
searchIcon.style.fontSize = '14px'
searchIcon.style.flexShrink = '0'
searchIcon.addEventListener('click', (e) => {
e.stopPropagation()
console.log(`搜索图标被点击 - 行: ${row}, 列: ${col}, 值: ${input.value}`)
openPopover(container, searchIcon)
if (cellProperties.selectCallback && typeof cellProperties.selectCallback === 'function') {
const rowData = (instance as any).getSourceDataAtRow(row)
cellProperties.selectCallback(row, col, prop, input.value, rowData, instance, td)
}
})
container.appendChild(input)
container.appendChild(searchIcon)
td.appendChild(container)
}else{
td.innerHTML = value || ''
}
return td
}
}

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElPopover, ElTableV2 } from 'element-plus'
import type { PopoverInstance, RowEventHandlers } from 'element-plus'
interface Props {
visible: boolean
buttonRef: HTMLElement | null | undefined
columns: any[]
data: any[]
rowEventHandlers?: RowEventHandlers
width?: number
height?: number
popperHeight?: string
}
const props = withDefaults(defineProps<Props>(), {
width: 955,
height: 350,
popperHeight: '380px'
})
const emit = defineEmits<{
'end-reached': []
}>()
const popoverRef = ref<PopoverInstance>()
// 暴露 popoverRef 供父组件调用
defineExpose({ popoverRef })
</script>
<template>
<ElPopover
ref="popoverRef"
:virtual-ref="buttonRef"
virtual-triggering
:visible="visible"
placement="bottom"
:width="width"
:popper-style="{ height: popperHeight }"
>
<ElTableV2
:columns="columns"
:data="data"
:width="width - 25"
:height="height"
fixed
:row-height="25"
:header-height="25"
:row-event-handlers="rowEventHandlers"
:cell-props="{
style: {
background: 'transparent !important'
}
}"
:teleported="false"
@end-reached="emit('end-reached')"
/>
</ElPopover>
</template>

View File

@@ -4,31 +4,62 @@ let currentAnchor: { instance: any; row: number; col: number; td: HTMLTableCellE
const clamp = (x: number, min: number, max: number) => (x < min ? min : x > max ? max : x) const clamp = (x: number, min: number, max: number) => (x < min ? min : x > max ? max : x)
/**
* 获取嵌套属性值的辅助函数
* @param obj 对象
* @param path 属性路径,如 'calcBase.formula'
* @returns 属性值
*/
const getNestedValue = (obj: any, path: string): any => {
if (!path || !obj) return ''
const keys = path.split('.')
let value = obj
for (const key of keys) {
if (value === null || value === undefined) return ''
value = value[key]
}
return value ?? ''
}
/** /**
* 创建表格数据结构的辅助函数 * 创建表格数据结构的辅助函数
* @param dataSource 数据源数组 * @param dataSource 数据源数组
* @param fieldKeys 字段键数组,按顺序对应表格列 * @param fieldKeys 字段键数组,按顺序对应表格列
* @param getLabelFn 获取标签的函数 * @param getDisplayText 获取显示文本的函数
* @returns 格式化后的表格行HTML和数据属性 * @returns 格式化后的表格行HTML和数据属性
*/ */
export function createTableDataStructure( export function createTableDataStructure(
dataSource: any[], dataSource: any[],
fieldKeys: string[], fieldKeys: string[],
getLabelFn?: (item: any) => string getDisplayText?: (item: any) => string
) { ) {
const getLabel = getLabelFn ?? ((x: any) => x?.name ?? '') const getLabel = getDisplayText ?? ((x: any) => x?.name ?? '')
return dataSource.map(item => { return dataSource.map(item => {
// 动态生成单元格 // 动态生成单元格 - 支持嵌套属性
const cells = fieldKeys.map(key => `<td>${String(item?.[key] ?? '')}</td>`).join('') const cells = fieldKeys.map(key => {
const value = getNestedValue(item, key)
return `<td>${String(value)}</td>`
}).join('')
// 动态生成 data 属性 // 动态生成 data 属性 - 支持嵌套属性
const dataAttrs = fieldKeys const dataAttrs = fieldKeys
.map(key => `data-${key.toLowerCase()}="${String(item?.[key] ?? '')}"`) .map(key => {
const value = getNestedValue(item, key)
// 将嵌套路径转换为有效的 data 属性名,如 'calcBase.formula' -> 'calcbase-formula'
const attrName = key.toLowerCase().replace(/\./g, '-')
return `data-${attrName}="${String(value)}"`
})
.join(' ') .join(' ')
// 将完整的 item 数据序列化存储
const itemDataJson = JSON.stringify(item).replace(/"/g, '&quot;')
return { return {
html: `<tr class="hot-dropdown-row" data-label="${String(getLabel(item) ?? '')}" ${dataAttrs}>${cells}</tr>`, html: `<tr class="hot-dropdown-row" data-label="${String(getLabel(item) ?? '')}" data-item="${itemDataJson}" ${dataAttrs}>${cells}</tr>`,
data: item data: item
} }
}) })
@@ -93,21 +124,18 @@ export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, ro
return el return el
} }
const labelFn: ((x: any) => string) | undefined = cellProperties.customGetLabel
const display = createEl('div', 'hot-dropdown-display') const display = createEl('div', 'hot-dropdown-display')
const labelText = typeof value === 'string' ? value : labelFn?.(value) ?? (value && typeof value === 'object' ? String(value.name ?? '') : '') const labelText = typeof value === 'string' ? value : (value && typeof value === 'object' ? String(value.name ?? '') : '')
if (labelText && labelText.length > 0) display.appendChild(createEl('span', 'hot-dropdown-text', labelText)) if (labelText && labelText.length > 0) display.appendChild(createEl('span', 'hot-dropdown-text', labelText))
else display.appendChild(createEl('span', 'hot-dropdown-placeholder', '选择')) else display.appendChild(createEl('span', 'hot-dropdown-placeholder', '选择'))
const trigger = createEl('button', 'hot-dropdown-trigger', '▼') as HTMLButtonElement const trigger = createEl('button', 'hot-dropdown-trigger', '▼') as HTMLButtonElement
trigger.type = 'button' trigger.type = 'button'
const buildDropdown = () => { const buildDropdown = async () => {
const src = cellProperties.customDropdownSource as any[] | undefined
const headers: string[] | undefined = cellProperties.customTableHeaders const headers: string[] | undefined = cellProperties.customTableHeaders
const dropdown = createEl('div', 'hot-dropdown') const dropdown = createEl('div', 'hot-dropdown')
const getLabel = labelFn ?? ((x: any) => x?.name ?? '') const getDisplayText = (x: any) => x?.name ?? ''
// 创建搜索栏 // 创建搜索栏
const searchContainer = createEl('div', 'hot-dropdown-search') const searchContainer = createEl('div', 'hot-dropdown-search')
@@ -119,29 +147,51 @@ export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, ro
const theadHtml = headers && headers.length ? `<thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead>` : '' const theadHtml = headers && headers.length ? `<thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead>` : ''
// 使用自定义字段键或默认字段键 // 使用自定义字段键或默认字段键
const fieldKeys = cellProperties.customFieldKeys || ['code', 'name', 'spec', 'category', 'unit', 'taxRate', 'priceExTax', 'priceInTax', 'priceExTaxComp', 'priceInTaxComp', 'calcBase'] const fieldKeys = cellProperties.customFieldKeys || []
const rowsHtml = Array.isArray(src)
? createTableDataStructure(src, fieldKeys, labelFn).map(row => row.html).join('')
: ''
// 创建加载提示
const tableEl = createEl('div', 'hot-dropdown-table-wrapper') const tableEl = createEl('div', 'hot-dropdown-table-wrapper')
tableEl.innerHTML = `<table class="hot-dropdown-table">${theadHtml}<tbody>${rowsHtml}</tbody></table>` tableEl.innerHTML = `<table class="hot-dropdown-table">${theadHtml}<tbody><tr><td colspan="${fieldKeys.length}" style="text-align: center; padding: 20px;">加载中...</td></tr></tbody></table>`
dropdown.appendChild(searchContainer) dropdown.appendChild(searchContainer)
dropdown.appendChild(tableEl) dropdown.appendChild(tableEl)
const tbody = dropdown.querySelector('tbody') as HTMLTableSectionElement // 异步加载数据
const allRows = Array.from(tbody.querySelectorAll('tr')) as HTMLTableRowElement[] let src: any[] = []
const dataSource = cellProperties.customDropdownSource
// 搜索功能 - 动态搜索所有字段 if (typeof dataSource === 'function') {
try {
src = await dataSource()
} catch (error) {
console.error('加载下拉数据失败:', error)
tableEl.innerHTML = `<table class="hot-dropdown-table">${theadHtml}<tbody><tr><td colspan="${fieldKeys.length}" style="text-align: center; padding: 20px; color: red;">加载失败</td></tr></tbody></table>`
return dropdown
}
} else if (Array.isArray(dataSource)) {
src = dataSource
}
// 渲染数据
const rowsHtml = Array.isArray(src) && src.length > 0
? createTableDataStructure(src, fieldKeys, getDisplayText).map(row => row.html).join('')
: `<tr><td colspan="${fieldKeys.length}" style="text-align: center; padding: 20px;">暂无数据</td></tr>`
tableEl.innerHTML = `<table class="hot-dropdown-table">${theadHtml}<tbody>${rowsHtml}</tbody></table>`
const tbody = dropdown.querySelector('tbody') as HTMLTableSectionElement
const allRows = Array.from(tbody.querySelectorAll('tr.hot-dropdown-row')) as HTMLTableRowElement[]
// 搜索功能 - 动态搜索所有字段(支持嵌套属性)
const searchFieldKeys = cellProperties.customSearchFields || cellProperties.customFieldKeys || ['code', 'name', 'spec', 'category', 'unit'] const searchFieldKeys = cellProperties.customSearchFields || cellProperties.customFieldKeys || ['code', 'name', 'spec', 'category', 'unit']
searchInput.addEventListener('input', (ev) => { searchInput.addEventListener('input', (ev) => {
ev.stopPropagation() ev.stopPropagation()
const keyword = searchInput.value.toLowerCase().trim() const keyword = searchInput.value.toLowerCase().trim()
allRows.forEach(tr => { allRows.forEach(tr => {
const matches = searchFieldKeys.some((key: string) => { const matches = searchFieldKeys.some((key: string) => {
const value = (tr.dataset[key.toLowerCase()] ?? '').toLowerCase() // 将嵌套路径转换为 data 属性名,如 'calcBase.formula' -> 'calcbase-formula'
const attrName = key.toLowerCase().replace(/\./g, '-')
const value = (tr.dataset[attrName] ?? '').toLowerCase()
return value.includes(keyword) return value.includes(keyword)
}) })
tr.style.display = matches ? '' : 'none' tr.style.display = matches ? '' : 'none'
@@ -154,9 +204,25 @@ export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, ro
tbody.addEventListener('click', (ev) => { tbody.addEventListener('click', (ev) => {
ev.stopPropagation() ev.stopPropagation()
const tr = (ev.target as HTMLElement).closest('tr') as HTMLTableRowElement | null const tr = (ev.target as HTMLElement).closest('tr') as HTMLTableRowElement | null
if (!tr) return if (!tr || !tr.classList.contains('hot-dropdown-row')) return
const next = tr.dataset.label ?? ''
instance.setDataAtCell(row, column, next) // 从 data-item 属性中恢复完整的 item 数据
const itemJson = tr.dataset.item ?? '{}'
const selectedItem = JSON.parse(itemJson.replace(/&quot;/g, '"'))
// 获取当前行的完整数据
const currentRowData = instance.getSourceDataAtRow(row)
// 调用自定义回调函数
const callbackFn = cellProperties.customCallbackRow
if (typeof callbackFn === 'function') {
callbackFn(currentRowData, selectedItem, row, column, instance)
} else {
// 默认行为:设置显示文本
const displayValue = tr.dataset.label ?? ''
instance.setDataAtCell(row, column, displayValue)
}
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown) if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown)
currentDropdownEl = null currentDropdownEl = null
if (currentOnDocClick) { if (currentOnDocClick) {
@@ -171,11 +237,11 @@ export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, ro
return dropdown return dropdown
} }
const openDropdown = () => { const openDropdown = async () => {
if (currentDropdownEl && currentDropdownEl.parentNode) currentDropdownEl.parentNode.removeChild(currentDropdownEl) if (currentDropdownEl && currentDropdownEl.parentNode) currentDropdownEl.parentNode.removeChild(currentDropdownEl)
if (currentOnDocClick) document.removeEventListener('click', currentOnDocClick) if (currentOnDocClick) document.removeEventListener('click', currentOnDocClick)
const dropdown = buildDropdown() const dropdown = await buildDropdown()
document.body.appendChild(dropdown) document.body.appendChild(dropdown)
currentDropdownEl = dropdown currentDropdownEl = dropdown
currentAnchor = { instance, row, col: column, td } currentAnchor = { instance, row, col: column, td }

View File

@@ -0,0 +1,28 @@
export function handlerDuplicateCodeRenderer(instance: any, td: HTMLTableCellElement, row: number, column: number, prop: string | number, value: any, cellProperties: any){
td.textContent = value || ''
// 检查是否需要设置验证背景色
const cellMeta = instance.getCellMeta(row, column)
const isValid = cellMeta?.valid !== false
// 如果单元格被标记为无效,设置红色背景
if (!isValid) {
td.style.backgroundColor = '#fa4d3233' // 淡红色背景
} else {
td.style.backgroundColor = '' // 清除背景色
}
// 检查当前值是否重复(排除空值)
if (value && value.toString().trim() !== '') {
// 获取所有数据
const data = instance.getSourceData()
const duplicateCount = data.filter((item: any) => item.code === value).length
if (duplicateCount > 1) {
td.style.color = 'red'
td.style.fontWeight = 'bold'
} else {
td.style.color = ''
td.style.fontWeight = ''
}
}
return td
}

View File

@@ -1,3 +1,38 @@
import { getTreeLine, getTreeLinePaint, getTreeCellDom } from './treeLine'
// 通用的验证 renderer用于显示验证背景色
export const validationRenderer = (
hot: any,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any
) => {
// 清空单元格
while (TD.firstChild) TD.removeChild(TD.firstChild)
// 检查是否需要设置验证背景色
const columns = this.getSettings().columns || []
const currentColumn = columns[col]
const isEmpty = value === null || value === undefined || String(value).trim() === ''
// 如果列配置了 allowInvalid: true 且值为空,设置红色背景
if (currentColumn?.allowInvalid === true && isEmpty) {
TD.style.backgroundColor = '#ff4d4f20' // 淡红色背景
} else {
TD.style.backgroundColor = '' // 清除背景色
}
// 创建文本节点
const text = document.createElement('span')
text.textContent = value == null ? '' : String(value)
TD.appendChild(text)
return TD
}
export const codeRenderer = ( export const codeRenderer = (
hot: any, hot: any,
TD: HTMLTableCellElement, TD: HTMLTableCellElement,
@@ -7,43 +42,71 @@ export const codeRenderer = (
value: any, value: any,
cellProperties: any cellProperties: any
) => { ) => {
while (TD.firstChild) TD.removeChild(TD.firstChild)
// 检查是否需要设置验证背景色
const cellMeta = hot.getCellMeta(row, col)
const isValid = cellMeta?.valid !== false
// 如果单元格被标记为无效,设置红色背景
if (!isValid) {
TD.style.backgroundColor = '#fa4d3233' // 淡红色背景
} else {
TD.style.backgroundColor = cellProperties.className == "row-highlight"?'#e9ecfc':'' // 清除背景色
}
const nestedRowsPlugin = hot.getPlugin('nestedRows') const nestedRowsPlugin = hot.getPlugin('nestedRows')
const physicalRow = nestedRowsPlugin ? nestedRowsPlugin.collapsingUI.translateTrimmedRow(row) : hot.toPhysicalRow(row) const physicalRow = nestedRowsPlugin ? nestedRowsPlugin.collapsingUI.translateTrimmedRow(row) : hot.toPhysicalRow(row)
const rowObj = hot.getSourceDataAtRow(physicalRow) const dataManager = nestedRowsPlugin?.dataManager
const container = document.createElement('div')
container.style.display = 'flex' const dataNode = dataManager?.getDataObject?.(physicalRow)
container.style.alignItems = 'center' const root = (dataManager?.getData?.() as any[] | undefined) ?? []
container.style.gap = '6px' const line = nestedRowsPlugin && dataManager && dataNode && Array.isArray(root)
const level = nestedRowsPlugin?.dataManager.getRowLevel(physicalRow) ?? 0 ? getTreeLine(dataNode, dataManager, root)
for (let i = 0; i < (level || 0); i++) { : ({ key: '0', hasChildren: false } as const)
const spacer = document.createElement('span')
spacer.className = 'ht_nestingLevel_empty' const { indentLayer, content, toggleEl, textEl } = getTreeCellDom(TD)
container.appendChild(spacer) if (indentLayer.dataset.paintKey !== line.key) {
indentLayer.dataset.paintKey = line.key
const paint = getTreeLinePaint(line.key)
indentLayer.style.width = paint.width
indentLayer.style.backgroundImage = paint.backgroundImage
indentLayer.style.backgroundSize = paint.backgroundSize
indentLayer.style.backgroundPosition = paint.backgroundPosition
indentLayer.style.backgroundRepeat = paint.backgroundImage ? 'no-repeat' : ''
} }
if (rowObj && Array.isArray(rowObj.__children) && rowObj.__children.length > 0) {
if (line.hasChildren && nestedRowsPlugin) {
const isCollapsed = nestedRowsPlugin?.collapsingUI.areChildrenCollapsed(physicalRow) ?? false const isCollapsed = nestedRowsPlugin.collapsingUI.areChildrenCollapsed(physicalRow)
const btn = document.createElement('div') const needsButton = toggleEl.tagName !== 'DIV' || !toggleEl.classList.contains('ht_nestingButton')
const btn = needsButton ? document.createElement('div') : toggleEl
btn.className = `ht_nestingButton ${isCollapsed ? 'ht_nestingExpand' : 'ht_nestingCollapse'}` btn.className = `ht_nestingButton ${isCollapsed ? 'ht_nestingExpand' : 'ht_nestingCollapse'}`
btn.addEventListener('mousedown', (ev) => { btn.dataset.row = String(physicalRow)
if (ev.button !== 0) return if (!(btn as any).__htTreeToggleBound) {
ev.stopPropagation() ;(btn as any).__htTreeToggleBound = true
ev.preventDefault() btn.addEventListener('mousedown', (ev) => {
if (!nestedRowsPlugin) return ev.stopPropagation()
const nowCollapsed = nestedRowsPlugin.collapsingUI.areChildrenCollapsed(physicalRow) ev.preventDefault()
if (nowCollapsed) nestedRowsPlugin.collapsingUI.expandChildren(physicalRow) const rowStr = (ev.currentTarget as HTMLElement | null)?.dataset.row
else nestedRowsPlugin.collapsingUI.collapseChildren(physicalRow) const targetRow = rowStr ? Number(rowStr) : NaN
}) if (!nestedRowsPlugin || Number.isNaN(targetRow)) return
container.appendChild(btn) const nowCollapsed = nestedRowsPlugin.collapsingUI.areChildrenCollapsed(targetRow)
}/*else{ if (nowCollapsed) nestedRowsPlugin.collapsingUI.expandChildren(targetRow)
container.classList.add('text-relative') else nestedRowsPlugin.collapsingUI.collapseChildren(targetRow)
}*/ })
const text = document.createElement('span') }
text.textContent = value == null ? '' : String(value) if (needsButton) content.replaceChild(btn, toggleEl)
text.classList.add('rowHeader') } else {
const needsSpacer = toggleEl.tagName !== 'SPAN' || !toggleEl.classList.contains('ht_treeToggleSpacer')
if (needsSpacer) {
const spacer = document.createElement('span')
spacer.className = 'ht_treeToggleSpacer'
content.replaceChild(spacer, toggleEl)
}
}
textEl.textContent = value == null ? '' : String(value)
//解决右键行头触发上下文菜单事件 //解决右键行头触发上下文菜单事件
text.addEventListener('contextmenu', (ev) => { textEl.addEventListener('contextmenu', (ev) => {
ev.preventDefault() ev.preventDefault()
ev.stopPropagation() ev.stopPropagation()
const e = new MouseEvent('contextmenu', { const e = new MouseEvent('contextmenu', {
@@ -56,19 +119,52 @@ export const codeRenderer = (
}) })
TD.dispatchEvent(e) TD.dispatchEvent(e)
}) })
container.appendChild(text) }
TD.appendChild(container)
return TD // codeRenderer 的编辑后回调处理
export const handleCodeCallback = (hot: any, changes: any[], codeCallbackRow?: Function) => {
if (!changes || !codeCallbackRow) return
const columns = hot.getSettings().columns || []
changes.forEach(([row, prop, oldValue, newValue]) => {
// 查找当前列配置
const colIndex = hot.propToCol(prop)
const currentColumn = columns[colIndex]
//console.log('currentColumn?.code',currentColumn?.code)
// 只处理配置了 code: true 的列的变化
if (currentColumn?.code === true && oldValue !== newValue) {
const nestedRowsPlugin = hot.getPlugin('nestedRows')
const physicalRow = nestedRowsPlugin ? nestedRowsPlugin.collapsingUI.translateTrimmedRow(row) : hot.toPhysicalRow(row)
const currentRowData = hot.getSourceDataAtRow(physicalRow)
// 调用回调函数,传递参数:当前行数据、旧值、新值、行索引、列索引、实例
if (typeof codeCallbackRow === 'function') {
codeCallbackRow(currentRowData, oldValue, newValue, row, colIndex, hot)
}
}
})
} }
export const computeCodeColWidth = (hot: any): number => { export const computeCodeColWidth = (hot: any): number => {
// if (!hot) return codeColWidth.value if (!hot) return 120
// 查找配置了 code: true 的列
const columns = hot.getSettings().columns || []
const codeColumn = columns.find((col: any) => col.code === true)
if (!codeColumn || !codeColumn.data) return 120
const data = hot.getSourceData() || [] const data = hot.getSourceData() || []
const codeDataKey = codeColumn.data
// 计算该列的最大长度
const maxLen = data.reduce((m: number, r: any) => { const maxLen = data.reduce((m: number, r: any) => {
const v = r && r.code != null ? String(r.code) : '' const v = r && r[codeDataKey] != null ? String(r[codeDataKey]) : ''
return Math.max(m, v.length) return Math.max(m, v.length)
}, 0) }, 0)
const charWidth = 9 const charWidth = 9
const basePadding = 24 const basePadding = 24
const nested = hot.getPlugin('nestedRows') const nested = hot.getPlugin('nestedRows')
@@ -84,100 +180,71 @@ export const computeCodeColWidth = (hot: any): number => {
return Math.min(Math.max(80, width), 480) return Math.min(Math.max(80, width), 480)
} }
// 工具函数:解析 level 字符串
const parseLevel = (level: string): number[] =>
level?.split('-').map(n => Number(n)).filter(n => !Number.isNaN(n)) ?? []
// 工具函数:根据 level 获取容器和索引 const createNode = (dataSchema: any,level: string): any => ({
const getContainerAndIndexByLevel = (data: any[], level: string) => { ...dataSchema,
const seg = parseLevel(level) level,
if (seg.length === 0) return null __children: []
if (seg.length === 1) return { container: data, index: seg[0], parentLevel: null } })
const getSelectedVisualRowRange = (hot: any): { startRow: number; amount: number } | null => {
let parent = data[seg[0]] const sel = hot.getSelectedLast?.() ?? hot.getSelected?.()?.[0]
for (let i = 1; i < seg.length - 1; i++) { if (!sel) return null
if (!Array.isArray(parent.__children)) parent.__children = []
parent = parent.__children[seg[i] - 1] const [r1, , r2] = sel as [number, number, number, number]
if (!parent) return null const startRow = Math.min(r1, r2)
const endRow = Math.max(r1, r2)
return { startRow, amount: endRow - startRow + 1 }
}
/** 生成树结构 */
const computeRowPath = (node: any, dm: any, root: any[]): string => {
const chain: any[] = []
for (let n = node; n; n = dm.getRowParent(n)) chain.unshift(n)
const segs: string[] = [String(root.indexOf(chain[0]))]
for (let i = 1; i < chain.length; i++) segs.push(String((chain[i - 1].__children?.indexOf(chain[i]) ?? -1) + 1))
return segs.join('-')
}
export const handleRowOperation = (hot: any, type: 'above' | 'below' | 'child' | 'append' | 'delete') => {
//空白处右键 新增行
if (type === 'append') {
//const root = hot.getSourceData() as any[]
// 使用正确的 level 格式索引值从0开始
// hot.getSettings().data.push(createNode(hot.getSettings().dataSchema, "0"))
// hot.loadData(hot.getSettings().data)
// console.log('append',root,hot.getSettings().data)
const root = hot.getSourceData() as any[]
hot.alter('insert_row_below', root.length, 1, 'insert_row_alter')
console.log('append',root)
return
} }
const container = Array.isArray(parent.__children) ? parent.__children : (parent.__children = [])
const index = seg[seg.length - 1] - 1
const parentLevel = seg.slice(0, -1).join('-')
return { container, index, parentLevel }
}
// 工具函数:根据 level 查找节点
const findNodeByLevel = (data: any[], level: string) => {
const loc = getContainerAndIndexByLevel(data, level)
return loc ? (loc.container[loc.index] ?? null) : null
}
// 工具函数:重新索引 level
const reindexLevels = (container: any[], parentLevel: string | null): void => {
container.forEach((row, i) => {
const currentLevel = parentLevel == null ? String(i) : `${parentLevel}-${i + 1}`
row.level = currentLevel
if (Array.isArray(row.__children) && row.__children.length > 0) {
reindexLevels(row.__children, currentLevel)
}
})
}
// 统一的行操作处理函数
export const handleRowOperation = (hot: any, type: 'above' | 'below' | 'child' | 'delete') => {
const selected = hot.getSelected()
if (!selected || selected.length === 0) return
const row = selected[0][0]
const nestedRowsPlugin = hot.getPlugin('nestedRows')
if (!nestedRowsPlugin) return
const physicalRow = nestedRowsPlugin ? nestedRowsPlugin.collapsingUI.translateTrimmedRow(row) : hot.toPhysicalRow(row)
const rowData = hot.getSourceDataAtRow(physicalRow)
const currentLevel = String(rowData.level ?? '')
const data = JSON.parse(JSON.stringify(hot.getSettings().data))
if (type === 'delete') { if (type === 'delete') {
// 删除行 const range = getSelectedVisualRowRange(hot)
const loc = getContainerAndIndexByLevel(data, currentLevel) if (!range) return
if (!loc) return hot.alter('remove_row', range.startRow, range.amount, 'remove_row_alter')
const { container, index, parentLevel } = loc return
container.splice(index, 1)
reindexLevels(container, parentLevel)
} else {
// 根据 columns 配置动态生成 newRow 对象结构
const columns = hot.getSettings().columns || []
const newRow: any = {
level: null,
__children: []
}
// 根据 columns 的 data 字段生成对象结构
columns.forEach((col: any) => {
if (col.data && col.data !== 'level' && col.data !== '__children') {
newRow[col.data] = null
}
})
if (type === 'child') {
// 添加子行
const parentNode = findNodeByLevel(data, currentLevel)
if (!parentNode) return
if (!Array.isArray(parentNode.__children)) parentNode.__children = []
const nextIndex = parentNode.__children.length + 1
newRow.level = `${currentLevel}-${nextIndex}`
parentNode.__children.push(newRow)
} else {
// 在上方或下方插入行
const loc = getContainerAndIndexByLevel(data, currentLevel)
if (!loc) return
const { container, index, parentLevel } = loc
const insertIndex = type === 'above' ? Math.max(index, 0) : Math.max(index + 1, 0)
container.splice(insertIndex, 0, newRow)
reindexLevels(container, parentLevel)
}
} }
hot.updateSettings({ data }) const sel = hot.getSelected()
// nestedRowsPlugin.headersUI.updateRowHeaderWidth() if (!sel?.length) return
hot.render() const plugin = hot.getPlugin('nestedRows')
if (!plugin) return
const dm = plugin.dataManager
const row = plugin.collapsingUI.translateTrimmedRow(sel[0][0])
const target = dm.getDataObject(row)
const parent = dm.getRowParent(row)
const root = dm.getRawSourceData() as any[]
console.log('target',target)
if (type === 'child') {
const base = target.level && typeof target.level === 'string' ? target.level : computeRowPath(target, dm, root)
const next = (target.__children?.length ?? 0) + 1
;(target.__children ??= []).push(createNode(hot.getSettings().dataSchema,`${base}-${next}`))
hot.loadData(root)
return
}
const list = parent ? parent.__children : root
const pos = dm.getRowIndexWithinParent(row)
const lvl = String(dm.getRowLevel(row) ?? 0)
list.splice(type === 'above' ? pos : pos + 1, 0, createNode(hot.getSettings().dataSchema,lvl))
hot.loadData(root)
} }

View File

@@ -0,0 +1,140 @@
type TreeLinePaint = {
key: string
width: string
backgroundImage: string
backgroundSize: string
backgroundPosition: string
}
let treeLineCache = new WeakMap<any, { key: string; hasChildren: boolean }>()
const treeLinePaintCache = new Map<string, TreeLinePaint>()
export const resetTreeLineCaches = () => {
treeLineCache = new WeakMap<any, { key: string; hasChildren: boolean }>()
treeLinePaintCache.clear()
}
export const getTreeLine = (node: any, dataManager: any, root: any[]) => {
const cached = treeLineCache.get(node)
if (cached) return cached
const level = Math.max(0, dataManager.getRowLevel(node) ?? 0)
const hasChildren = !!(node && Array.isArray(node.__children) && node.__children.length)
if (level <= 0) {
const next = { key: '0', hasChildren }
treeLineCache.set(node, next)
return next
}
const isLast = (n: any) => {
const parent = dataManager.getRowParent(n)
const siblings = (parent ? parent.__children : root) as any[] | undefined
if (!Array.isArray(siblings) || siblings.length === 0) return true
return siblings[siblings.length - 1] === n
}
const flags: boolean[] = []
let parent = dataManager.getRowParent(node)
while (parent && (dataManager.getRowLevel(parent) ?? 0) > 0) {
flags.push(!isLast(parent))
parent = dataManager.getRowParent(parent)
}
flags.reverse()
const key = `${level}|${isLast(node) ? 1 : 0}|${flags.map(v => (v ? '1' : '0')).join('')}`
const next = { key, hasChildren }
treeLineCache.set(node, next)
return next
}
export const getTreeLinePaint = (key: string) => {
const cached = treeLinePaintCache.get(key)
if (cached) return cached
if (key === '0') {
const empty = { key, width: '0px', backgroundImage: '', backgroundSize: '', backgroundPosition: '' }
treeLinePaintCache.set(key, empty)
return empty
}
const [levelStr, isLastStr, flagsStr] = key.split('|')
const level = Number(levelStr)
const isLast = isLastStr === '1'
const flags = flagsStr ? flagsStr.split('').map(v => v === '1') : []
const color = 'var(--ht-tree-line-color)'
const width = 'var(--ht-tree-line-width)'
const indent = 'var(--ht-tree-indent)'
const images: string[] = []
const sizes: string[] = []
const positions: string[] = []
for (let i = 0; i < flags.length; i++) {
if (!flags[i]) continue
images.push(`linear-gradient(${color}, ${color})`)
sizes.push(`${width} 100%`)
positions.push(`calc(${indent} * ${i} + (${indent} / 2)) 0`)
}
const selfDepth = level - 1
images.push(`linear-gradient(${color}, ${color})`)
sizes.push(`${width} ${isLast ? '50%' : '100%'}`)
positions.push(`calc(${indent} * ${selfDepth} + (${indent} / 2)) 0`)
images.push(`linear-gradient(to right, ${color}, ${color})`)
sizes.push(`calc(${indent} / 2) ${width}`)
positions.push(`calc(${indent} * ${selfDepth} + (${indent} / 2)) 50%`)
const paint = {
key,
width: `calc(var(--ht-tree-indent) * ${level})`,
backgroundImage: images.join(', '),
backgroundSize: sizes.join(', '),
backgroundPosition: positions.join(', '),
}
treeLinePaintCache.set(key, paint)
return paint
}
export const getTreeCellDom = (TD: HTMLTableCellElement) => {
const currentTreeCell = TD.firstElementChild as HTMLElement | null
if (currentTreeCell && currentTreeCell.classList.contains('ht_treeCell')) {
const indentLayer = currentTreeCell.firstElementChild as HTMLElement
const content = currentTreeCell.lastElementChild as HTMLElement
return {
treeCell: currentTreeCell,
indentLayer,
content,
toggleEl: content.firstElementChild as HTMLElement,
textEl: content.lastElementChild as HTMLElement,
}
}
const treeCell = document.createElement('div')
treeCell.className = 'ht_treeCell'
const indentLayer = document.createElement('span')
indentLayer.className = 'ht_treeIndentLayer'
const content = document.createElement('div')
content.className = 'ht_treeContent'
const toggleSpacer = document.createElement('span')
toggleSpacer.className = 'ht_treeToggleSpacer'
const text = document.createElement('span')
text.className = 'rowHeader'
content.appendChild(toggleSpacer)
content.appendChild(text)
treeCell.appendChild(indentLayer)
treeCell.appendChild(content)
TD.replaceChildren(treeCell)
return {
treeCell,
indentLayer,
content,
toggleEl: toggleSpacer,
textEl: text,
}
}

View File

@@ -0,0 +1,89 @@
// 验证行
export const validatorRow = (_this: any,changes: any) => {
// 获取列配置
const columns = _this.getSettings().columns;
// 收集所有需要验证的行(去重)
const rowsToValidate = new Set<number>();
for (let i = 0; i < changes.length; i++) {
const currChange = changes[i];
const row = currChange[0]; // 当前修改的行索引
const prop = currChange[1]; // 当前修改的列(属性名)
const oldValue = currChange[2]; // 修改前的值
const newValue = currChange[3]; // 修改后的值
// console.log(`行${row}, 列${prop}, 从 ${oldValue} 改为 ${newValue}`)
// 将当前行加入待验证列表
rowsToValidate.add(row);
}
let hasEmptyCell = false
// 验证所有受影响的行
for (const row of rowsToValidate) {
// console.log(`验证第 ${row} 行的所有必填列`)
// 遍历所有列,验证 required: true 的列
columns.forEach((columnConfig: any, colIndex: number) => {
if (columnConfig.required === true) {
// 获取当前单元格的值
const cellValue = _this.getDataAtCell(row, colIndex);
// 检查值是否为空null、undefined、空字符串
let isEmpty = false
// 对于 使用 db-dropdown renderer需要特殊处理
if (columnConfig.renderer === 'db-dropdown' || columnConfig.renderer === 'db-duplicate') {
// 检查值是否为空或不在 source 列表中
isEmpty = cellValue === null || cellValue === undefined || cellValue === ''
// 如果有值,还需要验证是否在允许的选项中
if (!isEmpty && Array.isArray(columnConfig.source)) {
const validValues = columnConfig.source.map((opt: any) =>
typeof opt === 'object' && opt !== null ? opt.value : opt
)
isEmpty = !validValues.includes(cellValue)
}
} else {
// 其他列的常规验证
isEmpty = cellValue === null || cellValue === undefined || cellValue === ''
}
if (isEmpty) {
// 值为空,标记为无效
_this.setCellMeta(row, colIndex, 'valid', false);
hasEmptyCell = true
// console.log(` 列 ${columnConfig.data} (索引${colIndex}) 值为空,标记为无效`);
} else {
// 对于 numeric 类型,额外验证是否为有效数字
if (columnConfig.type === 'numeric') {
const numValue = Number(cellValue)
if (isNaN(numValue)) {
// 不是有效数字,标记为无效
_this.setCellMeta(row, colIndex, 'valid', false);
hasEmptyCell = true
// console.log(` 列 ${columnConfig.data} (索引${colIndex}) 不是有效数字,标记为无效`);
} else {
// 是有效数字,标记为有效
_this.setCellMeta(row, colIndex, 'valid', true);
// console.log(` 列 ${columnConfig.data} (索引${colIndex}) 是有效数字,标记为有效`);
}
} else {
// 值不为空,标记为有效
_this.setCellMeta(row, colIndex, 'valid', true);
//console.log(` 列 ${columnConfig.data} (索引${colIndex}) 值不为空,标记为有效`);
}
}
}
});
}
// 重新渲染表格以显示验证状态
_this.render();
// 如果有空单元格,提前返回,不执行后续操作
if(hasEmptyCell){
console.log('存在空单元格,验证失败,不执行后续操作')
return false
}
return true
}

View File

@@ -1,6 +1,7 @@
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { ref } from 'vue' import { ref } from 'vue'
import type { DropdownInstance, TreeV2Instance } from 'element-plus' import type { DropdownInstance, TreeV2Instance } from 'element-plus'
import { contextMenuManager } from '../db-hst/contextMenuManager'
interface NodeBase<T> { id: string; label: string; children?: T[] } interface NodeBase<T> { id: string; label: string; children?: T[] }
type NodeType<T> = T & NodeBase<T> type NodeType<T> = T & NodeBase<T>
@@ -31,11 +32,19 @@ interface LevelConfig {
addKey?: string addKey?: string
addText?: string addText?: string
allowDelete?: boolean allowDelete?: boolean
condition?: (node: any) => boolean // 添加条件判断函数,用于控制是否显示添加菜单
sort?: string // 排序字段名,如 'sortOrder'
customMenuItems?: Array<{ key: string; text: string; condition?: (node: any) => boolean }> // 自定义菜单项
onAdd?: (parentNode: any, newNode: any, allChildren: any[]) => void | Promise<void>
onDelete?: (node: any) => void | Promise<void>
onCustomCommand?: (cmd: string, node: any) => void | Promise<void> // 自定义命令处理
} }
interface HierarchyConfig { interface HierarchyConfig {
rootKey: string rootKey: string
rootText: string rootText: string
onRootAdd?: (newNode: any, allRootNodes: any[]) => void | Promise<void>
onRootDelete?: (node: any) => void | Promise<void>
levels: LevelConfig[] levels: LevelConfig[]
} }
@@ -73,7 +82,9 @@ class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
} }
const depth = getDepth(node, ctx) const depth = getDepth(node, ctx)
console.log('getMenuItems - 节点:', node, '深度:', depth)
const levelConfig = this.config.levels.find(l => l.depth === depth) const levelConfig = this.config.levels.find(l => l.depth === depth)
console.log('找到的 levelConfig:', levelConfig)
if (!levelConfig) { if (!levelConfig) {
// 未配置的层级,只显示删除 // 未配置的层级,只显示删除
@@ -82,9 +93,26 @@ class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
const items: MenuItem[] = [] const items: MenuItem[] = []
// 添加子级菜单项 // 添加子级菜单项(检查条件)
if (levelConfig.addKey && levelConfig.addText) { if (levelConfig.addKey && levelConfig.addText) {
items.push({ key: levelConfig.addKey, text: levelConfig.addText }) console.log('检查 addKey 条件, levelConfig.condition:', levelConfig.condition)
// 如果有条件函数,检查是否满足条件
if (!levelConfig.condition || levelConfig.condition(node)) {
console.log('条件满足,添加菜单项:', levelConfig.addText)
items.push({ key: levelConfig.addKey, text: levelConfig.addText })
} else {
console.log('条件不满足,不添加菜单项')
}
}
// 添加自定义菜单项
if (levelConfig.customMenuItems) {
for (const customItem of levelConfig.customMenuItems) {
// 如果有条件函数,检查是否满足条件
if (!customItem.condition || customItem.condition(node)) {
items.push({ key: customItem.key, text: customItem.text })
}
}
} }
// 删除菜单项 // 删除菜单项
@@ -100,7 +128,26 @@ class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
if (!node && cmd === this.config.rootKey) { if (!node && cmd === this.config.rootKey) {
const next = ctx.createNode('root') const next = ctx.createNode('root')
next.label = this.config.rootText.replace('添加', '') next.label = this.config.rootText.replace('添加', '')
// 先添加到数据中
ctx.setData([...ctx.dataRef.value, next]) ctx.setData([...ctx.dataRef.value, next])
// 调用根节点添加回调,传入所有根节点数据
if (this.config.onRootAdd) {
try {
await this.config.onRootAdd(next, ctx.dataRef.value)
// 回调完成后,重新设置数据以确保更新
ctx.setData([...ctx.dataRef.value])
} catch (error) {
// 如果回调失败,移除刚添加的节点
const index = ctx.dataRef.value.findIndex(n => n.id === next.id)
if (index !== -1) {
ctx.dataRef.value.splice(index, 1)
ctx.setData([...ctx.dataRef.value])
}
throw error
}
}
return return
} }
@@ -110,29 +157,108 @@ class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
if (cmd === 'delete') { if (cmd === 'delete') {
const target = ctx.locate(node.id) const target = ctx.locate(node.id)
if (!target) return if (!target) return
// 查找当前节点的层级配置
const depth = getDepth(node, ctx)
const levelConfig = this.config.levels.find(l => l.depth === depth)
// 如果是根节点depth === -1 或 parent === null调用根节点删除回调
if (!target.parent && this.config.onRootDelete) {
await this.config.onRootDelete(node)
} else if (levelConfig?.onDelete) {
// 否则调用层级删除回调
await levelConfig.onDelete(node)
}
target.container.splice(target.index, 1) target.container.splice(target.index, 1)
ctx.setData([...ctx.dataRef.value]) ctx.setData([...ctx.dataRef.value])
return return
} }
// 查找匹配的层级配置 // 查找匹配的层级配置
const depth = getDepth(node, ctx) const depth = getDepth(node, ctx)
const levelConfig = this.config.levels.find(l => l.depth === depth && l.addKey === cmd) const levelConfig = this.config.levels.find(l => l.depth === depth && l.addKey === cmd)
if (levelConfig) {
const target = ctx.locate(node.id)
if (!target) return
const next = ctx.createNode(node.id) if (levelConfig) {
next.label = levelConfig.addText?.replace('添加', '') || '新目录' const target = ctx.locate(node.id)
if (!target) return
target.node.children = target.node.children || []
// 如果配置了排序字段,先对现有子节点排序
if (levelConfig.sort) {
const sortField = levelConfig.sort
target.node.children.sort((a: any, b: any) => {
const aVal = a[sortField] ?? 0
const bVal = b[sortField] ?? 0
return aVal - bVal
})
// 计算新节点的排序号(取最大值 + 1
const maxSort = target.node.children.reduce((max: number, child: any) => {
const childSort = child[sortField] ?? 0
return Math.max(max, childSort)
}, 0)
const next = ctx.createNode(node.id)
next.label = levelConfig.addText?.replace('添加', '') || '新目录'
;(next as any)[sortField] = maxSort + 1
target.node.children.push(next)
ctx.setData([...ctx.dataRef.value])
ctx.expandNode(target.node.id)
// 调用添加回调,传递父节点的所有子节点
if (levelConfig.onAdd) {
try {
await levelConfig.onAdd(node, next, target.node.children)
// 回调完成后,重新设置数据以确保更新
ctx.setData([...ctx.dataRef.value])
} catch (error) {
// 如果回调失败,移除刚添加的节点
const index = target.node.children!.findIndex(n => n.id === next.id)
if (index !== -1) {
target.node.children!.splice(index, 1)
ctx.setData([...ctx.dataRef.value])
}
throw error
}
}
} else {
// 没有配置排序字段,使用原逻辑
const next = ctx.createNode(node.id)
next.label = levelConfig.addText?.replace('添加', '') || '新目录'
target.node.children.push(next)
ctx.setData([...ctx.dataRef.value])
ctx.expandNode(target.node.id)
// 调用添加回调,传递父节点的所有子节点
if (levelConfig.onAdd) {
try {
await levelConfig.onAdd(node, next, target.node.children)
// 回调完成后,重新设置数据以确保更新
ctx.setData([...ctx.dataRef.value])
} catch (error) {
// 如果回调失败,移除刚添加的节点
const index = target.node.children!.findIndex(n => n.id === next.id)
if (index !== -1) {
target.node.children!.splice(index, 1)
ctx.setData([...ctx.dataRef.value])
}
throw error
}
}
}
return
}
target.node.children = target.node.children || [] // 处理自定义命令
target.node.children.push(next) const customLevelConfig = this.config.levels.find(l => l.depth === getDepth(node, ctx))
ctx.setData([...ctx.dataRef.value]) if (customLevelConfig?.onCustomCommand) {
ctx.expandNode(target.node.id) await customLevelConfig.onCustomCommand(cmd, node)
ctx.setCurrentKey(next.id) }
} }
}
} }
/** /**
@@ -170,7 +296,7 @@ class DefaultContextMenuHandler<T> implements ContextMenuHandler<T> {
target.node.children.push(ctx.createNode(target.node.id)) target.node.children.push(ctx.createNode(target.node.id))
ctx.setData([...ctx.dataRef.value]) ctx.setData([...ctx.dataRef.value])
ctx.expandNode(target.node.id) ctx.expandNode(target.node.id)
ctx.setCurrentKey(target.node.id) // ctx.setCurrentKey(target.node.id)
return return
} }
if (cmd === 'rename') { ctx.startEdit(node); return } if (cmd === 'rename') { ctx.startEdit(node); return }
@@ -191,10 +317,22 @@ class DbTreeContextMenu<T> {
private config: ContextMenuConfig<T> private config: ContextMenuConfig<T>
private handler: ContextMenuHandler<T> private handler: ContextMenuHandler<T>
private unregister: (() => void) | null = null
constructor(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T>) { constructor(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T>) {
this.config = config this.config = config
this.handler = handler ?? new DefaultContextMenuHandler<T>() this.handler = handler ?? new DefaultContextMenuHandler<T>()
// 注册到全局菜单管理器
this.unregister = contextMenuManager.register(this.closeContextMenu)
}
destroy() {
// 取消注册
if (this.unregister) {
this.unregister()
this.unregister = null
}
} }
private createNode = (prefix: string): NodeType<T> => { private createNode = (prefix: string): NodeType<T> => {
@@ -223,18 +361,30 @@ class DbTreeContextMenu<T> {
getMenuItems = (): MenuItem[] => this.handler.getMenuItems(this.currentNode.value, this.ctx()) getMenuItems = (): MenuItem[] => this.handler.getMenuItems(this.currentNode.value, this.ctx())
openContextMenu = (event: MouseEvent, nodeData: NodeType<T>) => { openContextMenu = (event: MouseEvent, nodeData: NodeType<T>) => {
const { clientX, clientY } = event // console.log('openContextMenu',nodeData)
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY }) // 通知管理器即将打开新菜单,关闭其他菜单
contextMenuManager.notifyOpening(this.closeContextMenu)
event.preventDefault() event.preventDefault()
const { clientX, clientY } = event
this.currentNode.value = nodeData this.currentNode.value = nodeData
const items = this.handler.getMenuItems(this.currentNode.value, this.ctx())
if (!items.length) return
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
this.dropdownRef.value?.handleOpen() this.dropdownRef.value?.handleOpen()
} }
openBlankContextMenu = (event: MouseEvent) => { openBlankContextMenu = (event: MouseEvent) => {
const { clientX, clientY } = event // console.log('openBlankContextMenu')
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY }) // 通知管理器即将打开新菜单,关闭其他菜单
contextMenuManager.notifyOpening(this.closeContextMenu)
event.preventDefault() event.preventDefault()
const { clientX, clientY } = event
this.currentNode.value = null this.currentNode.value = null
const items = this.handler.getMenuItems(this.currentNode.value, this.ctx())
if (!items.length) return
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
this.dropdownRef.value?.handleOpen() this.dropdownRef.value?.handleOpen()
} }

View File

@@ -7,11 +7,12 @@
placeholder="请输入关键字" placeholder="请输入关键字"
@input="onQueryChanged" @input="onQueryChanged"
/> />
<!-- @contextmenu="openBlankContextMenu" -->
<ElTreeV2 <ElTreeV2
class="treeLine-2" class="treeLine-2"
:indent="0" :indent="0"
ref="treeRef" ref="treeRef"
style="max-width: 600px"
:data="data" :data="data"
:props="props" :props="props"
:filter-method="filterMethod" :filter-method="filterMethod"
@@ -19,49 +20,64 @@
:height="treeHeight" :height="treeHeight"
@node-expand="onNodeExpand" @node-expand="onNodeExpand"
@node-collapse="onNodeCollapse" @node-collapse="onNodeCollapse"
@contextmenu="openBlankContextMenu" @contextmenu="handleContextMenu"
@nodeContextmenu="handleNodeContextMenu"
highlight-current highlight-current
scrollbar-always-on scrollbar-always-on
:expand-on-click-node="false"
@nodeClick="onNodeSingleClick"
> >
<template #default="{ node, data: nodeData }"> <template #default="{ node, data: nodeData }">
<!-- 根据层级生成占位符level 1 不需要占位 --> <!-- 根据层级生成占位符level 1 不需要占位 -->
<!-- :style="{ paddingLeft: node.isLeaf ? '0px' : '0px' }" -->
<span v-for="i in (node.level - 1)" :key="i" class="node_nestingLevel_empty"></span> <span v-for="i in (node.level - 1)" :key="i" class="node_nestingLevel_empty"></span>
<div class="node-content-wrapper" :style="{ paddingLeft: node.isLeaf ? '0px' : '11px' }"> <div class="node-content-wrapper" :style="{ paddingLeft: '5px' }">
<span class="node-icon-wrapper"> <div class="node-left-content">
<IconifyIcon <span class="node-icon-wrapper" >
v-if="!node.isLeaf" <IconifyIcon
:icon="node.expanded ? 'ep:remove' : 'ep:circle-plus'" v-if="!node.isLeaf"
class="custom-expand-icon" :icon="node.expanded ? 'ep:remove' : 'ep:circle-plus'"
/> class="custom-expand-icon"
</span> @click.stop="onNodeIconWrapperClick(node)"
<template v-if="editingId === nodeData.id"> />
<ElInput
:id="`edit-${nodeData.id}`"
v-model="editingLabel"
@blur="saveEdit"
@keydown.enter.prevent="saveEdit"
@keydown.esc.prevent="cancelEdit"
@click.stop
size="small"
class="node-edit-input"
/>
</template>
<template v-else>
<span
class="node-label"
:style="getNodeStyles(nodeData)"
draggable="true"
@dragstart="onDragStart(nodeData, $event)"
@dragover.prevent="onDragOver(nodeData, $event)"
@drop.prevent="onDrop(nodeData, $event)"
@dragend="onDragEnd"
@contextmenu.stop="(e) => openContextMenu(e, nodeData)"
@click="onNodeSingleClick(nodeData, $event)"
@dblclick.stop="onNodeDblClick(nodeData, $event)"
>
{{ nodeData.label }}
</span> </span>
</template> <template v-if="editingId === nodeData.id">
<ElInput
:id="`edit-${nodeData.id}`"
v-model="editingLabel"
@blur="saveEdit"
@keydown.enter.prevent="saveEdit"
@keydown.esc.prevent="cancelEdit"
@click.stop
size="small"
class="node-edit-input"
/>
</template>
<template v-else>
<!-- @contextmenu.stop="(e) => openContextMenu(e, nodeData)" -->
<span
class="node-label"
:style="getNodeStyles(nodeData)"
draggable="true"
@dragstart="onDragStart(nodeData, $event)"
@dragover.prevent="onDragOver(nodeData, $event)"
@drop.prevent="onDrop(nodeData, $event)"
@dragend="onDragEnd"
@dblclick.stop="onNodeDblClick(nodeData, $event)"
>
{{ nodeData.label }}
</span>
</template>
</div>
<!-- 默认是type="info"有值时type="primary" -->
<el-button
v-if="node.isLeaf && shouldShowDecideButton(nodeData)"
:type="nodeData.selected?'primary':'info'"
circle
size="small"
:class="['decide-button', { 'decide-button-highlight': isDecideHighlighted(nodeData) }]"
@click.stop="onDecideClick(nodeData, $event)"
>{{ decideText }}</el-button>
</div> </div>
</template> </template>
</ElTreeV2> </ElTreeV2>
@@ -91,18 +107,27 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, nextTick, computed, watch } from 'vue'
import { ElTreeV2, ElDropdown, ElDropdownMenu, ElDropdownItem, ElInput } from 'element-plus'
import type { TreeNodeData, TreeV2Instance } from 'element-plus'
import { IconifyIcon } from '@vben/icons' import { IconifyIcon } from '@vben/icons'
import { useContextMenu } from './contextMenu' import type { TreeNodeData, TreeV2Instance } from 'element-plus'
import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElInput, ElTreeV2 } from 'element-plus'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import type { ContextMenuHandler, HierarchyConfig } from './contextMenu' import type { ContextMenuHandler, HierarchyConfig } from './contextMenu'
import { useInlineEdit } from './inlineEdit' import { useContextMenu } from './contextMenu'
import { useDragAndDrop } from './draggable' import { useDragAndDrop } from './draggable'
import { useInlineEdit } from './inlineEdit'
defineOptions({ name: 'DbTree' }); defineOptions({ name: 'DbTree' });
const componentProps = defineProps<{ height?: number; data?: Tree[]; defaultExpandedKeys?: number | string | string[]; contextMenuHandler?: ContextMenuHandler<Tree> | HierarchyConfig; search?: boolean }>() const componentProps = defineProps<{
const emit = defineEmits<{ (e: 'select', node: Tree): void }>() height?: number;
data?: Tree[];
defaultExpandedKeys?: number | string | string[];
contextMenuHandler?: ContextMenuHandler<Tree> | HierarchyConfig;
search?: boolean;
decide?: boolean;
decideText?: string;
decideHighlightKey?: string;
decideFilter?: (node: Tree) => boolean; // 新增:过滤哪些节点可以显示工字按钮
}>()
const emit = defineEmits<{ (e: 'select', node: Tree): void; (e: 'edit-save', node: Tree, oldLabel: string, newLabel: string): void; (e: 'decide', node: Tree): void }>()
interface Tree { interface Tree {
id: string id: string
@@ -122,6 +147,14 @@ watch(
const treeHeight = computed(() => componentProps.height ?? 400) const treeHeight = computed(() => componentProps.height ?? 400)
const isSearchEnabled = computed(() => componentProps.search ?? true) const isSearchEnabled = computed(() => componentProps.search ?? true)
const isDecideEnabled = computed(() => componentProps.decide ?? false)
const decideText = computed(() => componentProps.decideText ?? '定')
const decideHighlightKey = computed(() => componentProps.decideHighlightKey ?? '')
const decideFilter = computed(() => componentProps.decideFilter ?? (() => true)) // 默认所有节点都可以显示
const isContextMenuEnabled = computed(() => {
// console.log(componentProps.contextMenuHandler)
return componentProps.contextMenuHandler ?? false
})
const props = { const props = {
value: 'id', value: 'id',
label: 'label', label: 'label',
@@ -173,6 +206,17 @@ const onQueryChanged = (query: string) => {
const filterMethod = (query: string, node: TreeNodeData) => const filterMethod = (query: string, node: TreeNodeData) =>
node.label!.includes(query) node.label!.includes(query)
const isDecideHighlighted = (nodeData: Tree) => {
const key = decideHighlightKey.value
if (!key) return false
return Boolean((nodeData as Record<string, any>)[key])
}
const shouldShowDecideButton = (nodeData: Tree) => {
if (!isDecideEnabled.value) return false
return decideFilter.value(nodeData)
}
type LocateResult = { node: Tree; parent: Tree | null; container: Tree[]; index: number } type LocateResult = { node: Tree; parent: Tree | null; container: Tree[]; index: number }
const locate = (id: string): LocateResult | null => { const locate = (id: string): LocateResult | null => {
@@ -187,36 +231,97 @@ const locate = (id: string): LocateResult | null => {
} }
return null return null
} }
type TreeKey = string | number
const { editingId, editingLabel, editingOriginalLabel, startEdit, saveEdit, cancelEdit } = useInlineEdit<Tree>({ dataRef: data, locate }) interface TreeNode {
key: TreeKey
level: number
parent?: TreeNode
children?: TreeNode[]
data: TreeNodeData
disabled?: boolean
label?: string
isLeaf?: boolean
expanded?: boolean
isEffectivelyChecked?: boolean
}
const onNodeIconWrapperClick = (node: TreeNode) => {
if (!treeRef.value) return
if (node.isLeaf) return
if (node.expanded) treeRef.value.collapseNode(node)
else treeRef.value.expandNode(node)
}
const handleEditSave = async (node: Tree, oldLabel: string, newLabel: string) => {
emit('edit-save', node, oldLabel, newLabel)
}
const { editingId, editingLabel, editingOriginalLabel, startEdit, saveEdit, cancelEdit } = useInlineEdit<Tree>({ dataRef: data, locate, onSave: handleEditSave })
const { dropdownRef, position, triggerRef, currentNode, openContextMenu, openBlankContextMenu, closeContextMenu, onGlobalCommand, getMenuItems } = useContextMenu<Tree>({ dataRef: data, treeRef, expandedKeysRef: expandedKeys, locate, startEdit }, componentProps.contextMenuHandler) const { dropdownRef, position, triggerRef, currentNode, openContextMenu, openBlankContextMenu, closeContextMenu, onGlobalCommand, getMenuItems } = useContextMenu<Tree>({ dataRef: data, treeRef, expandedKeysRef: expandedKeys, locate, startEdit }, componentProps.contextMenuHandler)
const { draggingId, dropState, getNodeStyles, onDragStart, onDragOver, onDrop, onDragEnd } = useDragAndDrop<Tree>({ dataRef: data, treeRef, expandedKeysRef: expandedKeys, locate }) const { draggingId, dropState, getNodeStyles, onDragStart, onDragOver, onDrop, onDragEnd } = useDragAndDrop<Tree>({ dataRef: data, treeRef, expandedKeysRef: expandedKeys, locate })
// 包装右键菜单处理函数,根据 isContextMenuEnabled 决定是否执行
const handleContextMenu = (e: MouseEvent) => {
// 始终阻止浏览器默认右键菜单
e.preventDefault()
if (isContextMenuEnabled.value) {
openBlankContextMenu(e)
}
}
const handleNodeContextMenu = (e: MouseEvent, data: Tree) => {
// 始终阻止浏览器默认右键菜单
e.preventDefault()
if (isContextMenuEnabled.value) {
openContextMenu(e, data)
}
}
//防止误触发 //防止误触发
let clickTimer: ReturnType<typeof setTimeout> | null = null let clickTimer: ReturnType<typeof setTimeout> | null = null
const clickDelayMs = 250
const triggerSingleClick = (node: Tree) => {
closeContextMenu()
emit('select', node)
}
const onNodeSingleClick = (node: Tree, e: MouseEvent) => { const onNodeSingleClick = (node: Tree, e: MouseEvent) => {
// 清除之前的定时器
if (clickTimer) { if (clickTimer) {
clearTimeout(clickTimer) clearTimeout(clickTimer)
clickTimer = null clickTimer = null
} }
// 单击延迟执行,避免与双击冲突
clickTimer = setTimeout(() => { clickTimer = setTimeout(() => {
triggerSingleClick(node) closeContextMenu()
emit('select', node)
clickTimer = null clickTimer = null
}, clickDelayMs) }, 250)
} }
const onNodeDblClick = (node: Tree, e: MouseEvent) => { const onNodeDblClick = (node: Tree, e: MouseEvent) => {
console.log('onNodeDblClick')
// 清除单击定时器,避免触发单击事件
if (clickTimer) { if (clickTimer) {
clearTimeout(clickTimer) clearTimeout(clickTimer)
clickTimer = null clickTimer = null
} }
// 直接启动编辑,不需要防抖
startEdit(node) startEdit(node)
} }
const onDecideClick = (node: Tree, e: MouseEvent) => {
// 阻止事件冒泡和默认行为
e.stopPropagation()
e.preventDefault()
// 清除可能存在的单击定时器
if (clickTimer) {
clearTimeout(clickTimer)
clickTimer = null
}
// 触发 decide 事件
emit('decide', node)
}
const onNodeExpand = (data: TreeNodeData) => { const onNodeExpand = (data: TreeNodeData) => {
const key = (data as any)[props.value] as string const key = (data as any)[props.value] as string
if (!key) return if (!key) return
@@ -233,6 +338,25 @@ const onNodeCollapse = (data: TreeNodeData) => {
} }
} }
// 暴露方法给父组件
const setCurrentKey = (key: string | number | null) => {
if (treeRef.value) {
treeRef.value.setCurrentKey(key)
}
}
defineExpose({
setCurrentKey
})
// 组件卸载时清理
onUnmounted(() => {
// 如果 contextMenu 实例有 destroy 方法,调用它
if (typeof (dropdownRef.value as any)?.destroy === 'function') {
(dropdownRef.value as any).destroy()
}
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -244,9 +368,23 @@ const onNodeCollapse = (data: TreeNodeData) => {
// gap: 8px; // gap: 8px;
} }
.treeLine-2 { .treeLine-2 {
:deep(.el-tree__empty-block) {
height: v-bind('treeHeight + "px"');
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
// 设置颜色
background-color: #e9ecfc !important; // 透明度为0.2的skyblue作者比较喜欢的颜色
color: #273fe2; // 节点的字体颜色
// font-weight: bold; // 字体加粗
box-shadow: inset 2px 0 0 0 #0000ff !important;
}
// :deep(.el-tree .el-tree-node.is-current > .el-tree-node__content) {
// box-shadow: inset 2px 0 0 0 var(--992e623a);
// }
.node_nestingLevel_empty { .node_nestingLevel_empty {
display: inline-block; display: inline-block;
padding-left: 18px; padding-left: 13px;
} }
.node_nestingLevel_empty::before { .node_nestingLevel_empty::before {
content: ''; content: '';
@@ -271,6 +409,16 @@ const onNodeCollapse = (data: TreeNodeData) => {
align-items: center; align-items: center;
gap: 0px; gap: 0px;
line-height: 1; line-height: 1;
width: 100%;
justify-content: space-between;
}
.node-left-content {
display: flex;
align-items: center;
gap: 0px;
flex: 1;
min-width: 0;
} }
.node-icon-wrapper { .node-icon-wrapper {
@@ -303,19 +451,34 @@ const onNodeCollapse = (data: TreeNodeData) => {
flex: 1; flex: 1;
min-width: 100px; min-width: 100px;
} }
.decide-button {
width: 20px !important;
height: 20px !important;
padding: 0 !important;
font-size: 12px !important;
min-width: 20px !important;
margin-left: 4px;
}
.decide-button-highlight {
background-color: #cfe8ff !important;
border-color: #cfe8ff !important;
color: #1f3b66 !important;
}
/* 选择后面还有 ht_nestingLevel_empty 的元素(不是最后一个) */ /* 选择后面还有 ht_nestingLevel_empty 的元素(不是最后一个) */
.node_nestingLevel_empty:has(+ .node_nestingLevel_empty)::after { .node_nestingLevel_empty:has(+ .node_nestingLevel_empty)::after {
/* 你的样式 */ /* 你的样式 */
width: 0px; width: 0px;
} }
:deep(.el-tree-node){ :deep(.el-tree-node){
left: -9px !important; // left: -9px !important;
} }
:deep(.el-tree-node__expand-icon){ :deep(.el-tree-node__expand-icon){
display: none !important; display: none !important;
} }
// :deep(.el-tree-node__content){ :deep(.el-tree-node .el-tree-node__content){
// display: none !important; // padding-left: 5px !important;
// } }
} }
</style> </style>

View File

@@ -7,8 +7,9 @@ type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; containe
export const useInlineEdit = <T>(params: { export const useInlineEdit = <T>(params: {
dataRef: Ref<NodeType<T>[]>; dataRef: Ref<NodeType<T>[]>;
locate: (id: string) => LocateResult<T>; locate: (id: string) => LocateResult<T>;
onSave?: (node: NodeType<T>, oldLabel: string, newLabel: string) => void | Promise<void>;
}) => { }) => {
const { dataRef, locate } = params const { dataRef, locate, onSave } = params
const editingId = ref<string | null>(null) const editingId = ref<string | null>(null)
const editingLabel = ref('') const editingLabel = ref('')
const editingOriginalLabel = ref('') const editingOriginalLabel = ref('')
@@ -24,12 +25,18 @@ export const useInlineEdit = <T>(params: {
}) })
} }
const saveEdit = () => { const saveEdit = async () => {
if (!editingId.value) return if (!editingId.value) return
const target = locate(editingId.value) const target = locate(editingId.value)
if (!target) { editingId.value = null; return } if (!target) { editingId.value = null; return }
const next = editingLabel.value.trim() const next = editingLabel.value.trim()
if (next) target.node.label = next const oldLabel = editingOriginalLabel.value
if (next && next !== oldLabel) {
target.node.label = next
if (onSave) {
await onSave(target.node, oldLabel, next)
}
}
dataRef.value = [...dataRef.value] dataRef.value = [...dataRef.value]
editingId.value = null editingId.value = null
} }

View File

@@ -0,0 +1,18 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/database/entry/add',
component: () => import('#/views/database/entry/add.vue'),
name: 'EntryAdd',
meta: {
title: '信息价录入',
icon: 'ant-design:history-outlined',
activePath: '/database/entry',
keepAlive: false,
hideInMenu: true,
},
},
];
export default routes;

View File

@@ -0,0 +1,182 @@
import { ref, nextTick } from 'vue'
import { selectCellBorderStyle } from '#/components/db-hst/base'
import { handleRowOperation, codeRenderer } from '#/components/db-hst/tree'
import Handsontable from 'handsontable'
export const createUploadPhotoRender = (callbacks: {
onPreview: (value: string) => void;
onUpload: (row: number, col: number, value: string) => void;
}) => {
return (instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) => {
td.innerHTML = '';
// 创建 flex 容器
const container = document.createElement('div');
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'space-between';
container.style.gap = '4px';
container.style.width = '100%';
container.style.height = '100%';
// 左侧图片容器
const imgContainer = document.createElement('div');
imgContainer.style.flex = '1';
imgContainer.style.display = 'flex';
imgContainer.style.alignItems = 'center';
imgContainer.style.overflow = 'hidden';
if (value) {
const img = document.createElement('img');
img.src = value
img.style.maxWidth = '25px';
img.style.maxHeight = '25px';
img.style.objectFit = 'contain';
img.style.cursor = 'pointer';
// 点击图片预览
img.addEventListener('click', (event) => {
event.stopPropagation();
callbacks.onPreview(value);
});
img.addEventListener('mousedown', (event) => {
event.preventDefault();
});
imgContainer.appendChild(img);
}
//右侧上传图标
const uploadIcon = document.createElement('span');
uploadIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M11 16V7.85l-2.6 2.6L7 9l5-5l5 5l-1.4 1.45l-2.6-2.6V16zm-5 4q-.825 0-1.412-.587T4 18v-3h2v3h12v-3h2v3q0 .825-.587 1.413T18 20z"/></svg>';
uploadIcon.style.cursor = 'pointer';
uploadIcon.style.color = '#979797';
uploadIcon.style.display = 'flex';
uploadIcon.style.alignItems = 'center';
uploadIcon.style.flexShrink = '0';
uploadIcon.addEventListener('click', (event) => {
event.stopPropagation();
callbacks.onUpload(row, col, value || '');
});
uploadIcon.addEventListener('mousedown', (event) => {
event.preventDefault();
});
container.appendChild(imgContainer);
container.appendChild(uploadIcon);
td.appendChild(container);
return td;
};
}
export const createMorePriceRender = (callbacks: {
onSelectPrice: (row: number) => void;
}) => {
return (instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) => {
td.innerHTML = '';
td.style.padding = '2px';
td.style.display = 'flex';
td.style.alignItems = 'center';
td.style.justifyContent = 'space-between';
td.style.gap = '4px';
// 左侧价格文本
const priceText = document.createElement('span');
priceText.textContent = value || '';
priceText.style.flex = '1';
priceText.style.overflow = 'hidden';
priceText.style.textOverflow = 'ellipsis';
priceText.style.whiteSpace = 'nowrap';
// 右侧选择图标
const selectIcon = document.createElement('span');
selectIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m0 16H5V5h14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"/></svg>';
selectIcon.style.cursor = 'pointer';
selectIcon.style.color = '#979797';
selectIcon.style.display = 'flex';
selectIcon.style.alignItems = 'center';
selectIcon.style.flexShrink = '0';
selectIcon.addEventListener('click', (event) => {
event.stopPropagation();
callbacks.onSelectPrice(row);
});
selectIcon.addEventListener('mousedown', (event) => {
event.preventDefault();
});
td.appendChild(priceText);
td.appendChild(selectIcon);
return td;
};
}
export const createIdCellRenderer = (callbacks: {
onShowMorePrice: (row: number, col: number, value: any) => void;
}) => {
return (
instance: any,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: string | number,
value: any,
cellProperties: any
) => {
Handsontable.renderers.TextRenderer(instance, TD, row, col, prop, value, cellProperties)
TD.classList.add('ht-id-cell')
// TD.style.textAlign = 'center'
const existingIcon = TD.querySelector('.ht-id-icon') as HTMLElement | null
if (existingIcon) existingIcon.remove()
const iconButton = document.createElement('div')
iconButton.className = 'ht-id-icon'
iconButton.title = '查看历史价格'
iconButton.innerHTML = '<svg t="1766623587261" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13644" width="16" height="16"><path d="M981.25962 291.954992a476.085215 476.085215 0 0 0-110.062711-152.296077 511.919586 511.919586 0 0 0-160.828069-102.383917 546.474158 546.474158 0 0 0-396.73768 0 511.919586 511.919586 0 0 0-162.534468 102.383917A478.218213 478.218213 0 0 0 42.74038 291.954992a451.769035 451.769035 0 0 0-42.659966 187.277249 461.154227 461.154227 0 0 0 127.979897 316.110344l23.036381 196.662441a36.260971 36.260971 0 0 0 35.834371 31.994974 36.260971 36.260971 0 0 0 17.917185-4.692596l148.45668-85.319931a547.327357 547.327357 0 0 0 358.343711-13.651189 511.919586 511.919586 0 0 0 162.534468-102.383917A478.218213 478.218213 0 0 0 981.25962 666.936089a453.902033 453.902033 0 0 0 0-374.981097z m-691.091441 243.161803a55.031355 55.031355 0 1 1 55.031356-55.031355 55.031355 55.031355 0 0 1-53.324957 55.031355z m220.125422 0a55.031355 55.031355 0 1 1 55.031356-55.031355A55.031355 55.031355 0 0 1 512 535.116795z m220.125422 0a55.031355 55.031355 0 1 1 55.031356-55.031355 55.031355 55.031355 0 0 1-53.751557 55.031355z" fill="#1a42e8" p-id="13645"></path></svg>'
iconButton.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
callbacks.onShowMorePrice(row, col, value)
})
iconButton.addEventListener('mousedown', (event) => {
event.preventDefault()
})
TD.appendChild(iconButton)
}
}
export const contextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]
export const moreContextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]

View File

@@ -0,0 +1,638 @@
<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,ElInput,ElCard, ElMessage, ElImage,ElPopover, ElDatePickerPanel,ElText } from 'element-plus';
import { useElementSize } from '@vueuse/core'
import { DbTree } from '#/components/db-tree';
import { DbHst } from '#/components/db-hst';
import type { UploadProps } from 'element-plus'
import HistoryDialog from './components/HistoryDialog.vue'
import { createDateRangeRenderer } from './index'
import { IconifyIcon } from '@vben/icons';
import { validatorRow } from '#/components/db-hst/validator'
import { createUploadPhotoRender, createMorePriceRender, createIdCellRenderer, contextMenuItems, moreContextMenuItems } from './add'
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 hstContainerRef = ref<HTMLElement | null>(null)
const { height: hstContainerHeight } = useElementSize(hstContainerRef)
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 colHeaders = ref<string[]>(topColHeaders)
const hstRef = ref<any>(null)
// 创建回调函数
const handlePhotoPreview = (value: string) => {
previewImageUrl.value = value
previewVisible.value = true
}
const handlePhotoUpload = (row: number, col: number, value: string) => {
currentUploadRow.value = row
currentUploadCol.value = col
uploadImageUrl.value = value
uploadDialogVisible.value = true
}
const handlePriceSelect = (row: number) => {
currentPriceRow.value = row
morePriceDialogVisible.value = true
}
const handleShowMorePrice = (row: number, col: number, value: any) => {
currentPriceRow.value = row
morePriceDialogVisible.value = true
console.log('查看历史价格:', { row, col, value })
}
const handleMorePriceDialogClose = () => {
morePriceDialogVisible.value = false
}
const handleSelectPrice = (row: any) => {
// 选择价格后更新表格数据
if (hstRef.value?.hotInstance) {
const colIndex = hstRef.value.hotInstance.propToCol('priceExcludeTax')
hstRef.value.hotInstance.setDataAtCell(currentPriceRow.value, colIndex, row.priceExcludeTax)
const taxRateIndex = hstRef.value.hotInstance.propToCol('taxRate')
hstRef.value.hotInstance.setDataAtCell(currentPriceRow.value, taxRateIndex, row.taxRate)
const priceIncludeTaxIndex = hstRef.value.hotInstance.propToCol('priceIncludeTax')
hstRef.value.hotInstance.setDataAtCell(currentPriceRow.value, priceIncludeTaxIndex, row.priceIncludeTax)
}
ElMessage.success('价格已更新')
handleMorePriceDialogClose()
}
// 上传处理
const handleUploadSuccess: UploadProps['onSuccess'] = (response, uploadFile) => {
// 这里假设上传成功后返回图片URL
// 实际项目中需要根据后端返回的数据结构调整
const imageUrl = URL.createObjectURL(uploadFile.raw!)
uploadImageUrl.value = imageUrl
// 更新表格数据
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.setDataAtCell(currentUploadRow.value, currentUploadCol.value, imageUrl)
}
}
const handleHistoryConfirm = (node: any) => {
console.log('确认选择的历史节点:', node)
// 这里可以根据选中的节点加载对应的表格数据
}
// 更多价格对话框相关
const morePriceDialogVisible = ref(false)
const currentPriceRow = ref<number>(0)
const morePriceTableData = ref<any[]>([
{
id: 1,
priceDate: '2024-01',
priceExcludeTax: '1250.00',
taxRate: '13',
priceIncludeTax: '1412.50'
},
{
id: 2,
priceDate: '2024-02',
priceExcludeTax: '1280.00',
taxRate: '13',
priceIncludeTax: '1446.40'
},
{
id: 3,
priceDate: '2024-03',
priceExcludeTax: '1300.00',
taxRate: '13',
priceIncludeTax: '1469.00'
}
])
// 历史信息对话框相关
const historyDialogVisible = ref(false)
const columns = ref<any[]>([
{type:'text',data:'number',title:'序号'},
{type:'text',data:'code',title:'编码'},
{type:'text',data:'name',title:'名称'},
{type:'text',data:'model',title:'型号规格'},
{type:'text',data:'unit',title:'单位',width: 50, className: 'htCenter'},
{type:'numeric',data:'priceExcludeTax',title:'除税编制价'},
{type:'numeric',data:'taxRate',title:'税率%',width: 60, renderer: createIdCellRenderer({
onShowMorePrice: handleShowMorePrice
}), className: 'ht-id-cell htCenter'},
{type:'numeric',data:'priceIncludeTax',title:'含税编制价'},
{type:'text',data:'drawing',title:'图样(仅供参考)',readOnly: true,
renderer: createUploadPhotoRender({
onPreview: handlePhotoPreview,
onUpload: handlePhotoUpload
})},
{type:'text',data:'category',title:'分类',renderer: 'db-dropdown',
source: ['措施类1', '措施类2', '措施类3', '措施类3'],
readOnly: true,},
// {type:'text',data:'servicePrice',title:'劳务单价',renderer: createMorePriceRender({
// onSelectPrice: handlePriceSelect
// })},
{type:'text',data:'remark',title:'备注'},
// {type:'text',data:'',title:'操作'},
])
// const colHeaders = ref<string[]>(topColHeaders)
const mockData = ()=>{
// 生成模拟数据
const mockData = Array.from({ length: 30 }, (_, index) => ({
code: `DTL${String(index + 1).padStart(6, '0')}`,
name: `明细项目${index + 1}`,
model: `型号${index + 1}-规格${index + 1}`,
unit: ['个', '米', '吨', '套', '台'][index % 5],
priceExcludeTax: (Math.random() * 1000 + 100).toFixed(2),
taxRate: ['13', '9', '6', '3'][index % 4],
priceIncludeTax: (Math.random() * 1200 + 120).toFixed(2),
drawing: ``,
category: ['材料', '设备', '人工', '机械'][index % 4],
servicePrice: (Math.random() * 500 + 50).toFixed(2),
remark: `备注${index + 1}`,
}))
return mockData;
}
let dbSettings = {
data:mockData(),
columns: columns.value,
contextMenu: {
items: {
row_above: {
name: '在上方插入行',
// callback: function(key: string, selection: any[]) {
// const selectedRow = selection[0].start.row
// this.alter('insert_row_above', selectedRow, 1)
// },
},
row_below: {
name: '在下方插入行',
// callback: function(key: string, selection: any[]) {
// const selectedRow = selection[0].start.row
// this.alter('insert_row_below', selectedRow, 1)
// },
},
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)
// 如果有 ID可以调用删除 API
// deleteResourceMerged(rowData.id).then(res => {
// console.log('deleteResourceMerged',res)
// })
} else {
// 没有 ID 直接删除
this.alter('remove_row', selectedRow, 1)
}
},
}
}
},
afterChange(changes, source) {
console.log('source',source)
if (!changes || source === 'loadData' || source === 'updateId') return
// 如果有空单元格,提前返回,不执行后续操作
if(!validatorRow(this, changes)){
return
}
//console.log('currChange', currChange)
const row = changes[0][0]; // 获取行索引
const rowData = this.getSourceDataAtRow(row)
// 构建更新数据
const sendData: any = {
// catalogItemId: catalogItemId.value,
// categoryId: categoryId,
...rowData
}
if (rowData.id == null) {
console.log('moreDbSettings-create', sendData )
// 调用创建接口
// createResourceItems(sendData).then(res => {
// console.log('createResourceItems', res)
// // 更新当前行ID - 使用自定义source避免循环
// this.setDataAtRowProp(row, 'id', res, 'updateId');
// ElMessage.success('新增成功')
// }).catch(err => {
// console.error('新增失败', err)
// // ElMessage.error('新增失败')
// })
} else {
// 调用更新接口
console.log('moreDbSettings-update', sendData)
// 实现更新接口调用
// updateResourceItems(sendData).then(res => {
// console.log('updateResourceItems', res)
// }).catch(err => {
// console.error('更新失败', err)
// // ElMessage.error('更新失败')
// })
}
}
}
// 上传对话框相关
const uploadDialogVisible = ref(false)
const currentUploadRow = ref<number>(0)
const currentUploadCol = ref<number>(0)
const uploadImageUrl = ref<string>('')
// 图片预览相关
const previewImageUrl = ref<string>('')
const previewVisible = ref(false)
const handleBeforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
if (!rawFile.type.startsWith('image/')) {
console.error('只能上传图片文件!')
return false
}
if (rawFile.size / 1024 / 1024 > 5) {
console.error('图片大小不能超过 5MB!')
return false
}
return true
}
const handleUploadChange: UploadProps['onChange'] = (uploadFile) => {
if (uploadFile.raw) {
const imageUrl = URL.createObjectURL(uploadFile.raw)
uploadImageUrl.value = imageUrl
// 更新表格数据
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.setDataAtCell(currentUploadRow.value, currentUploadCol.value, imageUrl)
}
handleDialogClose()
}
}
const handleRemoveImage = () => {
uploadImageUrl.value = ''
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.setDataAtCell(currentUploadRow.value, currentUploadCol.value, '')
}
}
const handleDialogClose = () => {
uploadDialogVisible.value = false
}
const handlePreviewClose = () => {
previewVisible.value = false
previewImageUrl.value = ''
}
const categoryHandleSelect = (node: Tree) => {
console.log('categoryhandleSelect',node)
}
const detailHandleSelect = (node: Tree) => {
// if (topHstRef.value && typeof topHstRef.value.loadData === 'function') {
// // console.log('hstData.value',hstData.value)
// // topHstRef.value.loadData(topHstData.value)
// }
}
const search = ref()
const handleQuery = () => {
console.log('handleQuery')
}
const moreHstRef = ref()
// Popover 相关
const popoverDateRangeRef = ref()
const visibleDateRange = ref(false)
const popoverDate = ref<[Date, Date]>()
const currentEditingRow = ref()
const handleCalendarChange = (val: any) =>{
nextTick(()=>{
console.log('handleCalendarChange',val, popoverDate.value)
if(val[0] != null && val[1] != null){
visibleDateRange.value = false
setTimeout(() => {
// 更新当前行的数据
if (currentEditingRow.value >= 0 && popoverDate.value && hstRef.value?.hotInstance) {
const [startDate, endDate] = popoverDate.value
const dateRangeStr = `${startDate} ~ ${endDate}`
// 更新 Handsontable 中的数据
moreHstRef.value.hotInstance.setDataAtRowProp(currentEditingRow.value, 'time', dateRangeStr)
console.log(`已更新第 ${currentEditingRow.value} 行的价格时间段为: ${dateRangeStr}`)
}
}, 200);
}
})
}
// 创建更多价格时间段渲染器
const dateRangeRenderer = createDateRangeRenderer({
onDateIconClick: (row: number, data: any, event: MouseEvent) => {
console.log('时间图标点击:', data)
// 先关闭 popover
visibleDateRange.value = false
// 使用 nextTick 确保 DOM 更新后再打开
nextTick(() => {
currentEditingRow.value = row
popoverDateRangeRef.value = event.target
visibleDateRange.value = true
})
}
})
const moreColumns = ref<any[]>([
{type:'text',data:'time',title:'价格时间段',width: 200, renderer: dateRangeRenderer},
{type:'numeric',data:'priceExcludeTax',title:'除税单价'},
{type:'numeric',data:'taxRate',title:'税率%',width: 60},
{type:'numeric',data:'priceIncludeTax',title:'含税单价'},
])
let moreDbSettings = {
data: [],
columns: moreColumns.value,
height: 300,
contextMenu: {
items: {
row_above: {
name: '在上方插入行',
// callback: function(key: string, selection: any[]) {
// const selectedRow = selection[0].start.row
// this.alter('insert_row_above', selectedRow, 1)
// },
},
row_below: {
name: '在下方插入行',
// callback: function(key: string, selection: any[]) {
// const selectedRow = selection[0].start.row
// this.alter('insert_row_below', selectedRow, 1)
// },
},
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)
// 如果有 ID可以调用删除 API
// deleteResourceMerged(rowData.id).then(res => {
// console.log('deleteResourceMerged',res)
// })
} else {
// 没有 ID 直接删除
this.alter('remove_row', selectedRow, 1)
}
},
}
}
},
afterChange(changes, source) {
console.log('source',source)
if (!changes || source === 'loadData' || source === 'updateId') return
// 如果有空单元格,提前返回,不执行后续操作
if(!validatorRow(this, changes)){
return
}
//console.log('currChange', currChange)
const row = changes[0][0]; // 获取行索引
const rowData = this.getSourceDataAtRow(row)
// 构建更新数据
const sendData: any = {
// catalogItemId: catalogItemId.value,
// categoryId: categoryId,
...rowData
}
if (rowData.id == null) {
console.log('moreDbSettings-create', sendData )
// 调用创建接口
// createResourceItems(sendData).then(res => {
// console.log('createResourceItems', res)
// // 更新当前行ID - 使用自定义source避免循环
// this.setDataAtRowProp(row, 'id', res, 'updateId');
// ElMessage.success('新增成功')
// }).catch(err => {
// console.error('新增失败', err)
// // ElMessage.error('新增失败')
// })
} else {
// 调用更新接口
console.log('moreDbSettings-update', sendData)
// 实现更新接口调用
// updateResourceItems(sendData).then(res => {
// console.log('updateResourceItems', res)
// }).catch(err => {
// console.error('更新失败', err)
// // ElMessage.error('更新失败')
// })
}
}
}
function onBottomHeight(height: number){
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.updateSettings({
height: height
})
// hstRef.value.loadData(bootomMock())
hstRef.value.hotInstance.render()
console.log('onResizeEnd-hstRef',height);
}
}
onMounted(() => {
setTimeout(() => {
onBottomHeight(hstContainerHeight.value)
}, 200);
})
onUnmounted(() => {
})
</script>
<template>
<Page auto-content-height>
<ElCard class="w-full border-radius-0" body-class="!p-0" header-class="!p-1">
<template #header>
<div class="card-header">
<ElText class="mx-1">信息价名称: 珠海市</ElText>
<!-- <span>信息价名称: 珠海市</span> -->
<ElInput
v-model="search"
style="width: 300px"
placeholder="请输入"
class="input-with-select"
size="small"
>
<template #append>
<ElButton @click="handleQuery" size="small"><IconifyIcon icon="ep:search"/></ElButton>
</template>
</ElInput>
<ElButton @click="historyDialogVisible = true" type="primary" size="small" style="float: right;">调用历史信息</ElButton>
</div>
</template>
</ElCard>
<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>
</ElSplitterPanel>
<ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="hstContainerRef">
<DbHst ref="hstRef" :settings="dbSettings" :contextMenuItems="contextMenuItems"></DbHst>
</ElCard>
</ElSplitterPanel>
</ElSplitter>
<!-- 历史信息对话框 -->
<HistoryDialog
v-model:visible="historyDialogVisible"
@confirm="handleHistoryConfirm"
/>
<!-- 上传图片对话框 -->
<ElDialog
v-model="uploadDialogVisible"
title="上传图片"
width="500px"
@close="handleDialogClose"
>
<div class="upload-container">
<!-- <div v-if="uploadImageUrl" class="image-preview">
<img :src="uploadImageUrl" alt="预览图片" />
<ElButton type="danger" size="small" @click="handleRemoveImage" class="remove-btn">
删除图片
</ElButton>
</div> -->
<ElUpload
class="upload-demo"
drag
:auto-upload="false"
:show-file-list="false"
:before-upload="handleBeforeUpload"
:on-change="handleUploadChange"
accept="image/*"
>
<div class="flex flex-col items-center justify-center py-5">
<IconifyIcon icon="ep:upload-filled" class="mb-2.5 text-gray-400" style="font-size: 2.75rem !important;"/>
<div class="ant-upload-text text-base text-gray-400">
拖拽文件至此或者
<em class="cursor-pointer not-italic text-blue-500">
选择文件
</em>
</div>
<div class="mt-2.5 text-sm text-gray-400">
已支持 pg/png 每个文件不超过
1 MB
</div>
</div>
</ElUpload>
</div>
<template #footer>
<ElButton @click="handleDialogClose">关闭</ElButton>
</template>
</ElDialog>
<!-- 图片预览对话框 -->
<ElDialog
v-model="previewVisible"
title="图片预览"
width="800px"
@close="handlePreviewClose"
>
<div class="preview-image-container">
<ElImage
:src="previewImageUrl"
fit="contain"
:preview-src-list="[previewImageUrl]"
:initial-index="0"
style="width: 100%; max-height: 600px;"
/>
</div>
<template #footer>
<ElButton @click="handlePreviewClose">关闭</ElButton>
</template>
</ElDialog>
<!-- 更多价格对话框 -->
<ElDialog
v-model="morePriceDialogVisible"
title="历史价格信息"
width="700px"
@close="handleMorePriceDialogClose"
>
<div class="more-price-container">
<DbHst ref="moreHstRef" :settings="moreDbSettings" :contextMenuItems="moreContextMenuItems"></DbHst>
</div>
<template #footer>
<ElButton @click="handleMorePriceDialogClose">关闭</ElButton>
</template>
</ElDialog>
<ElPopover
:virtual-ref="popoverDateRangeRef"
virtual-triggering
:visible="visibleDateRange"
:popper-style="{padding: '0px !important'}"
:width="648"
>
<ElDatePickerPanel v-model="popoverDate" type="daterange" size="small" @calendar-change="handleCalendarChange" value-format="YYYY-MM-DD"/>
</ElPopover>
</Page>
</template>
<style lang="css">
</style>

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElDialog, ElForm, ElFormItem, ElInput, ElDatePicker, ElButton, ElMessage } from 'element-plus'
interface AddForm {
name: string
priceDate: string
}
interface AttachmentData {
id: number
name: string
priceDate: string
attachment: string
attachmentUrl: string
}
const props = defineProps<{
visible: boolean
data: AttachmentData[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:data': [value: AttachmentData[]]
}>()
const addFormRef = ref()
const addForm = ref<AddForm>({
name: '',
priceDate: ''
})
const addFormRules = {
name: [
{ required: true, message: '请输入信息价名称', trigger: 'blur' }
],
priceDate: [
{ required: true, message: '请选择价格时间', trigger: 'change' }
]
}
const handleSubmit = async () => {
if (!addFormRef.value) return
await addFormRef.value.validate((valid: boolean) => {
if (valid) {
const newId = props.data.length > 0
? Math.max(...props.data.map(item => item.id)) + 1
: 1
const newData = [...props.data, {
id: newId,
name: addForm.value.name,
priceDate: addForm.value.priceDate,
attachment: '',
attachmentUrl: ''
}]
emit('update:data', newData)
ElMessage.success('新增成功')
handleClose()
} else {
ElMessage.error('请填写完整信息')
return false
}
})
}
const handleClose = () => {
addFormRef.value?.resetFields()
addForm.value = {
name: '',
priceDate: ''
}
emit('update:visible', false)
}
</script>
<template>
<ElDialog
:model-value="visible"
title="新增信息价"
width="500px"
@update:model-value="emit('update:visible', $event)"
@close="handleClose"
>
<ElForm
ref="addFormRef"
:model="addForm"
:rules="addFormRules"
label-width="120px"
>
<ElFormItem label="信息价名称" prop="name">
<ElInput
v-model="addForm.name"
placeholder="请输入信息价名称"
clearable
/>
</ElFormItem>
<ElFormItem label="价格时间" prop="priceDate">
<ElDatePicker
v-model="addForm.priceDate"
type="daterange"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="flex justify-end gap-2">
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">确定</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped>
:deep(.el-form) {
padding: 20px 20px 0;
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElDialog, ElDatePicker, ElTable, ElTableColumn, ElButton, ElMessage } from 'element-plus'
interface AttachmentData {
id: number
name: string
priceDate: string
attachment: string
attachmentUrl: string
}
const props = defineProps<{
visible: boolean
data: AttachmentData[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:data': [value: AttachmentData[]]
}>()
const attachmentDateRange = ref<[Date, Date] | null>(null)
const filteredAttachmentData = computed(() => {
if (!attachmentDateRange.value || attachmentDateRange.value.length !== 2) {
return props.data
}
const [startDate, endDate] = attachmentDateRange.value
return props.data.filter(item => {
const itemDate = new Date(item.priceDate)
return itemDate >= startDate && itemDate <= endDate
})
})
const handleUpload = (row: AttachmentData) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.pdf,.doc,.docx,.xls,.xlsx,.zip'
input.onchange = (e: any) => {
const file = e.target.files[0]
if (file) {
const fileUrl = URL.createObjectURL(file)
row.attachment = file.name
row.attachmentUrl = fileUrl
ElMessage.success('上传成功')
}
}
input.click()
}
const handleDownload = (row: AttachmentData) => {
if (!row.attachmentUrl && !row.attachment) {
ElMessage.warning('暂无附件可下载')
return
}
if (row.attachmentUrl) {
const link = document.createElement('a')
link.href = row.attachmentUrl
link.download = row.attachment
link.click()
ElMessage.success('下载成功')
} else {
ElMessage.info('模拟下载: ' + row.attachment)
}
}
const handleDelete = (row: AttachmentData) => {
const index = props.data.findIndex(item => item.id === row.id)
if (index > -1) {
const newData = [...props.data]
newData.splice(index, 1)
emit('update:data', newData)
ElMessage.success('删除成功')
}
}
const handleClose = () => {
attachmentDateRange.value = null
emit('update:visible', false)
}
</script>
<template>
<ElDialog
:model-value="visible"
title="附件管理"
width="900px"
@update:model-value="emit('update:visible', $event)"
@close="handleClose"
>
<div class="attachment-container">
<div class="mb-4 items-center gap-2">
<span>价格时间段</span>
<ElDatePicker
v-model="attachmentDateRange"
type="daterange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 300px"
/>
</div>
<ElTable :data="filteredAttachmentData" border stripe style="width: 100%">
<ElTableColumn prop="name" label="信息价名称" width="200" />
<ElTableColumn prop="priceDate" label="价格时间段" width="150" />
<ElTableColumn prop="attachment" label="附件" min-width="200">
<template #default="{ row }">
<span v-if="row.attachment">{{ row.attachment }}</span>
<span v-else class="text-gray-400">暂无附件</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="flex gap-2">
<ElButton type="primary" size="small" link @click="handleUpload(row)">
上传
</ElButton>
<ElButton
type="success"
size="small"
@click="handleDownload(row)"
:disabled="!row.attachment"
link
>
下载
</ElButton>
<ElButton type="danger" size="small" link @click="handleDelete(row)">
删除
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
</div>
<template #footer>
<ElButton @click="handleClose">关闭</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.attachment-container {
padding: 10px 0;
}
</style>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { ElDialog, ElTable, ElTableColumn, ElButton, ElMessage } from 'element-plus'
interface CopyData {
id: number
serialNumber: string
tenantName: string
specialty: string
region: string
priceDate: string
priceName: string
priceCount: number
attachment: string
published: boolean
}
const props = defineProps<{
visible: boolean
data: CopyData[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:data': [value: CopyData[]]
}>()
const handleTogglePublish = (row: CopyData) => {
row.published = !row.published
const status = row.published ? '已发布' : '未发布'
ElMessage.success(`状态已更新为:${status}`)
}
const handleClose = () => {
emit('update:visible', false)
}
</script>
<template>
<ElDialog
:model-value="visible"
title="软件商信息价管理"
width="1200px"
@update:model-value="emit('update:visible', $event)"
@close="handleClose"
>
<div class="copy-container">
<ElTable :data="data" border stripe style="width: 100%" max-height="600">
<ElTableColumn prop="serialNumber" label="序号" width="80" fixed="left" />
<ElTableColumn prop="tenantName" label="租户名称" width="120" />
<ElTableColumn prop="specialty" label="专业" width="120" />
<ElTableColumn prop="region" label="地区" width="120" />
<ElTableColumn prop="priceDate" label="价格时间段" width="120" />
<ElTableColumn prop="priceName" label="信息价名称" width="150" />
<ElTableColumn prop="priceCount" label="信息价条数" width="120" align="center" />
<ElTableColumn prop="attachment" label="附件" min-width="150">
<template #default="{ row }">
<span v-if="row.attachment">{{ row.attachment }}</span>
<span v-else class="text-gray-400">暂无附件</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<ElButton
:type="row.published ? 'success' : 'info'"
size="small"
@click="handleTogglePublish(row)"
>
{{ row.published ? '已发布' : '未发布' }}
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</div>
<template #footer>
<ElButton @click="handleClose">关闭</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.copy-container {
padding: 10px 0;
}
</style>

View File

@@ -0,0 +1,220 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElDialog, ElRow, ElCol, ElTreeV2, ElTable, ElTableColumn, ElButton, ElSplitter, ElSplitterPanel, ElImage } from 'element-plus'
import { useElementSize } from '@vueuse/core'
import { DbTree } from '#/components/db-tree';
const containerRef = ref<HTMLElement | null>(null)
const { height: containerHeight } = useElementSize(containerRef)
interface HistoryTreeNode {
id: string
label: string
children?: HistoryTreeNode[]
}
interface HistoryTableData {
id: number
name: string
model: string
unit: string
drawing: string
category: string
}
const props = defineProps<{
visible: boolean
// treeData: HistoryTreeNode[]
// tableData: HistoryTableData[]
}>()
const treeData = ref<HistoryTreeNode[]>([
{
id: '1',
label: '2024年',
children: [
{
id: '1-1',
label: '2024-01',
children: [
{ id: '1-1-1', label: '广东省信息价' },
{ id: '1-1-2', label: '广西省信息价' }
]
},
{
id: '1-2',
label: '2024-02',
children: [
{ id: '1-2-1', label: '广东省信息价' },
{ id: '1-2-2', label: '广西省信息价' }
]
}
]
},
{
id: '2',
label: '2023年',
children: [
{
id: '2-1',
label: '2023-12',
children: [
{ id: '2-1-1', label: '广东省信息价' },
{ id: '2-1-2', label: '广西省信息价' }
]
}
]
}
])
const tableData = ref<HistoryTableData[]>([
{
id: 1,
name: '水泥',
model: 'P.O 42.5',
unit: '吨',
drawing: 'https://via.placeholder.com/400x300',
category: '材料'
},
{
id: 2,
name: '钢筋',
model: 'HRB400E Φ12',
unit: '吨',
drawing: 'https://via.placeholder.com/400x300',
category: '材料'
},
{
id: 3,
name: '混凝土',
model: 'C30',
unit: '立方米',
drawing: '',
category: '材料'
},
{
id: 4,
name: '砖',
model: 'MU10',
unit: '千块',
drawing: 'https://via.placeholder.com/400x300',
category: '材料'
},
{
id: 5,
name: '挖掘机',
model: '1m³',
unit: '台班',
drawing: '',
category: '机械'
},
{
id: 4,
name: '砖',
model: 'MU10',
unit: '千块',
drawing: 'https://via.placeholder.com/400x300',
category: '材料'
},
{
id: 5,
name: '挖掘机',
model: '1m³',
unit: '台班',
drawing: '',
category: '机械'
}
])
const emit = defineEmits<{
'update:visible': [value: boolean]
'confirm': [node: any]
}>()
const selectedHistoryNode = ref<any>(null)
const handleTreeSelect = (node: any) => {
selectedHistoryNode.value = node
console.log('选中的历史节点:', node)
}
const handleSelectionChange = (newSelection: any[]) => {
console.log('handleSelectionChange:', newSelection)
}
const handleConfirm = () => {
// emit('confirm', selectedHistoryNode.value)
handleClose()
}
const handleClose = () => {
// selectedHistoryNode.value = null
emit('update:visible', false)
}
</script>
<template>
<ElDialog
:model-value="visible"
title="调用历史信息"
width="50%"
@update:model-value="emit('update:visible', $event)"
@close="handleClose"
>
<div class="history-container" ref="containerRef">
<ElSplitter >
<ElSplitterPanel size="20%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef">
<DbTree :height="containerHeight" :data="treeData" @select="handleTreeSelect" :defaultExpandedKeys="2" :search="false" />
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full">
<ElTable :data="tableData" border stripe style="width: 100%" height="380" @selection-change="handleSelectionChange">
<ElTableColumn type="selection" width="55" />
<ElTableColumn prop="name" label="名称" width="150" />
<ElTableColumn prop="model" label="型号规格" width="150" />
<ElTableColumn prop="unit" label="单位" width="100" align="center" />
<ElTableColumn prop="drawing" label="图样(仅供参考)" width="150" align="center">
<template #default="{ row }">
<ElImage
v-if="row.drawing"
:src="row.drawing"
:preview-src-list="[row.drawing]"
fit="cover"
style="width: 60px; height: 60px; cursor: pointer;"
preview-teleported
/>
<span v-else class="text-gray-400">暂无图样</span>
</template>
</ElTableColumn>
<ElTableColumn prop="category" label="分类" min-width="120" />
</ElTable>
</ElCard>
</ElSplitterPanel>
</ElSplitter>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确定</ElButton>
</div>
</template>
</ElDialog>
</template>
<style scoped>
.history-container {
padding: 10px 0;
height: 400px;
}
.history-tree-container {
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 10px;
background-color: #f5f7fa;
}
.history-table-container {
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 10px;
}
</style>

View File

@@ -0,0 +1,322 @@
import { ref, nextTick } from 'vue'
import { selectCellBorderStyle } from '#/components/db-hst/base'
import { handleRowOperation, codeRenderer } from '#/components/db-hst/tree'
// 操作列回调函数类型
export interface OperationCallbacks {
onUpload?: (row: number, data: any) => void
onComplete?: (row: number, data: any) => void
onPublish?: (row: number, data: any) => void
onView?: (row: number, data: any) => void
}
// 价格时间段渲染器回调类型
export interface DateRangeCallbacks {
onDateIconClick?: (row: number, data: any, event: MouseEvent) => void
}
// 附件下载渲染器回调类型
export interface AttachmentCallbacks {
onDownload?: (row: number, data: any) => void
}
// 价格时间段渲染器
export function createDateRangeRenderer(callbacks: DateRangeCallbacks) {
return function dateRangeRenderer(instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) {
td.innerHTML = ''
const container = document.createElement('div')
container.style.display = 'flex'
container.style.alignItems = 'center'
container.style.justifyContent = 'space-between'
container.style.gap = '8px'
// container.style.padding = '0 8px'
// 文字部分
const textSpan = document.createElement('span')
textSpan.textContent = value || ''
textSpan.style.flex = '1'
// 时间图标
const iconSpan = document.createElement('span')
iconSpan.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
`
iconSpan.style.cssText = `
cursor: pointer;
color: #bbbbbbff;
display: flex;
align-items: center;
transition: all 0.3s;
`
// iconSpan.addEventListener('mouseenter', () => {
// iconSpan.style.color = '#66b1ff'
// iconSpan.style.transform = 'scale(1.1)'
// })
// iconSpan.addEventListener('mouseleave', () => {
// iconSpan.style.color = '#409eff'
// iconSpan.style.transform = 'scale(1)'
// })
iconSpan.addEventListener('click', (e) => {
e.stopPropagation()
const rowData = instance.getSourceDataAtRow(row)
console.log('时间图标点击 - 行:', row, '数据:', rowData)
if (callbacks.onDateIconClick) {
callbacks.onDateIconClick(row, rowData, e)
}
})
container.appendChild(textSpan)
container.appendChild(iconSpan)
td.appendChild(container)
return td
}
}
// 附件下载渲染器
export function createAttachmentRenderer(callbacks: AttachmentCallbacks) {
return function attachmentRenderer(instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) {
td.innerHTML = ''
if (!value) {
td.textContent = '-'
td.style.textAlign = 'center'
td.style.color = '#999'
return td
}
const container = document.createElement('div')
container.style.display = 'flex'
container.style.alignItems = 'center'
container.style.justifyContent = 'center'
container.style.gap = '6px'
// 下载图标
const iconSpan = document.createElement('span')
iconSpan.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
`
iconSpan.style.cssText = `
display: flex;
align-items: center;
color: #409eff;
`
// 文件名
const textSpan = document.createElement('span')
textSpan.textContent = '下载'
textSpan.style.cssText = `
cursor: pointer;
color: #409eff;
font-size: 12px;
text-decoration: underline;
transition: all 0.3s;
`
// 鼠标悬停效果
const handleMouseEnter = () => {
textSpan.style.color = '#66b1ff'
iconSpan.style.color = '#66b1ff'
}
const handleMouseLeave = () => {
textSpan.style.color = '#409eff'
iconSpan.style.color = '#409eff'
}
container.addEventListener('mouseenter', handleMouseEnter)
container.addEventListener('mouseleave', handleMouseLeave)
// 点击下载
container.addEventListener('click', (e) => {
e.stopPropagation()
const rowData = instance.getSourceDataAtRow(row)
console.log('下载附件 - 行:', row, '数据:', rowData)
if (callbacks.onDownload) {
callbacks.onDownload(row, rowData)
}
})
container.style.cursor = 'pointer'
container.appendChild(iconSpan)
container.appendChild(textSpan)
td.appendChild(container)
return td
}
}
// 操作列渲染器
export function createOperationRenderer(callbacks: OperationCallbacks) {
return function operationRenderer(instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) {
td.innerHTML = ''
const container = document.createElement('div')
container.style.display = 'flex'
container.style.gap = '4px'
container.style.justifyContent = 'center'
container.style.alignItems = 'center'
container.style.flexWrap = 'wrap'
const actions = [
{ text: '附件上传', color: '#409eff', callback: callbacks.onUpload },
{ text: '完成', color: '#409eff', callback: callbacks.onComplete },
{ text: '发布', color: '#409eff', callback: callbacks.onPublish },
{ text: '查看', color: '#409eff', callback: callbacks.onView }
]
actions.forEach(action => {
const btn = document.createElement('span')
btn.textContent = action.text
btn.style.cssText = `
cursor: pointer;
color: ${action.color};
font-size: 12px;
padding: 2px 6px;
border-radius: 3px;
transition: all 0.3s;
white-space: nowrap;
`
// btn.addEventListener('mouseenter', () => {
// btn.style.backgroundColor = action.color
// btn.style.color = '#fff'
// })
// btn.addEventListener('mouseleave', () => {
// btn.style.backgroundColor = 'transparent'
// btn.style.color = action.color
// })
btn.addEventListener('click', (e) => {
e.stopPropagation()
const rowData = instance.getSourceDataAtRow(row)
console.log(`${action.text} 操作 - 行:`, row, '数据:', rowData)
// 调用回调函数
if (action.callback) {
action.callback(row, rowData)
}
})
container.appendChild(btn)
})
td.appendChild(container)
return td
}
}
export const contextMenuHandler = {
rootKey: 'add-category',
rootText: '添加总类',
onRootAdd: async (newNode: any, allRootNodes: any[]) => {
console.log('添加总类:', newNode, '所有根节点:', allRootNodes)
// 计算所有根节点的最大 sortOrder
const maxSortOrder = allRootNodes.reduce((max, node) => {
const sortOrder = node.sortOrder ?? 0
return Math.max(max, sortOrder)
}, 0)
const newSortOrder = maxSortOrder + 1
newNode.code = 'ROOT'
newNode.sortOrder = newSortOrder
console.log('新节点 sortOrder:', newSortOrder)
},
onRootDelete: async (node: any) => {
console.log('删除总类:', node)
// TODO: 调用 API 删除根节点
// await deleteCategories(node.id)
},
levels: [
{
depth: 0,
addKey: 'add-province',
addText: '添加省市',
sort: 'sortOrder',
allowDelete: true,
onAdd: async (parentNode: any, newNode: any, allChildren: any[]) => {
console.log('添加省市 - 父节点:', parentNode, '新节点:', newNode, '所有子节点:', allChildren)
// 计算当前父节点下所有子节点的最大 sortOrder
const maxSortOrder = allChildren.reduce((max, child) => {
const sortOrder = child.sortOrder ?? 0
return Math.max(max, sortOrder)
}, 0)
console.log('父节点下子节点最大 sortOrder:', maxSortOrder)
// TODO: 调用 API 保存
},
onDelete: async (node: any) => {
console.log('删除省市:', node)
// TODO: 调用 API 删除
// await deleteCatalog(node.id)
}
},
{
depth: 1,
addKey: 'add-specialty',
addText: '添加工料机专业',
sort: 'sortOrder',
allowDelete: true,
onAdd: async (parentNode: any, newNode: any, allChildren: any[]) => {
console.log('添加工料机专业 - 父节点:', parentNode, '新节点:', newNode, '所有子节点:', allChildren)
// 计算当前父节点下所有子节点的最大 sortOrder
const maxSortOrder = allChildren.reduce((max, child) => {
const sortOrder = child.sortOrder ?? 0
return Math.max(max, sortOrder)
}, 0)
console.log('父节点下子节点最大 sortOrder:', maxSortOrder)
// TODO: 调用 API 保存
},
onDelete: async (node: any) => {
console.log('删除工料机专业:', node)
// TODO: 调用 API 删除
// await deleteCatalog(node.id)
}
},
{
depth: 2,
allowDelete: true,
onDelete: async (node: any) => {
console.log('删除第三层节点:', node)
// TODO: 调用 API 删除第三层节点
}
}
]
}
export const contextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]

File diff suppressed because it is too large Load Diff

View File

@@ -49,7 +49,7 @@ const categoryTreeData = ref<Tree[]>([
} }
]) ])
const topColumns = ref<any[]>([ const topColumns = ref<any[]>([
{type:'text',data:'code',title:'编码', renderer: codeRenderer}, {type:'text',data:'code',title:'编码', renderer: codeRenderer, code:true},
{type:'text',data:'name',title:'名称'}, {type:'text',data:'name',title:'名称'},
{type:'text',data:'unit',title:'单位'}, {type:'text',data:'unit',title:'单位'},
]) ])

View File

@@ -233,12 +233,12 @@ onUnmounted(() => {
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<ElSplitter > <ElSplitter >
<ElSplitterPanel collapsible size="15%" :min="200"> <ElSplitterPanel size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef"> <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" /> <DbTree :height="containerHeight" :data="categoryTreeData" @select="categoryHandleSelect" :defaultExpandedKeys="2" :search="false" />
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
<ElSplitterPanel collapsible :min="200"> <ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full"> <ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full">
<DbHst ref="bottomHstRef" :settings="bottomDbSettings"></DbHst> <DbHst ref="bottomHstRef" :settings="bottomDbSettings"></DbHst>
</ElCard> </ElCard>

View File

@@ -8,7 +8,7 @@ const props = defineProps<{
const hstRef = ref<any>(null) const hstRef = ref<any>(null)
const columns = ref<any[]>([ const columns = ref<any[]>([
{ type: 'text', data: 'seq', title: '序', width: 40 }, { type: 'text', data: 'seq', title: '序', width: 40 },
{ type: 'text', data: 'code', title: '编码', renderer: codeRenderer }, { type: 'text', data: 'code', title: '编码', renderer: codeRenderer, code:true },
{ type: 'text', data: 'category', title: '类别' }, { type: 'text', data: 'category', title: '类别' },
{ type: 'text', data: 'name', title: '名称' }, { type: 'text', data: 'name', title: '名称' },
{ type: 'text', data: 'feature', title: '项目特征' }, { type: 'text', data: 'feature', title: '项目特征' },

View File

@@ -77,7 +77,7 @@ const dialogRenderer = (instance: any, td: HTMLElement, row: number, col: number
const columns = ref<any[]>([ const columns = ref<any[]>([
{ type: 'text', data: 'seq', title: '序', width: 40 }, { type: 'text', data: 'seq', title: '序', width: 40 },
{ type: 'text', data: 'name', title: '费用名称', renderer: codeRenderer }, { type: 'text', data: 'name', title: '费用名称', renderer: codeRenderer , code:true},
{ type: 'text', data: 'spec', title: '费用代码', width: 300,renderer: dialogRenderer }, { type: 'text', data: 'spec', title: '费用代码', width: 300,renderer: dialogRenderer },
{ type: 'text', data: 'cardinal', title: '计算基数(用户端不显示)', width: 200 }, { type: 'text', data: 'cardinal', title: '计算基数(用户端不显示)', width: 200 },
]) ])

View File

@@ -39,28 +39,107 @@ export const treeData = ref<Tree[]>([
export const contextMenuHandler = { export const contextMenuHandler = {
rootKey: 'add-category', rootKey: 'add-category',
rootText: '添加定额', rootText: '添加定额',
onRootAdd: async (newNode: any, allRootNodes: any[]) => {
console.log('添加总类:', newNode, '所有根节点:', allRootNodes)
// 计算所有根节点的最大 sortOrder
const maxSortOrder = allRootNodes.reduce((max, node) => {
const sortOrder = node.sortOrder ?? 0
return Math.max(max, sortOrder)
}, 0)
const newSortOrder = maxSortOrder + 1
newNode.code = 'ROOT'
newNode.sortOrder = newSortOrder
console.log('新节点 sortOrder:', newSortOrder)
},
onRootDelete: async (node: any) => {
console.log('删除总类:', node)
// TODO: 调用 API 删除根节点
// await deleteCategories(node.id)
},
levels: [ levels: [
{ {
depth: 0, depth: 0,
addKey: 'add-province', addKey: 'add-province',
addText: '添加省市', addText: '添加省市',
allowDelete: true allowDelete: true,
onAdd: async (parentNode: any, newNode: any, allChildren: any[]) => {
console.log('添加省市 - 父节点:', parentNode, '新节点:', newNode, '所有子节点:', allChildren)
// 计算当前父节点下所有子节点的最大 sortOrder
const maxSortOrder = allChildren.reduce((max, child) => {
const sortOrder = child.sortOrder ?? 0
return Math.max(max, sortOrder)
}, 0)
console.log('父节点下子节点最大 sortOrder:', maxSortOrder)
// TODO: 调用 API 保存省市节点
},
onDelete: async (node: any) => {
console.log('删除省市:', node)
// TODO: 调用 API 删除节点
// await deleteCatalog(node.id)
}
}, },
{ {
depth: 1, depth: 1,
addKey: 'add-name', addKey: 'add-name',
addText: '添加名称', addText: '添加名称',
allowDelete: true allowDelete: true,
onAdd: async (parentNode: any, newNode: any, allChildren: any[]) => {
console.log('添加名称 - 父节点:', parentNode, '新节点:', newNode, '所有子节点:', allChildren)
// 计算当前父节点下所有子节点的最大 sortOrder
const maxSortOrder = allChildren.reduce((max, child) => {
const sortOrder = child.sortOrder ?? 0
return Math.max(max, sortOrder)
}, 0)
console.log('父节点下子节点最大 sortOrder:', maxSortOrder)
// TODO: 调用 API 保存省市节点
},
onDelete: async (node: any) => {
console.log('删除名称:', node)
// TODO: 调用 API 删除省市节点
// await deleteCatalog(node.id)
}
}, },
{ {
depth: 2, depth: 2,
addKey: 'add-model', addKey: 'add-model',
addText: '添加模式', addText: '添加模式',
allowDelete: true allowDelete: true,
onAdd: async (parentNode: any, newNode: any, allChildren: any[]) => {
console.log('添加模式 - 父节点:', parentNode, '新节点:', newNode, '所有子节点:', allChildren)
// 计算当前父节点下所有子节点的最大 sortOrder
const maxSortOrder = allChildren.reduce((max, child) => {
const sortOrder = child.sortOrder ?? 0
return Math.max(max, sortOrder)
}, 0)
console.log('父节点下子节点最大 sortOrder:', maxSortOrder)
// TODO: 调用 API 创建
},
onDelete: async (node: any) => {
console.log('删除:', node)
// TODO: 调用 API 删除
// await deleteCatalog(node.id)
}
}, },
{ {
depth: 3, depth: 3,
allowDelete: true allowDelete: true,
onDelete: async (node: any) => {
console.log('删除第三层节点:', node)
// TODO: 调用 API 删除第三层节点
// await deleteCategoriesTree(node.id)
}
} }
] ]
} }

View File

@@ -1,17 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch,computed } from 'vue' import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch,computed } from 'vue'
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { ElSplitter,ElSplitterPanel,ElCard,ElDialog,ElTable,ElTableColumn,ElButton,ElInput,ElAlert } from 'element-plus'; import { ElSplitter,ElSplitterPanel,ElCard,ElDialog,ElTable,ElTableColumn,ElButton,ElInput,ElAlert,ElTreeV2 } from 'element-plus';
import { useElementSize } from '@vueuse/core' import { useElementSize } from '@vueuse/core'
import { DbTree } from '#/components/db-tree'; import { DbTree } from '#/components/db-tree';
import { DbHst } from '#/components/db-hst'; import { DbHst } from '#/components/db-hst';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { treeData as catalogTreeData, contextMenuHandler as catalogContextMenuHandler } from './catalog'; import { treeData as catalogTreeData, contextMenuHandler as catalogContextMenuHandler } from './catalog';
import { treeData as topTreeData, contextMenuHandler as topContextMenuHandler } from './catalog'; import { treeData as topTreeData, contextMenuHandler as topContextMenuHandler } from './catalog';
import { dbSettings as projectDbSettings } from './project'; import { dbSettings as projectDbSettings, contextMenuItems as projectContextMenuItems, initProjectHst, load as projectLoad } from './project';
import { dbSettings as subDbSettings } from './sub'; import { dbSettings as subDbSettings, contextMenuItems as subContextMenuItems, setEditDescCallback, initSubHst, load as subLoad } from './sub';
import { dbSettings as guideDbSettings } from './guide'; import { dbSettings as guideDbSettings, contextMenuItems as guideContextMenuItems, initGuideHst, load as guideLoad } from './guide';
import { dbSettings as bottomDbSettings } from './rightHst'; import { dbSettings as bottomDbSettings, contextMenuItems as bottomContextMenuItems, initBottomHst, load as bottomLoad } from './rightHst';
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
@@ -29,66 +30,221 @@ const subHstRef = ref<any>(null)
const guideHstRef = ref<any>(null) const guideHstRef = ref<any>(null)
const bottomHstRef = ref<any>(null) const bottomHstRef = ref<any>(null)
// Dialog state
const dialogVisible = ref(false)
const dialogTreeData = ref<any[]>([])
const selectedNode = ref<any>(null)
// Rich text editor dialog state
const richTextDialogVisible = ref(false)
const richTextContent = ref('')
const currentEditRow = ref<number>(-1)
const currentEditCol = ref<number>(-1)
// 生成100条模拟数据
function generateMockData() {
const units = ['项', 'm', 'm²', 'm³', 't', 'kg', '个', '套', '台', '组']
const categories = ['土建工程', '装饰工程', '安装工程', '市政工程', '园林工程', '钢结构工程', '幕墙工程', '消防工程', '电气工程', '给排水工程']
const subCategories = ['基础工程', '主体结构', '屋面工程', '门窗工程', '防水工程', '保温工程', '抹灰工程', '涂饰工程', '吊顶工程', '地面工程']
const data = []
for (let i = 1; i <= 100; i++) {
const categoryIndex = (i - 1) % categories.length
const hasChildren = i <= 50 // 前50条有子项
const item: any = {
code: `PRJ${String(i).padStart(3, '0')}`,
name: `${categories[categoryIndex]}-${i}`,
unit: units[i % units.length],
__children: []
}
// 为前50条数据添加2-5个子项
if (hasChildren) {
const childCount = Math.floor(Math.random() * 4) + 2 // 2-5个子项
for (let j = 1; j <= childCount; j++) {
const subItem: any = {
code: `${item.code}-${String(j).padStart(3, '0')}`,
name: `${subCategories[j % subCategories.length]}-${i}-${j}`,
unit: units[(i + j) % units.length],
__children: []
}
// 部分子项再添加孙项
if (i <= 20 && j <= 2) {
const grandChildCount = Math.floor(Math.random() * 3) + 1 // 1-3个孙项
for (let k = 1; k <= grandChildCount; k++) {
subItem.__children.push({
code: `${subItem.code}-${String(k).padStart(3, '0')}`,
name: `细项-${i}-${j}-${k}`,
unit: units[(i + j + k) % units.length],
__children: []
})
}
}
item.__children.push(subItem)
}
}
data.push(item)
}
return data
}
const treeHandleSelect = (node: any) => { const treeHandleSelect = (node: any) => {
console.log('treeHandleSelect',node) console.log('treeHandleSelect', node)
// 使用 projectLoad 加载数据到 projectHstRef
if (node && projectHstRef.value?.hotInstance) {
// 根据树节点加载对应的数据
// 示例1: 如果 node 包含数据数组
// projectLoad(node.data)
// 示例2: 如果需要根据 node.id 从 API 获取数据
// fetchProjectData(node.id).then(data => {
// projectLoad(data)
// })
// 示例3: 加载模拟数据(用于测试)
const mockData = generateMockData()
projectLoad(mockData)
}
} }
function onResizeEnd(index: number, sizes: number[]) { function onResizeEnd(index: number, sizes: number[]) {
// 通过 hotInstance 来操作 Handsontable // 通过 hotInstance 来操作 Handsontable
console.log(index,sizes) // console.log(index,sizes)
onTopHeight(sizes[0]) onTopHeight(sizes[0])
onBottomHeight(sizes[1]) onBottomHeight(sizes[1])
} }
function onTopHeight(height: number){ function onTopHeight(height: number){
// if (topHstRef.value?.hotInstance) { topContainerHeight.value = height
// topHstRef.value.hotInstance.updateSettings({
// height: height-15,
// })
// topHstRef.value.hotInstance.render()
// // console.log('onResizeEnd-onTopHeight',height, 'codeColWidth:', topHstRef.value.codeColWidth);
// }
} }
function onBottomHeight(height: number){ function onBottomHeight(height: number){
// if (bottomHstRef.value?.hotInstance) { if (bottomHstRef.value?.hotInstance) {
// bottomHstRef.value.hotInstance.updateSettings({ bottomHstRef.value.hotInstance.updateSettings({
// height: height-15 height: height
// }) })
bottomHstRef.value.hotInstance.render()
// // bottomHstRef.value.loadData(bootomMock()) }
// // bottomHstRef.value.hotInstance.render() }
// console.log('onResizeEnd-bottomHstRef',height); function onDecide(node: any){
// } console.log('onDecide',node)
selectedNode.value = node
// 设置弹窗树数据(这里使用示例数据,你可以根据需要修改)
dialogTreeData.value = [
{
id: '1',
label: '选项 1',
children: [
{ id: '1-1', label: '选项 1-1' },
{ id: '1-2', label: '选项 1-2' }
]
},
{
id: '2',
label: '选项 2',
children: [
{ id: '2-1', label: '选项 2-1' },
{ id: '2-2', label: '选项 2-2' }
]
}
]
dialogVisible.value = true
}
function handleDialogTreeClick(data: any) {
console.log('选中的树节点:', data)
}
function handleDialogConfirm() {
selectedNode.value.selected = true
console.log('确认选择:', selectedNode.value)
dialogVisible.value = false
}
function handleDialogCancel() {
dialogVisible.value = false
}
// 处理富文本编辑
function handleEditDesc(row: number, col: number, value: string) {
currentEditRow.value = row
currentEditCol.value = col
richTextContent.value = value
richTextDialogVisible.value = true
}
// 确认富文本编辑
function handleRichTextConfirm() {
if (subHstRef.value?.hotInstance && currentEditRow.value >= 0) {
subHstRef.value.hotInstance.setDataAtCell(currentEditRow.value, currentEditCol.value, richTextContent.value)
}
richTextDialogVisible.value = false
}
// 取消富文本编辑
function handleRichTextCancel() {
richTextDialogVisible.value = false
} }
onMounted(() => { onMounted(() => {
// 设置编辑回调
setEditDescCallback(handleEditDesc)
setTimeout(() => { setTimeout(() => {
// onTopHeight(topContainerHeight.value) // onTopHeight(topContainerHeight.value)
// onBottomHeight(bottomContainerHeight.value) // onBottomHeight(bottomContainerHeight.value)
if (projectHstRef.value?.hotInstance) { if (projectHstRef.value?.hotInstance) {
// 初始化 projectHstRef
initProjectHst(projectHstRef, (row: number | null) => {
console.log('projectHstRef-选中的行:', row)
// 在这里处理点击事件
subLoad(generateMockData())
})
projectHstRef.value?.hotInstance.updateSettings({ projectHstRef.value?.hotInstance.updateSettings({
height: containerHeight.value - 15 height: containerHeight.value
}) })
// 更新 code 列的宽度 // 更新 code 列的宽度
projectHstRef.value.updateCodeColWidth() // projectHstRef.value.updateCodeColWidth()
} }
if (subHstRef.value?.hotInstance) { if (subHstRef.value?.hotInstance) {
// 初始化 subHstRef
initSubHst(subHstRef, (row: number | null) => {
console.log('subHstRef-选中的行:', row)
// 在这里处理点击事件
guideLoad(generateMockData())
})
subHstRef.value?.hotInstance.updateSettings({ subHstRef.value?.hotInstance.updateSettings({
height: containerHeight.value - 15 height: containerHeight.value
}) })
} }
if (guideHstRef.value?.hotInstance) { if (guideHstRef.value?.hotInstance) {
// 初始化 guideHstRef
initGuideHst(guideHstRef, (row: number | null) => {
console.log('guideHstRef-选中的行:', row)
// 在这里处理点击事件
bottomLoad(generateMockData())
})
guideHstRef.value?.hotInstance.updateSettings({ guideHstRef.value?.hotInstance.updateSettings({
height: containerHeight.value - 15 height: containerHeight.value
}) })
} }
if (bottomHstRef.value?.hotInstance) { if (bottomHstRef.value?.hotInstance) {
// 初始化 bottomHstRef
initBottomHst(bottomHstRef, (row: number | null) => {
console.log('bottomHstRef-选中的行:', row)
// 在这里处理点击事件
})
bottomHstRef.value?.hotInstance.updateSettings({ bottomHstRef.value?.hotInstance.updateSettings({
height: containerHeight.value height: bottomContainerHeight.value
}) })
} }
@@ -102,41 +258,85 @@ onUnmounted(() => {
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<ElSplitter > <ElSplitter >
<ElSplitterPanel collapsible size="15%" :min="200"> <ElSplitterPanel size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef"> <ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef">
<DbTree :height="containerHeight" :data="catalogTreeData" @select="treeHandleSelect" :defaultExpandedKeys="3" :context-menu-handler="catalogContextMenuHandler" :search="false" /> <DbTree :height="containerHeight" :data="catalogTreeData" @select="treeHandleSelect"
:defaultExpandedKeys="3" :context-menu-handler="catalogContextMenuHandler" :search="false"
:decide="true" @decide="onDecide"
:contextmenu="true"
/>
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
<ElSplitterPanel collapsible :min="200"> <ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" > <ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" >
<DbHst ref="projectHstRef" :settings="projectDbSettings"></DbHst> <DbHst ref="projectHstRef" :settings="projectDbSettings" :contextMenuItems="projectContextMenuItems"></DbHst>
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
<ElSplitterPanel collapsible :min="200"> <ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" > <ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" >
<DbHst ref="subHstRef" :settings="subDbSettings"></DbHst> <DbHst ref="subHstRef" :settings="subDbSettings" :contextMenuItems="subContextMenuItems"></DbHst>
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
<ElSplitterPanel collapsible size="18%" :min="200"> <ElSplitterPanel size="18%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" > <ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" >
<DbHst ref="guideHstRef" :settings="guideDbSettings"></DbHst> <DbHst ref="guideHstRef" :settings="guideDbSettings" :contextMenuItems="guideContextMenuItems"></DbHst>
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel :min="200">
<ElSplitterPanel size="20%"> <ElSplitterPanel size="20%">
<ElSplitter layout="vertical" @resize-end="onResizeEnd"> <ElSplitter layout="vertical" @resize-end="onResizeEnd">
<ElSplitterPanel collapsible size="50%" :min="200"> <ElSplitterPanel size="50%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="topContainerRef"> <ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="topContainerRef">
<DbTree :height="topContainerHeight" :data="topTreeData" @select="treeHandleSelect" :defaultExpandedKeys="3" :context-menu-handler="topContextMenuHandler" :search="false" /> <!-- :context-menu-handler="topContextMenuHandler" -->
<DbTree :height="topContainerHeight" :data="topTreeData" @select="treeHandleSelect"
:defaultExpandedKeys="3" :search="false" />
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
<ElSplitterPanel collapsible :min="200"> <ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="bottomContainerRef"> <ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="bottomContainerRef">
<DbHst ref="bottomHstRef" :settings="bottomDbSettings"></DbHst> <DbHst ref="bottomHstRef" :settings="bottomDbSettings" :contextMenuItems="bottomContextMenuItems"></DbHst>
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
</ElSplitter> </ElSplitter>
</ElSplitterPanel> </ElSplitterPanel>
</ElSplitter> </ElSplitter>
<!-- 树选择弹窗 -->
<ElDialog
v-model="dialogVisible"
title="选择节点"
width="500px"
:close-on-click-modal="false"
>
<ElTreeV2
:data="dialogTreeData"
:props="{ value: 'id', label: 'label', children: 'children' }"
:height="400"
highlight-current
@node-click="handleDialogTreeClick"
/>
<template #footer>
<span class="dialog-footer">
<ElButton @click="handleDialogCancel">取消</ElButton>
<ElButton type="primary" @click="handleDialogConfirm">确定</ElButton>
</span>
</template>
</ElDialog>
<!-- 富文本编辑弹窗 -->
<ElDialog
v-model="richTextDialogVisible"
title="编辑清单说明"
width="800px"
:close-on-click-modal="false"
>
<RichTextarea v-model="richTextContent" height="400px" />
<template #footer>
<span class="dialog-footer">
<ElButton @click="handleRichTextCancel">取消</ElButton>
<ElButton type="primary" @click="handleRichTextConfirm">确定</ElButton>
</span>
</template>
</ElDialog>
</Page> </Page>
</template> </template>

View File

@@ -1,33 +1,108 @@
//清单指引 //清单指引
import { ref } from 'vue' import { ref, nextTick } from 'vue'
const columns = ref<any[]>([ import type { Ref } from 'vue'
{type:'text',data:'code',title:'编码'}, import { useParentChildLineNestedRowsFalse } from '#/components/db-hst/nestedRows'
{type:'text',data:'name',title:'名称'},
{type:'text',data:'unit',title:'单位',width:30},
]) // 用于存储 guideHstRef 的引用
let guideHstRef: Ref<any> | null = null
const selectedRow = ref<any>(null) // 记录当前选中的行
const mockData = ()=>{ // 回调函数,供 config.vue 使用
const units = ['m³', 'm²', 'm', 'kg', 't', '个', '套', '台', '块', '根'] let onCellMouseDownCallback: ((row: number | null) => void) | null = null
const categories = ['混凝土', '钢材', '木材', '砖瓦', '管材', '电缆', '涂料', '五金', '设备', '其他']
// 初始化函数,由 config.vue 调用
const mockData = Array.from({ length: 30 }, (_, index) => ({ export function initGuideHst(hstRef: Ref<any>, callback?: (row: number | null) => void) {
code: `A${String(index + 1).padStart(6, '0')}`, guideHstRef = hstRef
name: `${categories[index % categories.length]}材料${index + 1}`, onCellMouseDownCallback = callback || null
unit: units[index % units.length],
}))
return mockData;
} }
let rowSchema: any = {}
// 根据 columns 的 data 字段生成对象结构 const { load: originalLoad, codeRenderer, handleRowOperation, initSchema, highlightDeselect } = useParentChildLineNestedRowsFalse({
columns.value.forEach((col: any) => { getHotInstance: () => guideHstRef?.value?.hotInstance,
if (col.data) {
rowSchema[col.data] = null
}
}) })
export let dbSettings = { // 包装 load 函数,在加载前重置选中行
data: mockData(), export function load(data: any[]) {
dataSchema: rowSchema, selectedRow.value = null
colWidths: 100, return originalLoad(data)
columns: columns.value
} }
const columns:any[] = [
{type:'text',data:'number',title:'序号', width: 50},
{type:'text',data:'code',title:'编码',renderer: codeRenderer, code:true},
{type:'text',data:'name',title:'名称'},
{type:'text',data:'unit',title:'单位',width:50, className: 'htCenter'},
]
export let dbSettings = {
data: [],
dataSchema: initSchema(columns),
colWidths: 160,
columns: columns,
rowHeaders: false,
nestedRows: false,
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: {}
}
},
currentRowClassName: 'row-highlight',
beforeOnCellMouseDown(event: MouseEvent, coords: any, TD: HTMLTableCellElement){
// 只能单击
if (event.button !== 0) {
return // 直接返回,不执行后续逻辑
}
selectedRow.value = coords
// 触发回调函数
if (onCellMouseDownCallback) {
onCellMouseDownCallback(selectedRow.value)
}
},
afterDeselect(){
highlightDeselect(this, selectedRow.value)
}
}
// 定义 topHstRef 的自定义右键菜单
export const contextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
handleRowOperation(hotInstance, 'append')
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]

View File

@@ -1,75 +1,47 @@
//工程 //工程
import { ref } from 'vue' import { ref, nextTick } from 'vue'
import { handleRowOperation, codeRenderer } from '#/components/db-hst/tree' import type { Ref } from 'vue'
const columns = ref<any[]>([ import { useParentChildLineNestedRowsFalse } from '#/components/db-hst/nestedRows'
{type:'text',data:'code',title:'编号',renderer: codeRenderer},
{type:'text',data:'name',title:'名称'}, // 用于存储 projectHstRef 的引用
{type:'text',data:'unit',title:'单位', width: 50}, let projectHstRef: Ref<any> | null = null
]) const selectedRow = ref<any>(null) // 记录当前选中的行
const mockData = ()=>{
const mockData = [] // 回调函数,供 config.vue 使用
let codeCounter = 1 let onCellMouseDownCallback: ((row: number | null) => void) | null = null
// 生成5个父级工程项目 // 初始化函数,由 config.vue 调用
for (let i = 0; i < 5; i++) { export function initProjectHst(hstRef: Ref<any>, callback?: (row: number | null) => void) {
const parentCode = `PRJ${String(codeCounter++).padStart(2, '0')}` projectHstRef = hstRef
const parent = { onCellMouseDownCallback = callback || null
code: parentCode,
name: `工程分类${i + 1}`,
unit: ['项', 'm³', 'm²', 't', '套'][i % 5],
level: String(i),
__children: []
}
// 为每个父级生成2-4个子项
const childCount = Math.floor(Math.random() * 3) + 2
for (let j = 0; j < childCount; j++) {
const childCode = `${parentCode}-${String(j + 1).padStart(3, '0')}`
const child = {
code: childCode,
name: `${parent.name}-子项${j + 1}`,
unit: ['m³', 'm²', 't', '套', '台'][j % 5],
level: `${i}-${j + 1}`,
__children: []
}
// 为部分子项生成孙项
if (j === 0 && i < 3) {
const grandChildCount = Math.floor(Math.random() * 2) + 1
for (let k = 0; k < grandChildCount; k++) {
const grandChild = {
code: `${childCode}-${String(k + 1).padStart(2, '0')}`,
name: `${child.name}-明细${k + 1}`,
unit: ['m³', 'm²', 't', 'kg', '个'][k % 5],
level: `${i}-${j + 1}-${k + 1}`,
__children: []
}
child.__children.push(grandChild)
}
}
parent.__children.push(child)
}
mockData.push(parent)
}
return mockData
} }
let rowSchema: any = {level: null, __children: []}
// 根据 columns 的 data 字段生成对象结构 const { load: originalLoad, codeRenderer, handleRowOperation, initSchema, highlightDeselect } = useParentChildLineNestedRowsFalse({
columns.value.forEach((col: any) => { getHotInstance: () => projectHstRef?.value?.hotInstance,
if (col.data && col.data !== 'level' && col.data !== '__children') {
rowSchema[col.data] = null
}
}) })
// 包装 load 函数,在加载前重置选中行
export function load(data: any[]) {
selectedRow.value = null
return originalLoad(data)
}
const columns:any[] = [
{type:'text',data:'number',title:'序号', width: 50, className: 'htCenter'},
{type:'text',data:'code',title:'编号',renderer: codeRenderer, code:true},
{type:'text',data:'name',title:'名称'},
{type:'text',data:'unit',title:'单位', width: 60, className: 'htCenter'},
{type:'text',data:'text1',title:'编码增加数', width: 100},
{type:'text',data:'text2',title:'尾节点', width: 100},
{type:'text',data:'text3',title:'子目编码增加数', width: 110},
]
export let dbSettings = { export let dbSettings = {
data: mockData(), data: [],
dataSchema: rowSchema, dataSchema: initSchema(columns),
colWidths: 160, colWidths: 160,
columns: columns.value, columns: columns,
rowHeaders: false, rowHeaders: false,
nestedRows: true, nestedRows: false,
bindRowsWithHeaders: true, bindRowsWithHeaders: true,
contextMenu: { contextMenu: {
items: { items: {
@@ -104,5 +76,37 @@ export let dbSettings = {
// redo: {} // redo: {}
} }
}, },
currentRowClassName: 'row-highlight',
beforeOnCellMouseDown(event: MouseEvent, coords: any, TD: HTMLTableCellElement){
// 只能单击
if (event.button !== 0) {
return // 直接返回,不执行后续逻辑
}
selectedRow.value = coords
// 触发回调函数
if (onCellMouseDownCallback) {
onCellMouseDownCallback(selectedRow.value)
}
},
afterDeselect(){
highlightDeselect(this, selectedRow.value)
}
} }
// 定义 topHstRef 的自定义右键菜单
export const contextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
handleRowOperation(hotInstance, 'append')
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]

View File

@@ -1,33 +1,108 @@
//右下角 //右下角
import { ref } from 'vue' import { ref, nextTick } from 'vue'
const columns = ref<any[]>([ import type { Ref } from 'vue'
{type:'text',data:'code',title:'编码'}, import { useParentChildLineNestedRowsFalse } from '#/components/db-hst/nestedRows'
{type:'text',data:'name',title:'名称'},
{type:'text',data:'unit',title:'单位',width:30},
]) // 用于存储 bottomHstRef 的引用
let bottomHstRef: Ref<any> | null = null
const selectedRow = ref<number | null>(null) // 记录当前选中的行
const mockData = ()=>{ // 回调函数,供 config.vue 使用
const units = ['m³', 'm²', 'm', 'kg', 't', '个', '套', '台', '块', '根'] let onCellMouseDownCallback: ((row: number | null) => void) | null = null
const categories = ['混凝土', '钢材', '木材', '砖瓦', '管材', '电缆', '涂料', '五金', '设备', '其他']
// 初始化函数,由 config.vue 调用
const mockData = Array.from({ length: 30 }, (_, index) => ({ export function initBottomHst(hstRef: Ref<any>, callback?: (row: number | null) => void) {
code: `B${String(index + 1).padStart(6, '0')}`, bottomHstRef = hstRef
name: `${categories[index % categories.length]}材料${index + 1}`, onCellMouseDownCallback = callback || null
unit: units[index % units.length],
}))
return mockData;
} }
let rowSchema: any = {}
// 根据 columns 的 data 字段生成对象结构 const { load: originalLoad, codeRenderer, handleRowOperation, initSchema, highlightDeselect } = useParentChildLineNestedRowsFalse({
columns.value.forEach((col: any) => { getHotInstance: () => bottomHstRef?.value?.hotInstance,
if (col.data) {
rowSchema[col.data] = null
}
}) })
export let dbSettings = { // 包装 load 函数,在加载前重置选中行
data: mockData(), export function load(data: any[]) {
dataSchema: rowSchema, selectedRow.value = null
colWidths: 100, return originalLoad(data)
columns: columns.value
} }
const columns :any[] = [
{type:'text',data:'number',title:'序号', width: 50},
{type:'text',data:'code',title:'编码'},
{type:'text',data:'name',title:'名称', className: 'htCenter'},
{type:'text',data:'unit',title:'单位',width:50},
]
export let dbSettings = {
data: [],
dataSchema: initSchema(columns),
colWidths: 100,
columns: columns,
rowHeaders: false,
nestedRows: false,
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: {}
}
},
currentRowClassName: 'row-highlight',
beforeOnCellMouseDown(event: MouseEvent, coords: any, TD: HTMLTableCellElement){
// 只能单击
if (event.button !== 0) {
return // 直接返回,不执行后续逻辑
}
selectedRow.value = coords
// 触发回调函数
if (onCellMouseDownCallback) {
onCellMouseDownCallback(selectedRow.value)
}
},
afterDeselect(){
highlightDeselect(this, selectedRow.value)
}
}
// 定义 topHstRef 的自定义右键菜单
export const contextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
handleRowOperation(hotInstance, 'below')
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]

View File

@@ -1,35 +1,144 @@
//清单子目 //清单子目
import { ref } from 'vue' import { ref, nextTick } from 'vue'
const columns = ref<any[]>([ import type { Ref } from 'vue'
{type:'text',data:'code',title:'编码'}, import { useParentChildLineNestedRowsFalse } from '#/components/db-hst/nestedRows'
{type:'text',data:'name',title:'名称'}, import { selectCellBorderStyle } from '#/components/db-hst/base'
{type:'text',data:'unit',title:'单位',width:30},
{type:'text',data:'desc',title:'清单说明'},
]) // 用于存储 subHstRef 的引用
let subHstRef: Ref<any> | null = null
const selectedRow = ref<any>(null) // 记录当前选中的行
const mockData = ()=>{ // 回调函数,供 config.vue 使用
const units = ['m³', 'm²', 'm', 'kg', 't', '个', '套', '台', '块', '根'] let onCellMouseDownCallback: ((row: number | null) => void) | null = null
const categories = ['混凝土', '钢材', '木材', '砖瓦', '管材', '电缆', '涂料', '五金', '设备', '其他']
// 初始化函数,由 config.vue 调用
const mockData = Array.from({ length: 30 }, (_, index) => ({ export function initSubHst(hstRef: Ref<any>, callback?: (row: number | null) => void) {
code: `MAT${String(index + 1).padStart(6, '0')}`, subHstRef = hstRef
name: `${categories[index % categories.length]}材料${index + 1}`, onCellMouseDownCallback = callback || null
unit: units[index % units.length],
desc: `这是详细说明,`
}))
return mockData;
} }
let rowSchema: any = {}
// 根据 columns 的 data 字段生成对象结构 const { load: originalLoad, codeRenderer, handleRowOperation, initSchema, highlightDeselect } = useParentChildLineNestedRowsFalse({
columns.value.forEach((col: any) => { getHotInstance: () => subHstRef?.value?.hotInstance,
if (col.data) {
rowSchema[col.data] = null
}
}) })
export let dbSettings = {
data: mockData(), // 包装 load 函数,在加载前重置选中行
dataSchema: rowSchema, export function load(data: any[]) {
colWidths: 100, selectedRow.value = null
columns: columns.value return originalLoad(data)
} }
// 用于存储编辑回调函数
let editDescCallback: ((row: number, col: number, value: string) => void) | null = null
// 设置编辑回调
export function setEditDescCallback(callback: (row: number, col: number, value: string) => void) {
editDescCallback = callback
}
// 自定义渲染器
function editRenderer(instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) {
// 清空单元格内容
td.innerHTML = ''
// 创建链接元素
const link = document.createElement('a')
link.href = 'javascript:void(0)'
link.textContent = '说明'
// link.textContent = value || '点击编辑'
link.style.color = '#409eff'
link.style.textDecoration = 'underline'
link.style.cursor = 'pointer'
// 点击事件
link.addEventListener('mousedown', (e) => {
e.preventDefault()
e.stopPropagation()
if (editDescCallback) {
editDescCallback(row, col, value || '')
}
})
td.appendChild(link)
return td
}
const columns:any[] = [
{type:'text',data:'number',title:'序号', width: 50},
{type:'text',data:'code',title:'编码'},
{type:'text',data:'name',title:'名称'},
{type:'text',data:'unit',title:'单位',width:50, className: 'htCenter'},
{type:'text',data:'desc',title:'清单说明',renderer: editRenderer},
]
export let dbSettings = {
data: [],
dataSchema: initSchema(columns),
colWidths: 100,
columns: columns,
rowHeaders: false,
nestedRows: false,
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: {}
}
},
currentRowClassName: 'row-highlight',
beforeOnCellMouseDown(event: MouseEvent, coords: any, TD: HTMLTableCellElement){
// 只能单击
if (event.button !== 0) {
return // 直接返回,不执行后续逻辑
}
selectedRow.value = coords
// 触发回调函数
if (onCellMouseDownCallback) {
onCellMouseDownCallback(selectedRow.value)
}
},
afterDeselect(){
highlightDeselect(this, selectedRow.value)
}
}
// 定义 topHstRef 的自定义右键菜单
export const contextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
handleRowOperation(hotInstance, 'below')
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]

View File

@@ -1,22 +1,426 @@
export const materialsColHeaders: string[] = [ import { createCatalogItem, createCategoriesTree, deleteCatalogItem, deleteCategoriesTree } from '#/api/database/materials/index';
'名称', import { deleteCategories } from '#/api/database/materials/root';
'类别', import { ElMessage } from 'element-plus';
'除税基价代码', type Tree = { id: string; label: string; children?: Tree[]; [key: string]: any }
'含税基价代码',
'除税编制代码', // 生成随机6位数字+英文字符串
'含税编制代码', const generateRandomCode = (): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
// 递归转换树结构name -> label保留所有原始属性
export const transformTreeData = (data: any[]): Tree[] => {
return data.map(item => ({
...item, // 保留所有原始属性
id: String(item.id), // 确保ID是字符串
label: item.name,
children: item.children && item.children.length > 0
? transformTreeData(item.children)
: []
}))
}
// 自定义 renderer检测重复的 code 值并标红
/*const duplicateCodeRenderer = (instance: any, td: HTMLElement, _row: number, _col: number, _prop: string, value: any, _cellProperties: any) => {
// 检查是否需要设置验证背景色
// const columns = instance.getSettings().columns || []
// const currentColumn = columns[_col]
// const isEmpty = value === null || value === undefined || String(value).trim() === ''
// // 如果列配置了 allowInvalid: true 且值为空,设置红色背景
// if (currentColumn?.allowInvalid === true && isEmpty) {
// } else {
// td.style.backgroundColor = '' // 清除背景色
// }
// 默认渲染
td.textContent = value || ''
// 获取所有数据
const data = instance.getSourceData()
// 检查当前值是否重复(排除空值)
if (value && value.toString().trim() !== '') {
const duplicateCount = data.filter((item: any) => item.code === value).length
if (duplicateCount > 1) {
td.style.color = 'red'
td.style.fontWeight = 'bold'
} else {
td.style.color = ''
td.style.fontWeight = ''
}
}else{
// td.style.backgroundColor = '#ff4d4f20' // 淡红色背景
}
return td
}*/
// export const materialsColumns:any[] = [
// // {type:'text',data:'sort',title:'⇅', renderer: manualRowMoveRenderer,
// // width: 40,
// // readOnly: true,
// // editor: false,
// // },
// {type:'text',data:'sortOrder',title:'序号', allowInvalid: true},
// {type:'text',data:'name',title:'名称', allowInvalid: true},
// {type:'text',data:'code',title:'类别', allowInvalid: true,
// renderer: 'db-dropdown',
// // source 会在 category.vue 中动态设置
// onAfterSelect: (instance: any, row: number, column: number, selectedValue: string, optData?: any) => {
// console.log('选择后回调:', { row, column, selectedValue, optData })
// // 可以在这里处理选择后的逻辑,例如:
// // 1. 根据选中的类别自动填充其他字段
// // 2. 触发相关数据更新
// // 3. 验证或联动其他列
// if (optData) {
// // 如果需要根据选中的数据更新其他列,可以这样做:
// // instance.setDataAtRowProp(row, 'otherField', optData.someValue, 'autofill')
// }
// // const data = {
// // categoryTreeId: 5,
// // categoryId: 1,
// // sortOrder: 1
// // }
// }
// },
// ]
export const rootColum: any[] = [
{type:'text',data:'sortOrder',title:'序号', required: true, allowInvalid: true, className: 'htCenter'},
{type:'text',data:'name',title:'名称', required: true, allowInvalid: true},
{type:'text',data:'code',title:'类别', required: true, allowInvalid: true},
{type:'text',data:'taxExclBaseCode',title:'除税基价代码', required: true, allowInvalid: true, className: 'htRight'},
{type:'text',data:'taxInclBaseCode',title:'含税基价代码', required: true, allowInvalid: true, className: 'htRight'},
{type:'text',data:'taxExclCompileCode',title:'除税编制代码', required: true, allowInvalid: true, className: 'htRight'},
{type:'text',data:'taxInclCompileCode',title:'含税编制代码', required: true, allowInvalid: true, className: 'htRight'},
] ]
export const materialsColumns:any[] = [
{type:'text',data:'code',title:'编码'}, // 直接使用配置对象DbTree 会自动创建 HierarchyContextMenuHandler
{type:'text',data:'name',title:'名称'}, export const contextMenuHandler = {
{type:'text',data:'category',title:'类别', rootKey: 'add-category',
renderer: 'db-dropdown', rootText: '添加工料机总类',
source: ['人', '人机', '材', '机'], onRootAdd: async (newNode: any, allRootNodes: any[]) => {
readOnly: true, console.log('添加工料机总类:', newNode, '所有根节点:', allRootNodes)
},
{type:'text',data:'priceExTax',title:'除税基价代码'}, // 计算根节点数量作为新的 sortOrder
{type:'text',data:'priceInTax',title:'含税基价代码'}, const newSortOrder = allRootNodes.length + 1
{type:'text',data:'priceExTaxComp',title:'除税编制代码'}, newNode.code = 'ROOT'
{type:'text',data:'priceInTaxComp',title:'含税编制代码'}, newNode.sortOrder = newSortOrder
]
console.log('新节点 sortOrder:', newSortOrder)
// TODO: 有BUG添加时list不出现
// await createCategories({ name: newNode.label, code: newNode.code, sortOrder: newNode.sortOrder }).then(res=>{
// if(res){
// newNode.id = res
// }
// })
},
onRootDelete: async (node: any) => {
console.log('删除工料机总类:', node)
// 调用 API 删除根节点
await deleteCategories(node.id)
},
levels: [
{
depth: 0,
addKey: 'add-province',
addText: '添加省市',
sort: 'sortOrder',
allowDelete: true,
onAdd: async (parentNode: any, newNode: any, allChildren: any[]) => {
console.log('添加省市 - 父节点:', parentNode, '新节点:', newNode, '所有子节点:', allChildren)
// 检查父节点ID是否有效不是临时ID
if (parentNode.id.includes('-')) {
console.error('父节点未保存,无法添加子节点')
throw new Error('请先保存父节点')
}
// 计算子节点数量作为新的 sortOrder
const newSortOrder = allChildren.length + 1
console.log('父节点下子节点数量:', allChildren.length, '新 sortOrder:', newSortOrder)
// 调用 API 保存省市节点 - 直接使用字符串ID
// 生成唯一的编码region + 随机字符串
const uniqueCode = 'region-' + generateRandomCode()
await createCategoriesTree({
parentId: parentNode.id, // 直接使用字符串ID
code: uniqueCode,
name: newNode.label,
nodeType: "region",
sortOrder: newSortOrder
}).then(res => {
if(res){
newNode.id = String(res)
}
})
},
onDelete: async (node: any) => {
console.log('删除省市:', node)
// 调用 API 删除省市节点
await deleteCategoriesTree(node.id)
}
},
{
depth: 1,
addKey: 'add-specialty',
addText: '添加工料机专业',
sort: 'sortOrder',
allowDelete: true,
onAdd: async (parentNode: any, newNode: any, allChildren: any[]) => {
console.log('添加工料机专业 - 父节点:', parentNode, '新节点:', newNode, '所有子节点:', allChildren)
// 检查父节点ID是否有效不是临时ID
if (parentNode.id.includes('-')) {
console.error('父节点未保存,无法添加子节点')
throw new Error('请先保存父节点')
}
// 计算子节点数量作为新的 sortOrder
const newSortOrder = allChildren.length + 1
console.log('父节点下子节点数量:', allChildren.length, '新 sortOrder:', newSortOrder)
// 调用 API 保存工料机专业节点 - 直接使用字符串ID
// 生成唯一的编码specialty + 随机字符串
const uniqueCode = 'specialty-' + generateRandomCode()
await createCategoriesTree({
parentId: parentNode.id, // 直接使用字符串ID
code: uniqueCode,
name: newNode.label,
nodeType: "specialty",
sortOrder: newSortOrder
}).then(res=>{
if(res){
newNode.id = String(res)
}
console.log(res)
})
},
onDelete: async (node: any) => {
console.log('删除工料机专业:', node)
// 调用 API 删除工料机专业节点
await deleteCategoriesTree(node.id)
}
},
{
depth: 2,
allowDelete: true,
onDelete: async (node: any) => {
console.log('删除第三层节点:', node)
// TODO: 调用 API 删除第三层节点
await deleteCategoriesTree(node.id)
}
}
]
}
export const createCatalogueMenuHandler = (getCategoryTreeId: () => number | string | null) => ({
rootKey: 'add-catalogue',
rootText: '添加目录',
onRootAdd: async (newNode: any, allRootNodes: any[]) => {
console.log('添加目录:', newNode, '所有根节点:', allRootNodes)
const categoryTreeId = getCategoryTreeId()
if (!categoryTreeId) {
ElMessage.error('请先选择工料机专业')
throw new Error('categoryTreeId 未提供')
}
// 计算根节点数量作为新的 sortOrder
const newSortOrder = allRootNodes.length + 1
// 生成 code
const code = newNode.label + generateRandomCode()
try {
// 调用API创建根目录节点
const id = await createCatalogItem({
categoryTreeId: categoryTreeId,
code: code,
name: newNode.label,
parentId: null,
sortOrder: newSortOrder
})
// 更新节点数据
newNode.id = String(id)
newNode.code = code
newNode.sortOrder = newSortOrder
console.log('目录创建成功新ID:', id)
ElMessage.success('添加成功')
} catch (error) {
console.error('添加目录失败:', error)
ElMessage.error('添加失败')
throw error
}
},
onRootDelete: async (node: any) => {
console.log('删除目录:', node)
try {
await deleteCatalogItem(node.id)
ElMessage.success('删除成功')
} catch (error) {
console.error('删除目录失败:', error)
ElMessage.error('删除失败')
throw error
}
},
levels: [
{
depth: 0,
addKey: 'add-material',
addText: '添加子目录',
sort: 'sortOrder',
allowDelete: true,
onAdd: async (parentNode: any, newNode: any, allChildren: any[]) => {
console.log('添加子目录 - 父节点:', parentNode, '新节点:', newNode, '所有子节点:', allChildren)
const categoryTreeId = getCategoryTreeId()
if (!categoryTreeId) {
ElMessage.error('请先选择工料机专业')
throw new Error('categoryTreeId 未提供')
}
// 检查父节点ID是否有效不是临时ID
if (parentNode.id.includes('-')) {
ElMessage.error('请先保存父节点')
throw new Error('父节点未保存')
}
// 计算子节点数量作为新的 sortOrder
const newSortOrder = allChildren.length + 1
// 生成 code
const code = newNode.label + generateRandomCode()
console.log('父节点下子节点数量:', allChildren.length, '新 sortOrder:', newSortOrder)
try {
// 调用API创建子目录节点
const id = await createCatalogItem({
categoryTreeId: categoryTreeId,
code: code,
name: newNode.label,
parentId: parentNode.id, // 直接使用字符串ID
sortOrder: newSortOrder
})
// 更新节点数据
newNode.id = String(id)
newNode.code = code
newNode.sortOrder = newSortOrder
console.log('子目录创建成功新ID:', id)
ElMessage.success('添加成功')
} catch (error) {
console.error('添加子目录失败:', error)
ElMessage.error('添加失败')
throw error
}
},
onDelete: async (node: any) => {
console.log('删除目录:', node)
try {
await deleteCatalogItem(node.id)
ElMessage.success('删除成功')
} catch (error) {
console.error('删除目录失败:', error)
ElMessage.error('删除失败')
throw error
}
}
},
{
depth: 1,
addKey: 'add-material-child',
addText: '添加材料',
sort: 'sortOrder',
allowDelete: true,
onAdd: async (parentNode: any, newNode: any, allChildren: any[]) => {
console.log('添加材料 - 父节点:', parentNode, '新节点:', newNode, '所有子节点:', allChildren)
const categoryTreeId = getCategoryTreeId()
if (!categoryTreeId) {
ElMessage.error('请先选择工料机专业')
throw new Error('categoryTreeId 未提供')
}
// 检查父节点ID是否有效不是临时ID
if (parentNode.id.includes('-')) {
ElMessage.error('请先保存父节点')
throw new Error('父节点未保存')
}
// 计算子节点数量作为新的 sortOrder
const newSortOrder = allChildren.length + 1
// 生成 code
const code = newNode.label + generateRandomCode()
console.log('父节点下子节点数量:', allChildren.length, '新 sortOrder:', newSortOrder)
try {
// 调用API创建材料节点
const id = await createCatalogItem({
categoryTreeId: categoryTreeId,
code: code,
name: newNode.label,
parentId: parentNode.id, // 直接使用字符串ID
sortOrder: newSortOrder
})
// 更新节点数据
newNode.id = String(id)
newNode.code = code
newNode.sortOrder = newSortOrder
console.log('材料创建成功新ID:', id)
ElMessage.success('添加成功')
} catch (error) {
console.error('添加材料失败:', error)
ElMessage.error('添加失败')
throw error
}
},
onDelete: async (node: any) => {
console.log('删除材料:', node)
try {
await deleteCatalogItem(node.id)
ElMessage.success('删除成功')
} catch (error) {
console.error('删除材料失败:', error)
ElMessage.error('删除失败')
throw error
}
}
},
{
depth: 2,
allowDelete: true,
onDelete: async (node: any) => {
console.log('删除目录:', node)
try {
await deleteCatalogItem(node.id)
ElMessage.success('删除成功')
} catch (error) {
console.error('删除目录失败:', error)
ElMessage.error('删除失败')
throw error
}
}
}
]
})

View File

@@ -1,81 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch } from 'vue' import {
import { Page } from '@vben/common-ui'; createCategoryTreeMapping,
import { ElSplitter,ElSplitterPanel,ElCard,ElScrollbar } from 'element-plus'; deleteCategoryTreeMapping,
import { DbTree } from '#/components/db-tree'; getCatalogsTreeList, getCategoriesTree,
updateCategoriesTree
} from '#/api/database/materials/index';
import { DbHst } from '#/components/db-hst'; import { DbHst } from '#/components/db-hst';
import { useElementSize } from '@vueuse/core'
// import { materialsColumns } from './category' import { validatorRow } from '#/components/db-hst/validator';
import { manualRowMoveRenderer } from '#/components/db-hst/sort' import { DbTree } from '#/components/db-tree';
import { Page } from '@vben/common-ui';
import { useElementSize } from '@vueuse/core';
import { ElCard, ElMessage, ElSplitter, ElSplitterPanel } from 'element-plus';
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import { contextMenuHandler, rootColum, transformTreeData } from './category';
import { createCategories, deleteCategories, getCategoriesList, updateCategories } from '#/api/database/materials/root';
//获取元素高度 //获取元素高度
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const { height: containerHeight } = useElementSize(containerRef) const { height: containerHeight } = useElementSize(containerRef)
type Tree = { id: string; label: string; children?: Tree[] } type Tree = { id: string; label: string; children?: Tree[] }
const treeData = ref<Tree[]>([ const treeData = ref<Tree[]>([])
{
id: '1',
label: '工料机总类',
children: [
{
id: '2',
label: '广东',
children: [
{ id: '3', label: '广东工民建工料机' },
{ id: '4', label: '广东公路工料机' },
{ id: '5', label: '广东水利工料机' }
]
}
]
},
{
id: '11',
label: '工料机总类2',
children: [
{
id: '12',
label: '广西',
children: [
{ id: '13', label: '工料机1' },
{ id: '14', label: '工料机1' },
{ id: '15', label: '工料机2' }
]
}
]
}
])
const dbHstRef = ref<any>(null) const dbHstRef = ref<any>(null)
const handleSelect = (node: Tree) => {
if (dbHstRef.value && typeof dbHstRef.value.loadData === 'function') {
// console.log('hstData.value',hstData.value)
// dbHstRef.value.loadData(dbHstRef.value.defaultData(hstData.value))
}
}
// 直接使用配置对象DbTree 会自动创建 HierarchyContextMenuHandler
const contextMenuHandler = {
rootKey: 'add-category',
rootText: '添加总类',
levels: [
{
depth: 0,
addKey: 'add-province',
addText: '添加省市',
allowDelete: true
},
{
depth: 1,
addKey: 'add-specialty',
addText: '添加工料机专业',
allowDelete: true
},
{
depth: 2,
allowDelete: true
}
]
}
const currentNode = ref<any>(null)
const materialsColumns:any[] = [ const materialsColumns:any[] = [
// {type:'text',data:'sort',title:'⇅', renderer: manualRowMoveRenderer, // {type:'text',data:'sort',title:'⇅', renderer: manualRowMoveRenderer,
@@ -83,55 +33,304 @@ const materialsColumns:any[] = [
// readOnly: true, // readOnly: true,
// editor: false, // editor: false,
// }, // },
{type:'text',data:'code',title:'编码'}, {type:'text',data:'sortOrder',title:'序号',readOnly: true, allowInvalid: true, className: 'htCenter'},
{type:'text',data:'name',title:'名称'}, {type:'text',data:'name',title:'名称',readOnly: true, allowInvalid: true},
{type:'text',data:'category',title:'类别', {type:'text',data:'code',title:'类别', allowInvalid: true,
renderer: 'db-dropdown', renderer: 'db-dropdown',
source: ['人', '人机', '材', '机'], isOnlySelect: true,
readOnly: true, // source 会在 category.vue 中动态设置
}, onAfterSelect: (instance: any, row: number, column: number, oldValue: any, newValue: string, optData?: any) => {
{type:'text',data:'priceExTax',title:'除税基价代码'}, console.log('选择后回调:', { row, column, oldValue, newValue, optData })
{type:'text',data:'priceInTax',title:'含税基价代码'},
{type:'text',data:'priceExTaxComp',title:'除税编制代码'},
{type:'text',data:'priceInTaxComp',title:'含税编制代码'},
]
let i = 0
const emptyRow = materialsColumns.reduce((acc, col) => {
acc[col.data] = ''
return acc
}, {} as Record<string, any>)
const test = Array.from({ length: 30 }, () => ({ ...emptyRow }));
test.forEach(e=>{
e.code = i++
})
let dbSettings = { if(newValue == oldValue){
columns: materialsColumns, return
data: test, }
// manualRowMove: true,
const sortOrder = Math.max(...instance.getSourceData().map(item => Number(item.sortOrder ?? 0))) + 1
const data = {
categoryTreeId: currentNode.value.id,
categoryId: optData.id,
sortOrder: sortOrder
}
createCategoryTreeMapping(data).then(res=>{
const rowData = instance.getSourceDataAtRow(row)
instance.setDataAtRowProp([
[row, 'id', optData.id],
[row, 'sortOrder', rowData.sortOrder ?? sortOrder ],
[row, 'name', optData.name],
// [row, 'code', selectedValue]
],'createMapping')
ElMessage.success('绑定成功')
}).catch(err => {
console.error('绑定失败', err)
ElMessage.error('绑定失败')
})
console.log(data)
}
} }
]
let rootSettings = {
columns: rootColum,
// startRows: 1,
data: [],
// manualRowMove: true,
contextMenu: {
items: {
remove_row: {
name: '移除行',
callback: function(key: string, selection: any[]) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
console.log('删除工料机类别字典:', rowData)
if (rowData?.id) {
deleteCategories(rowData.id).then(res => {
console.log('deleteCategories 成功')
ElMessage.success('删除成功')
this.alter('remove_row', selectedRow, 1)
}).catch(err => {
console.error('删除失败', err)
})
} else {
// 没有 ID 直接删除
this.alter('remove_row', selectedRow, 1)
}
},
}
}
},
async afterChange(changes, source) {
console.log('source',source)
if (!changes || source === 'loadData' || source === 'updateId' || source === 'createMapping') return
// 如果有空单元格,提前返回,不执行后续操作
if(!validatorRow(this, changes)){
return
}
const row = changes[0][0]; // 获取行索引
const rowData = this.getSourceDataAtRow(row)
if (rowData.id == null) {
console.log('创建工料机类别字典', rowData )
// 调用创建接口 - 使用工料机类别字典接口
createCategories(rowData).then(res => {
console.log('createCategories 成功', res)
// 更新当前行ID - 使用自定义source避免循环
this.setDataAtRowProp(row, 'id', res, 'updateId');
ElMessage.success('新增成功')
}).catch(err => {
console.error('新增失败', err)
})
} else {
// 调用更新接口 - 使用工料机类别字典接口
console.log('更新工料机类别字典', rowData)
updateCategories(rowData).then(res => {
console.log('updateCategories 成功', res)
ElMessage.success('更新成功')
}).catch(err => {
console.error('更新失败', err)
})
}
}
}
let treeSettings = {
columns: materialsColumns,
// startRows: 1,
data: [],
// manualRowMove: true,
contextMenu: {
items: {
remove_row: {
name: '移除行',
callback: function(key: string, selection: any[]) {
const selectedRow = selection[0].start.row
const rowData = this.getSourceDataAtRow(selectedRow)
console.log(currentNode.value.id,rowData.id)
if (rowData?.id) {
deleteCategoryTreeMapping(currentNode.value.id,rowData.id).then(res => {
console.log('deleteCategoryTreeMapping')
ElMessage.success('删除成功')
this.alter('remove_row', selectedRow, 1)
}).catch(err => {
console.error('删除失败', err)
ElMessage.error('删除失败')
})
} else {
// 没有 ID 直接删除
this.alter('remove_row', selectedRow, 1)
}
},
}
}
},
//需要重置
afterChange(changes, source) {
}
}
let dbSettings = {
...rootSettings
}
const handleSelect = (node: any) => {
if (dbHstRef.value && typeof dbHstRef.value.loadData === 'function') {
console.log('handleSelect',node)
currentNode.value = node
// 检查是否是临时ID包含'-'
if (node.id.includes('-')) {
console.warn('临时节点,无法加载数据:', node.id)
return
}
// 如果是省市节点非ROOT显示空白页并禁用右键菜单
if(node.nodeType == "region" && node.code != "ROOT"){
contextMenuItems.value = [] // 清空自定义右键菜单
const emptySettings = {
columns: [],
data: [],
contextMenu: false, // 禁用右键菜单
readOnly: true
}
dbHstRef?.value.hotInstance.updateSettings(emptySettings)
dbHstRef.value.loadData([])
dbHstRef.value.hotInstance.render()
return
}
if(node.nodeType == "region" && node.code == "ROOT"){
// 恢复自定义右键菜单
contextMenuItems.value = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
if(currentNode.value == null){
ElMessage.error("请选择节点");
return
}
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
nextTick(() => {
hotInstance.render()
})
}
}
]
dbHstRef?.value.hotInstance.updateSettings(rootSettings)
dbHstRef.value.hotInstance.render()
getCategoriesList().then(res=>{
console.log('region:', res)
dbHstRef.value.loadData(res)
})
}else {
// 恢复自定义右键菜单
contextMenuItems.value = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
if(currentNode.value == null){
ElMessage.error("请选择节点");
return
}
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
nextTick(() => {
hotInstance.render()
})
}
}
]
dbHstRef?.value.hotInstance.updateSettings(treeSettings)
dbHstRef.value.hotInstance.render()
getCategoriesTree(node.id).then(res=>{
console.log('getCategories:', res)
dbHstRef.value.loadData(res)
dbHstRef.value.hotInstance.render()
})
setTimeout(() => {
getCategoriesList().then(res=>{
//{id: 5, code: "设", name: "设备费", sortOrder: 5}
//res.data
const transformedData = res.map((item: any) => ({
value: item.code,
label: item.code,
data: item
}))
// catalogsList.value = transformedData
console.log('getCatalogItemsAllowedList',transformedData)
// 更新 topHstRef 的 type 列的 source
if (dbHstRef.value?.hotInstance) {
const typeColIndex = materialsColumns.findIndex((col: any) => col.data === 'code')
if (typeColIndex !== -1) {
const columns = dbHstRef.value.hotInstance.getSettings().columns
columns[typeColIndex].source = transformedData
dbHstRef.value.hotInstance.updateSettings({
columns: columns
})
}
}
})
}, 500);
}
}
}
const handleEditSave = async (node: any, oldLabel: string, newLabel: string) => {
console.log('节点编辑保存:', { node, oldLabel, newLabel })
// 处理省市节点和工料机专业节点的更新
if(node.nodeType == "region" || node.nodeType == "specialty"){
const {id,parentId,code,nodeType,sortOrder} = node
await updateCategoriesTree({id,parentId,code,name:newLabel,nodeType,sortOrder}).then(res=>{
ElMessage.success('更新成功')
}).catch(err => {
console.error('更新失败', err)
ElMessage.error('更新失败')
})
}
}
const contextMenuItems = ref([
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
if(currentNode.value == null){
ElMessage.error("请选择节点");
return
}
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
])
// const loadingPlugin = ref()
onMounted(() => { onMounted(() => {
getCatalogsTreeList().then(res=>{
console.log('原始数据:', res)
treeData.value = transformTreeData(res)
console.log('转换后数据:', treeData.value)
})
setTimeout(() => { setTimeout(() => {
console.log('containerHeight.value',containerHeight.value) // console.log('containerHeight.value',containerHeight.value)
dbHstRef?.value.hotInstance.updateSettings({ dbHstRef?.value.hotInstance.updateSettings({
height: containerHeight.value, height: containerHeight.value,
}) })
// loadingPlugin.value = dbHstRef?.value.hotInstance.getPlugin('loading');
// 生成模拟数据 // loadingPlugin.value.show();
const categories = ['人', '人机', '材', '机'] // loadingPlugin.value.hide();
const mockData = Array.from({ length: 300 }, (_, index) => ({ // dbHstRef?.value.loadData(mockData)
code: `MAT${String(index + 1).padStart(6, '0')}`, // dbHstRef?.value.hotInstance.render()
name: `材料名称${index + 1}`,
category: categories[index % categories.length],
priceExTax: `EX${String(Math.floor(Math.random() * 10000)).padStart(4, '0')}`,
priceInTax: `IN${String(Math.floor(Math.random() * 10000)).padStart(4, '0')}`,
priceExTaxComp: `EXCOMP${String(Math.floor(Math.random() * 10000)).padStart(4, '0')}`,
priceInTaxComp: `INCOMP${String(Math.floor(Math.random() * 10000)).padStart(4, '0')}`,
}))
dbHstRef?.value.loadData(mockData)
dbHstRef?.value.hotInstance.render()
}, 200); }, 200);
}) })
onUnmounted(() => {}) onUnmounted(() => {})
@@ -142,12 +341,13 @@ onUnmounted(() => {})
<ElSplitter> <ElSplitter>
<ElSplitterPanel size="15%" :min="200"> <ElSplitterPanel size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef"> <ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef">
<DbTree :height="containerHeight" :data="treeData" @select="handleSelect" :defaultExpandedKeys="2" :context-menu-handler="contextMenuHandler" :search="false" /> <DbTree :height="containerHeight" :data="treeData" @select="handleSelect" @edit-save="handleEditSave"
:defaultExpandedKeys="2" :context-menu-handler="contextMenuHandler" :search="false" />
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
<ElSplitterPanel :min="200"> <ElSplitterPanel :min="200">
<ElCard class="w-full h-full" body-class="!p-0 h-full" > <ElCard class="w-full h-full" body-class="!p-0 h-full">
<DbHst ref="dbHstRef" :settings="dbSettings" class=""></DbHst> <DbHst ref="dbHstRef" :settings="dbSettings" :contextMenuItems="contextMenuItems"></DbHst>
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
</ElSplitter> </ElSplitter>

View File

@@ -0,0 +1,182 @@
import { deleteResourceMerged, getResourceItemsPage, updateResourceMerged } from '#/api/database/materials/index'
import { DICT_TYPE } from '@vben/constants'
import { getDictOptions } from '@vben/hooks'
import { ElMessage } from 'element-plus'
import { nextTick, ref } from 'vue'
// import { getResourceItemsPage, deleteResourceMerged } from '#/api/database/materials/index'
// 字典渲染器
const dictRenderer = (instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) => {
const source = cellProperties.source || []
const option = source.find((item: any) => item.value === value)
td.innerHTML = option?.label || value || ''
td.style.textAlign = 'center'
return td
}
export const useBottomTable = (mergedId: any) => {
const bottomHstRef = ref<any>(null)
// 处理定额消耗量失去焦点事件
const handleQuotaConsumptionBlur = async (row: number, col: number, hotInstance: any) => {
const rowData = hotInstance.getSourceDataAtRow(row)
// 只有当行数据存在ID时才更新已保存的数据
if (!rowData?.id) {
ElMessage.warning('请先保存数据后再修改定额消耗量')
return
}
// 验证必要字段
if (!rowData.sourceId) {
ElMessage.error('数据源ID不存在无法更新')
return
}
// 获取定额消耗量的值
const quotaConsumption = rowData.quotaConsumption
try {
// 调用更新接口,只更新定额消耗量字段
await updateResourceMerged({
id: rowData.id,
mergedId: mergedId.value,
sourceId: rowData.sourceId,
quotaConsumption: quotaConsumption
})
ElMessage.success('定额消耗量更新成功')
} catch (error) {
ElMessage.error('定额消耗量更新失败')
console.error('更新定额消耗量失败:', error)
}
}
const bottomColumns = ref<any[]>([
{ type: 'text', data: 'code', title: '编码', readOnly: true },
{ type: 'text', data: 'name', title: '名称', readOnly: true},
{ type: 'text', data: 'spec', title: '型号规格', readOnly: true },
{ type: 'text', data: 'categoryName', title: '类别', readOnly: true },
{ type: 'text', data: 'unit', title: '单位', readOnly: true,
renderer: dictRenderer, source: getDictOptions(DICT_TYPE.MATERIAL_UNIT, 'string')
},
{ type: 'numeric', data: 'taxRate', title: '税率', readOnly: true },
{ type: 'numeric', data: 'taxExclBasePrice', title: '除税基价', readOnly: true },
{ type: 'numeric', data: 'taxInclBasePrice', title: '含税基价', readOnly: true },
{ type: 'numeric', data: 'taxExclCompilePrice', title: '除税编制价', readOnly: true },
{ type: 'numeric', data: 'taxInclCompilePrice', title: '含税编制价', readOnly: true },
{ type: 'numeric', data: 'quotaConsumption', title: '定额消耗量' },
{ type: 'numeric', data: 'taxExclBaseTotal', title: '除税基价合价', readOnly: true },
{ type: 'numeric', data: 'taxInclBaseTotal', title: '含税基价合价', readOnly: true },
{ type: 'numeric', data: 'taxExclCompileTotal', title: '除税编制价合价', readOnly: true },
{ type: 'numeric', data: 'taxInclCompileTotal', title: '含税编制价合价', readOnly: true },
])
const bottomContextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
if (mergedId.value == null) {
ElMessage.error("请选择上方某一行")
return
}
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
}
},
]
const bottomDbSettings = {
data: [],
columns: bottomColumns.value,
colWidths: 110,
afterChange: (changes: any, source: string) => {
// 只处理用户编辑触发的变更
if (source === 'edit' && changes) {
changes.forEach((change: any) => {
const [row, prop, oldValue, newValue] = change
// 只处理定额消耗量字段的变更
if (prop === 'quotaConsumption' && oldValue !== newValue) {
const hotInstance = bottomHstRef.value?.hotInstance
if (hotInstance) {
handleQuotaConsumptionBlur(row, 0, hotInstance)
}
}
})
}
},
contextMenu: {
items: {
row_above: {
name: '在上方插入行',
callback: function(key: string, selection: any[]) {
const selectedRow = selection[0].start.row
this.alter('insert_row_above', selectedRow, 1)
},
},
row_below: {
name: '在下方插入行',
callback: function(key: string, selection: any[]) {
const selectedRow = selection[0].start.row
this.alter('insert_row_below', selectedRow, 1)
},
},
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)
}
},
}
}
}
}
const loadData = async (mergedIdValue: number) => {
if (!mergedIdValue) {
bottomHstRef.value?.loadData([])
return
}
const res = await getResourceItemsPage({ mergedId: mergedIdValue })
bottomHstRef.value?.loadData(res.list)
bottomHstRef.value?.hotInstance?.render()
return res
}
const updateHeight = (height: number) => {
if (bottomHstRef.value?.hotInstance) {
bottomHstRef.value.hotInstance.updateSettings({ height })
}
}
const updateNameColumn = (selectRender: any, callbacks: any) => {
bottomColumns.value = bottomColumns.value.map(col =>
col.data === 'name'
? { ...col, renderer: selectRender, ...callbacks }
: col
)
bottomDbSettings.columns = bottomColumns.value
}
return {
bottomHstRef,
bottomColumns,
bottomDbSettings,
bottomContextMenuItems,
loadData,
updateHeight,
updateNameColumn,
handleQuotaConsumptionBlur
}
}

View File

@@ -0,0 +1,41 @@
import { ref } from 'vue'
export const useCalcDialog = (hotInstanceRef: any) => {
const calcDialogVisible = ref(false)
const currentRow = ref<number>(0)
const currentCalcValue = ref<string>('')
const openDialog = (row: number) => {
currentRow.value = row
if (hotInstanceRef.value?.hotInstance) {
const currentValue = hotInstanceRef.value.hotInstance.getDataAtRowProp(row, 'calcBase')
if (currentValue && typeof currentValue === 'object' && currentValue.formula) {
currentCalcValue.value = currentValue.formula
} else if (typeof currentValue === 'string') {
currentCalcValue.value = currentValue
} else {
currentCalcValue.value = ''
}
}
calcDialogVisible.value = true
}
const handleConfirm = (result: { formula: string; variables: Record<string, number | { categoryId: number; priceField: string }> }) => {
if (hotInstanceRef.value?.hotInstance && result.formula) {
hotInstanceRef.value.hotInstance.setDataAtRowProp([
[currentRow.value, 'calcBase', result],
[currentRow.value, 'calcBase.formula', result.formula]
])
hotInstanceRef.value.hotInstance.render()
}
}
return {
calcDialogVisible,
currentCalcValue,
openDialog,
handleConfirm
}
}

View File

@@ -0,0 +1,27 @@
import { ref } from 'vue'
import { getCatalogsTreeList, getCatalogItemsAllowedList } from '#/api/database/materials/index'
import { transformTreeData } from '../category'
export const useCategoryTree = () => {
const categoryTreeData = ref<any[]>([])
const catalogsTreeId = ref<string>()
const loadCategoryTree = async () => {
const res = await getCatalogsTreeList()
categoryTreeData.value = transformTreeData(res)
return res
}
const handleSelect = async (node: any) => {
catalogsTreeId.value = node.id
const res = await getCatalogItemsAllowedList(node.id)
return res
}
return {
categoryTreeData,
catalogsTreeId,
loadCategoryTree,
handleSelect
}
}

View File

@@ -0,0 +1,75 @@
import { getCatalogsTreeItems, updateCatalogItem } from '#/api/database/materials/index'
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
import { createCatalogueMenuHandler, transformTreeData } from '../category'
export const useDetailTree = (catalogsTreeId: any) => {
const detailTreeData = ref<any[]>([])
const catalogItemId = ref<number | null>()
// const catalogsTreeId = ref<string>()
const catalogueMenuHandler = createCatalogueMenuHandler(()=>catalogsTreeId.value)
const loadDetailTree = async (catalogId: string) => {
const res = await getCatalogsTreeItems(catalogId)
detailTreeData.value = transformTreeData(res)
return res
}
const handleSelect = (node: any) => {
catalogItemId.value = parseInt(node.id)
return catalogItemId.value
}
const handleEditSave = async (node: any, oldLabel: string, newLabel: string) => {
console.log('保存节点名称修改 - 完整节点数据:', node)
console.log('旧名称:', oldLabel, '新名称:', newLabel)
// 检查节点ID是否有效不是临时ID
if (node.id.includes('-')) {
ElMessage.error('请先保存节点')
throw new Error('节点未保存')
}
// 确保 code 字段存在,如果不存在则使用 name 或 label
const code = node.code || node.name || node.label || newLabel
// 从 path 数组中提取 parentPath去掉最后一个元素即当前节点的 code
let parentPath: string[] | undefined = undefined
if (node.path && Array.isArray(node.path) && node.path.length > 1) {
parentPath = node.path.slice(0, -1)
}
console.log('计算的 parentPath:', parentPath)
try {
// 调用API更新节点名称使用 parentPath 而不是 parentId
await updateCatalogItem({
id: node.id,
categoryTreeId: catalogsTreeId.value,
code: code,
name: newLabel,
unit: node.unit,
parentPath: parentPath,
sortOrder: node.sortOrder,
attributes: node.attributes
})
ElMessage.success('修改成功')
} catch (error) {
console.error('修改节点名称失败:', error)
ElMessage.error('修改失败')
throw error
}
}
return {
detailTreeData,
catalogItemId,
catalogsTreeId,
catalogueMenuHandler,
loadDetailTree,
handleSelect,
handleEditSave
}
}

View File

@@ -0,0 +1,179 @@
import { createResourceMerged, getCatalogItemsPage, updateResourceMerged } from '#/api/database/materials/index'
import { createPopoverCellRenderer, usePopoverClickOutside } from '#/components/db-hst/popover'
import { DICT_TYPE } from '@vben/constants'
import { getDictOptions } from '@vben/hooks'
import type { PopoverInstance, RowEventHandlers } from 'element-plus'
import { computed, h, reactive, ref } from 'vue'
export const useItemSelect = (mergedId: any, catalogItemId: any) => {
const visible = ref(false)
const buttonRef = ref<HTMLElement | null>()
const popoverRef = ref<PopoverInstance>()
const popoverTableData = ref<any[]>([])
const itemSelectPopover = ref<any>()
const popoverPagination = reactive({
pageNo: 1,
pageSize: 20,
total: 0,
loading: false,
hasMore: true
})
const unitLabelByValue = computed(() => new Map(getDictOptions(DICT_TYPE.MATERIAL_UNIT, 'string').map((option) => [option.value, option.label])))
const selectRender = createPopoverCellRenderer({
visible,
buttonRef,
})
const columnsStyle = {
borderLeft: '1px solid #e4e4e7',
background: '#f0f0f0',
color: '#373737',
fontWeight: 400
}
const popoverColumns = computed(() => [
{ key: 'code', dataKey: 'code', title: '编码', width: 120 },
{ key: 'name', dataKey: 'name', title: '名称', width: 150 },
{ key: 'spec', dataKey: 'spec', title: '型号规格', width: 120 },
{ key: 'type', dataKey: 'type', title: '类别', width: 60, align: 'center' },
{ key: 'unit', dataKey: 'unit', title: '单位', width: 60, align: 'center', cellRenderer: ({ cellData }: { cellData: any }) => {
const label = cellData == null ? '' : unitLabelByValue.value.get(cellData) ?? String(cellData)
return h('span', label)
}, },
{ key: 'taxRate', dataKey: 'taxRate', title: '税率', width: 60, align: 'right' },
{ key: 'taxExclBasePrice', dataKey: 'taxExclBasePrice', title: '除税基价', width: 90, align: 'right' },
{ key: 'taxInclBasePrice', dataKey: 'taxInclBasePrice', title: '含税基价', width: 90, align: 'right' },
{ key: 'taxExclCompilePrice', dataKey: 'taxExclCompilePrice', title: '除税编制价', width: 90, align: 'right' },
{ key: 'taxInclCompilePrice', dataKey: 'taxInclCompilePrice', title: '含税编制价', width: 90, align: 'right' },
].map(item => ({
...item,
style: {
...columnsStyle,
...(item.key === 'taxInclCompilePrice' ? { borderRight: '1px solid #e4e4e7' } : {})
}
})))
const onClickOutside = () => {
popoverRef.value?.hide()
}
usePopoverClickOutside(visible, onClickOutside)
const getTableDataList = async (query: any, append: boolean = false) => {
if (popoverPagination.loading) return
popoverPagination.loading = true
try {
const res = await getCatalogItemsPage({
...query,
catalogItemId: catalogItemId.value, // 添加 catalogItemId 参数
includeChildren: true, // 包含子节点
pageNo: popoverPagination.pageNo,
pageSize: popoverPagination.pageSize
})
popoverPagination.total = res.total || 0
popoverPagination.hasMore = (popoverPagination.pageNo * popoverPagination.pageSize) < popoverPagination.total
if (append) {
popoverTableData.value = [...popoverTableData.value, ...(res.list || [])]
} else {
popoverTableData.value = res.list || []
}
return res.list || []
} finally {
popoverPagination.loading = false
}
}
const handleTableEndReached = () => {
if (popoverPagination.hasMore && !popoverPagination.loading) {
popoverPagination.pageNo++
getTableDataList({}, true)
}
}
const rowEventHandlers: RowEventHandlers = {
onClick: ({ rowIndex, event, rowData }) => {
event.stopPropagation()
visible.value = false
onClickOutside()
const { row, col, prop, value, currentRowData, instance, td } = itemSelectPopover.value
setTimeout(() => {
instance.setDataAtRowProp([
[row, 'code', rowData.code || ''],
[row, 'name', rowData.name || ''],
[row, 'spec', rowData.spec || ''],
[row, 'type', rowData.type || ''],
[row, 'unit', rowData.unit || ''],
[row, 'categoryName', rowData.categoryName || ''],
[row, 'taxRate', rowData.taxRate || ''],
[row, 'taxExclBasePrice', rowData.taxExclBasePrice],
[row, 'taxInclBasePrice', rowData.taxInclBasePrice],
[row, 'taxExclCompilePrice', rowData.taxExclCompilePrice],
[row, 'taxInclCompilePrice', rowData.taxInclCompilePrice],
[row, 'mergedId', mergedId.value],
[row, 'sourceId', rowData.id],
])
instance.render()
console.log(rowData,{ mergedId: mergedId.value, sourceId: rowData.id })
if (currentRowData.id == null) {
createResourceMerged({ mergedId: mergedId.value, sourceId: rowData.id }).then(res => {
instance.setDataAtRowProp([
[row, 'id', res],
],'updateId')
})
} else {
updateResourceMerged({
id: currentRowData.id,
mergedId: mergedId.value,
sourceId: rowData.id,
quotaConsumption: currentRowData.quotaConsumption || null
}).then(res => {
console.log('updateResourceMerged', res)
})
}
}, 200)
},
}
const createCallbacks = () => ({
focusCallback: async (row: any, col: any, prop: any, value: any, rowData: any, instance: any, td: any) => {
popoverPagination.pageNo = 1
popoverPagination.hasMore = true
await getTableDataList({}, false)
itemSelectPopover.value = { row, col, prop, value, currentRowData: rowData, instance, td }
},
selectCallback: async (row: any, col: any, prop: any, value: any, rowData: any, instance: any, td: any) => {
popoverPagination.pageNo = 1
popoverPagination.hasMore = true
await getTableDataList({}, false)
itemSelectPopover.value = { row, col, prop, value, currentRowData: rowData, instance, td }
},
inputCallback: async (row: any, col: any, prop: any, value: any, rowData: any, instance: any, td: any) => {
popoverPagination.pageNo = 1
popoverPagination.hasMore = true
await getTableDataList({ name: value.trim() }, false)
itemSelectPopover.value = { row, col, prop, value, currentRowData: rowData, instance, td }
}
})
return {
visible,
buttonRef,
popoverRef,
popoverTableData,
popoverColumns,
popoverPagination,
selectRender,
rowEventHandlers,
handleTableEndReached,
createCallbacks
}
}

View File

@@ -0,0 +1,231 @@
import { createResourceItems, deleteResourceItems, getCatalogItemsAllowedList, getCatalogItemsPage, updateResourceItems } from '#/api/database/materials/index'
import { useParentChildLineNestedRowsFalse } from '#/components/db-hst/nestedRows'
import { validatorRow } from '#/components/db-hst/validator'
import { DICT_TYPE } from '@vben/constants'
import { getDictOptions } from '@vben/hooks'
import { ElMessage } from 'element-plus'
import { nextTick, ref } from 'vue'
import { nameRenderer } from '../machine'
export const useTopTable = (catalogItemId: any, mergedId: any, onMergedIdChange?: (id: number) => void) => {
const topHstRef = ref<any>(null)
const selectedRow = ref<any>(null)
const calcTableData = ref<any[]>([])
const { load, codeRenderer, levelRenderer, handleRowOperation, initSchema, highlightDeselect } = useParentChildLineNestedRowsFalse({
getHotInstance: () => topHstRef.value?.hotInstance,
})
const topColumns:any[] = [
{ type: 'text', data: 'code', title: '编码', renderer: codeRenderer, required: true, autoWidth: true },
{ type: 'text', data: 'name', title: '名称', required: true, renderer: nameRenderer },
{ type: 'text', data: 'spec', title: '型号规格', required: true },
{ type: 'text', data: 'type', title: '类别', required: true, renderer: 'db-dropdown', source: [] },
{ type: 'text', data: 'unit', title: '单位', required: true, renderer: 'db-dropdown', source: getDictOptions(DICT_TYPE.MATERIAL_UNIT, 'string') },
{ type: 'numeric', data: 'taxRate', title: '税率', required: true },
{ type: 'numeric', data: 'taxExclBasePrice', title: '除税基价代码' },
{ type: 'numeric', data: 'taxInclBasePrice', title: '含税基价代码' },
{ type: 'numeric', data: 'taxExclCompilePrice', title: '除税编制代码' },
{ type: 'numeric', data: 'taxInclCompilePrice', title: '含税编制代码' },
]
const topContextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
if (catalogItemId.value == null) {
ElMessage.error("请选择")
return
}
handleRowOperation(hotInstance, 'append')
nextTick(() => {
hotInstance.render()
})
}
},
]
const topDbSettings = {
data: [],
dataSchema: initSchema(topColumns),
colWidths: 100,
columns: topColumns,
rowHeaders: false,
nestedRows: false,
bindRowsWithHeaders: true,
contextMenu: {
items: {
custom_row_above: {
name: '在上方插入行',
callback: function() {
handleRowOperation(this, 'above')
}
},
custom_row_below: {
name: '在下方插入行',
callback: function() {
handleRowOperation(this, 'below')
}
},
separator2: '---------',
remove_row: {
name: '删除行',
callback: function() {
const selected = this.getSelected()
const row = selected[0][0]
const rowData = this.getSourceDataAtRow(row)
handleRowOperation(this, 'delete')
deleteResourceItems(rowData.id).then(res => {
console.log('deleteResourceItems', res)
})
}
},
}
},
currentRowClassName: 'row-highlight',
beforeOnCellMouseDown(event: MouseEvent, coords: any, TD: HTMLTableCellElement) {
if (event.button !== 0) return
selectedRow.value = coords
const rowObj = topHstRef.value.hotInstance.getSourceDataAtRow(coords.row)
if (rowObj?.id) {
mergedId.value = rowObj.id
if (onMergedIdChange) {
onMergedIdChange(rowObj.id)
}
}
},
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)
// 从 type 列获取对应的 categoryId
// calcTableData 包含 {id, code, name} 的类别数据
const selectedCategory = calcTableData.value.find((cat: any) => cat.code === rowData.type)
const categoryId = selectedCategory?.id || null
const sendData: any = {
catalogItemId: catalogItemId.value,
categoryId: categoryId,
...rowData
}
if (rowData.id == null) {
createResourceItems(sendData).then(res => {
this.setDataAtRowProp(row, 'id', res, 'updateId')
ElMessage.success('新增成功')
}).catch(err => {
console.error('新增失败', err)
})
} else {
updateResourceItems(sendData).then(res => {
console.log('updateResourceItems', res)
}).catch(err => {
console.error('更新失败', err)
})
}
},
afterDeselect(){
highlightDeselect(this, selectedRow.value)
}
}
const transformToHierarchicalData = (items: any[], parentLevel: string | null = null): any[] => {
return items.map((item, index) => {
//const currentLevel = parentLevel === null ? String(index) : `${parentLevel}-${index + 1}`
const transformedItem: any = {
...item,
id: item.id || null,
level: null,
__children: []
}
topColumns.forEach((col: any) => {
if (col.data.includes('.')) {
const keys = col.data.split('.')
const rootKey = keys[0]
transformedItem[rootKey] = item[rootKey] || {}
let value = item
for (const key of keys) {
value = value?.[key]
if (value === undefined || value === null) break
}
transformedItem[col.data] = value || null
}
})
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
transformedItem.__children = transformToHierarchicalData(item.children, null)
}
return transformedItem
})
}
const loadData = async (catalogId: number) => {
catalogItemId.value = catalogId
mergedId.value = null
selectedRow.value = null
topHstRef.value.hotInstance.deselectCell()
// 清空底部表格数据
if (onMergedIdChange) {
onMergedIdChange(null as any)
}
const res = await getCatalogItemsPage({ catalogItemId: catalogId, pageNo: 1, pageSize: 10 })
const hierarchicalData = transformToHierarchicalData(res.list || [])
load(hierarchicalData)
return res
}
const loadAllowedCategories = async () => {
const res = await getCatalogItemsAllowedList(catalogItemId.value)
const transformedData = res.map((item: any) => ({
id: item.id,
code: item.code,
name: item.name, // 添加 name 字段用于表格显示
value: item.code,
label: item.name
}))
calcTableData.value = transformedData
if (topHstRef.value?.hotInstance) {
const typeColIndex = topColumns.findIndex((col: any) => col.data === 'type')
if (typeColIndex !== -1) {
const columns = topHstRef.value.hotInstance.getSettings().columns
columns[typeColIndex].source = transformedData
topHstRef.value.hotInstance.updateSettings({ columns: columns })
}
}
}
const updateHeight = (height: number) => {
if (topHstRef.value?.hotInstance) {
topHstRef.value.hotInstance.updateSettings({ height: height - 8 })
topHstRef.value.updateCodeColWidth()
}
}
const loadTopDataClear = ()=>{
selectedRow.value = null
topHstRef.value.hotInstance.deselectCell()
load([])
}
return {
topHstRef,
topColumns,
topDbSettings,
topContextMenuItems,
calcTableData,
loadData,
loadAllowedCategories,
updateHeight,
loadTopDataClear
}
}

View File

@@ -1,13 +1,107 @@
export const topColHeaders: string[] = [ export const nameRenderer = (instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) => {
'编码', // 清空单元格内容
'名称', td.innerHTML = ''
'型号规格', // 检查是否需要设置验证背景色
'类别', const cellMeta = instance.getCellMeta(row, col)
'单位', const isValid = cellMeta?.valid !== false
'税率',
'除税基价', // 如果单元格被标记为无效,设置红色背景
'含税基价', if (!isValid) {
'除税编制价', td.style.backgroundColor = '#ffbeba' // 淡红色背景
'含税编制价', } else {
'计算基数' td.style.backgroundColor = '' // 清除背景色
] }
// 获取当前行数据
const rowData = instance.getSourceDataAtRow(row)
const isMerged = rowData?.isMerged
// 创建容器
const container = document.createElement('div')
container.style.cssText = 'display: flex; justify-content: space-between; align-items: center; width: 100%; height: 100%; padding: 0 5px;'
// 创建文本显示
const textSpan = document.createElement('span')
textSpan.textContent = value || ''
textSpan.style.cssText = 'flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;'
container.appendChild(textSpan)
// 当 isMerged === 1 或 isMerged === true 时显示复合工料机图标
if (isMerged === 1 || isMerged === true) {
// 创建复合工料机图标(文件夹加号图标)
const iconSpan = document.createElement('span')
iconSpan.innerHTML = '<svg t="1766906584191" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7071" width="16" height="16"><path d="M905.874286 157.549714H417.499429l-70.948572-133.851428c0-15.798857-15.725714-23.698286-31.451428-23.698286H118.125714C55.149714 0 0 55.149714 0 118.125714v787.748572C0 968.850286 55.149714 1024 118.125714 1024h787.748572c62.976 0 118.125714-55.149714 118.125714-118.125714V275.675429c0-62.976-55.149714-118.125714-118.125714-118.125715z m39.350857 748.251429c0 23.698286-15.725714 39.497143-39.350857 39.497143H118.125714c-23.625143 0-39.350857-15.798857-39.350857-39.497143V118.198857c0-23.625143 15.725714-39.350857 39.350857-39.350857h173.348572l70.875428 133.851429c0 15.798857 15.725714 23.698286 31.451429 23.698285h512c23.698286 0 39.497143 15.725714 39.497143 39.350857v630.198858l-0.073143-0.146286z" fill="#2c48fc" p-id="7072"></path><path d="M475.428571 548.571429v-146.285715a36.571429 36.571429 0 0 1 73.142858 0v146.285715h146.285714a36.571429 36.571429 0 0 1 0 73.142857h-146.285714v146.285714a36.571429 36.571429 0 1 1-73.142858 0v-146.285714h-146.285714a36.571429 36.571429 0 0 1 0-73.142857h146.285714z" fill="#2c48fc" p-id="7073"></path></svg>'
iconSpan.style.cssText = 'margin-left: 5px; display: inline-flex; align-items: center; flex-shrink: 0;'
iconSpan.title = '复合工料机'
container.appendChild(iconSpan)
}
td.appendChild(container)
return td
}
export const calcRenderer = (instance: any, td: HTMLElement, row: number, col: number, prop: string, value: any, cellProperties: any) => {
// 清空单元格内容
td.innerHTML = ''
// 获取当前行数据
const rowData = instance.getSourceDataAtRow(row)
const unit = rowData?.unit
// 只有当 unit === '%' 时才可编辑,否则设置为只读
if (unit !== '%') {
cellProperties.readOnly = true
// 添加只读样式
td.style.backgroundColor = '#f5f5f5'
td.style.color = '#999'
td.textContent = value || ''
return td
}
// 创建容器
const container = document.createElement('div')
container.style.cssText = 'display: flex; justify-content: space-between; align-items: center; width: 100%; height: 100%; padding: 0 5px;'
// 创建文本显示
const textSpan = document.createElement('span')
textSpan.textContent = value || ''
textSpan.style.cssText = 'flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;'
container.appendChild(textSpan)
// 只有当 unit == '%' 时才显示编辑图标
if (unit === '%') {
// 创建编辑图标
const iconSpan = document.createElement('span')
iconSpan.innerHTML = '<svg t="1766200593500" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8130" width="16" height="16"><path d="M251.2 387H320v68.8c0 1.8 1.8 3.2 4 3.2h48c2.2 0 4-1.4 4-3.3V387h68.8c1.8 0 3.2-1.8 3.2-4v-48c0-2.2-1.4-4-3.3-4H376v-68.8c0-1.8-1.8-3.2-4-3.2h-48c-2.2 0-4 1.4-4 3.2V331h-68.8c-1.8 0-3.2 1.8-3.2 4v48c0 2.2 1.4 4 3.2 4z m328 0h193.6c1.8 0 3.2-1.8 3.2-4v-48c0-2.2-1.4-4-3.3-4H579.2c-1.8 0-3.2 1.8-3.2 4v48c0 2.2 1.4 4 3.2 4z m0 265h193.6c1.8 0 3.2-1.8 3.2-4v-48c0-2.2-1.4-4-3.3-4H579.2c-1.8 0-3.2 1.8-3.2 4v48c0 2.2 1.4 4 3.2 4z m0 104h193.6c1.8 0 3.2-1.8 3.2-4v-48c0-2.2-1.4-4-3.3-4H579.2c-1.8 0-3.2 1.8-3.2 4v48c0 2.2 1.4 4 3.2 4z m-195.7-81l61.2-74.9c4.3-5.2 0.7-13.1-5.9-13.1H388c-2.3 0-4.5 1-5.9 2.9l-34 41.6-34-41.6c-1.5-1.8-3.7-2.9-5.9-2.9h-50.9c-6.6 0-10.2 7.9-5.9 13.1l61.2 74.9-62.7 76.8c-4.4 5.2-0.8 13.1 5.8 13.1h50.8c2.3 0 4.5-1 5.9-2.9l35.5-43.5 35.5 43.5c1.5 1.8 3.7 2.9 5.9 2.9h50.8c6.6 0 10.2-7.9 5.9-13.1L383.5 675z" p-id="8131"></path><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32z m-36 732H180V180h664v664z" p-id="8132"></path></svg>'
iconSpan.style.cssText = 'cursor: pointer; margin-left: 5px; display: inline-flex; align-items: center; color: black; pointer-events: auto;'
iconSpan.title = '计算基数设置'
// iconSpan.className = 'calc-icon-btn'
// 处理点击事件的函数
const handleClick = (e: Event) => {
e.preventDefault()
e.stopPropagation()
console.log('图标被点击了', { row, col, value, rowData })
// 调用 calcCallback 回调
if (cellProperties.calcCallback && typeof cellProperties.calcCallback === 'function') {
cellProperties.calcCallback(rowData, value, row, col, instance)
}
}
// 同时绑定 mousedown 和 click 事件
iconSpan.addEventListener('mousedown', handleClick, true)
// iconSpan.addEventListener('click', handleClick, true)//不知道为什么不生效
container.appendChild(iconSpan)
}
td.appendChild(container)
return td
}

View File

@@ -1,13 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch,computed } from 'vue' import { DbCalc } from '#/components/db-calc'
import { Page } from '@vben/common-ui'; import { DbHst } from '#/components/db-hst'
import { ElSplitter,ElSplitterPanel,ElCard } from 'element-plus'; import { DbTree } from '#/components/db-tree'
import { Page } from '@vben/common-ui'
import { useElementSize } from '@vueuse/core' import { useElementSize } from '@vueuse/core'
import { DbTree } from '#/components/db-tree'; import { ElCard, ElSplitter, ElSplitterPanel, ElTableV2 } from 'element-plus'
import { DbHst } from '#/components/db-hst'; import { onMounted, ref } from 'vue'
import { topColHeaders } from './machine' import { useBottomTable } from './composables/useBottomTable'
import { handleRowOperation, codeRenderer } from '#/components/db-hst/tree' import { useCalcDialog } from './composables/useCalcDialog'
// import { sourceDataObject } from '#/components/db-hst/mockData' import { useCategoryTree } from './composables/useCategoryTree'
import { useDetailTree } from './composables/useDetailTree'
import { useItemSelect } from './composables/useItemSelect'
import { useTopTable } from './composables/useTopTable'
import { calcRenderer } from './machine'
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const { height: containerHeight } = useElementSize(containerRef) const { height: containerHeight } = useElementSize(containerRef)
const topContainerRef = ref<HTMLElement | null>(null) const topContainerRef = ref<HTMLElement | null>(null)
@@ -15,409 +21,209 @@ const { height: topContainerHeight } = useElementSize(topContainerRef)
const bottomContainerRef = ref<HTMLElement | null>(null) const bottomContainerRef = ref<HTMLElement | null>(null)
const { height: bottomContainerHeight } = useElementSize(bottomContainerRef) const { height: bottomContainerHeight } = useElementSize(bottomContainerRef)
type Tree = { id: string; label: string; children?: Tree[] } const mergedId = ref<number>()
const categoryTreeData = ref<Tree[]>([ const detailTreeRef = ref<any>()
{
id: '1',
label: '工料机总类',
children: [
{
id: '2',
label: '广东',
children: [
{ id: '3', label: '广东工民建工料机' },
{ id: '4', label: '广东公路工料机' },
{ id: '5', label: '广东水利工料机' }
]
}
]
},
{
id: '11',
label: '工料机总类2',
children: [
{
id: '12',
label: '广西',
children: [
{ id: '13', label: '工料机1' },
{ id: '14', label: '工料机1' },
{ id: '15', label: '工料机2' }
]
}
]
}
])
const detailTreeData = ref<Tree[]>([
{
id: '1',
label: '目录1',
children: [
{
id: '2',
label: '黑色及有色金属',
children: [
{ id: '3', label: '钢筋' },
{ id: '4', label: '钢丝' },
{ id: '5', label: '钢纹线、网丝束' }
]
}
]
},
{
id: '11',
label: '目录2',
children: [
{
id: '12',
label: '塑料、塑胶及非金属',
children: [
{ id: '13', label: '塑料' },
{ id: '14', label: '塑胶' },
{ id: '15', label: '非金属' }
]
}
]
}
])
const topColumns = ref<any[]>([
{type:'text',data:'code',title:'编码', renderer: codeRenderer},
{type:'text',data:'name',title:'名称'},
{type:'text',data:'spec',title:'型号规格'},
{type:'text',data:'category',title:'类别'},
{type:'text',data:'unit',title:'单位'},
{type:'text',data:'taxRate',title:'税率'},
{type:'text',data:'priceExTax',title:'除税基价'},
{type:'text',data:'priceInTax',title:'含税基价'},
{type:'text',data:'priceExTaxComp',title:'除税编制价'},
{type:'text',data:'priceInTaxComp',title:'含税编制价'},
{type:'text',data:'calcBase',title:'计算基数'}
])
const tableHeaders = ref<string[]>([
'编码',
'名称',
'型号规格',
'类别',
'单位',
'税率',
'除税基价',
'含税基价',
'除税编制价',
'含税编制价',
'计算基数'
])
// 生成 tableData 模拟数据 const {
const generateTableData = () => { categoryTreeData,
const categories = ['人', '人机', '材', '机'] catalogsTreeId,
const units = ['台', '个', 't', 'm³', 'm²', 'kg'] loadCategoryTree,
handleSelect: categoryHandleSelect
return Array.from({ length: 20 }, (_, index) => ({ } = useCategoryTree()
code: `TBL${String(index + 1).padStart(6, '0')}`,
name: `表格项目${index + 1}`,
spec: `规格${index + 1}`,
category: categories[index % categories.length],
unit: units[index % units.length],
taxRate: `${(Math.random() * 0.13 + 0.03).toFixed(2)}`,
priceExTax: (Math.random() * 8000 + 800).toFixed(2),
priceInTax: (Math.random() * 9000 + 900).toFixed(2),
priceExTaxComp: (Math.random() * 8500 + 850).toFixed(2),
priceInTaxComp: (Math.random() * 9500 + 950).toFixed(2),
calcBase: (Math.random() * 80 + 1).toFixed(2)
}))
}
const tableData = ref<any[]>(generateTableData()) const {
const bottomColumns = ref<any[]>([ detailTreeData,
{type:'text',data:'code',title:'编码'}, catalogItemId,
{type:'text',data:'name',title:'名称', catalogsTreeId: detailCatalogsTreeId,
renderer: 'db-table', catalogueMenuHandler,
customDropdownSource: tableData.value, loadDetailTree,
customGetLabel: (item: any) => item?.name, handleSelect: detailHandleSelect,
customTableHeaders: tableHeaders.value, handleEditSave: handleCatalogItemEditSave
// 自定义字段键,按顺序对应表头 } = useDetailTree(catalogsTreeId)
customFieldKeys: ['code', 'name', 'spec', 'category', 'unit', 'taxRate', 'priceExTax', 'priceInTax', 'priceExTaxComp', 'priceInTaxComp', 'calcBase'],
// 自定义搜索字段
customSearchFields: ['code', 'name', 'spec', 'category', 'unit']
},
{type:'text',data:'spec',title:'型号规格'},
{type:'text',data:'category',title:'类别'},
{type:'text',data:'unit',title:'单位'},
{type:'text',data:'taxRate',title:'税率'},
{type:'text',data:'priceExTax',title:'除税基价'},
{type:'text',data:'priceExTax1',title:'除税编制价'}, const {
{type:'text',data:'priceExTax2',title:'含税编制价'}, bottomHstRef,
{type:'text',data:'priceExTax3',title:'定额消耗量'}, bottomDbSettings,
{type:'text',data:'priceExTax4',title:'含税编制合价'}, bottomContextMenuItems,
{type:'text',data:'priceExTax5',title:'除税基价合价'}, loadData: loadBottomData,
{type:'text',data:'priceExTax6',title:'含税基价合价'}, updateHeight: updateBottomHeight,
{type:'text',data:'priceExTax7',title:'除税编制合价'}, updateNameColumn
{type:'text',data:'calcBase',title:'计算基数(隐藏)'}, } = useBottomTable(mergedId)
]) const {
// const colHeaders = ref<string[]>(topColHeaders) topHstRef,
const topHstRef = ref<any>(null) topColumns,
const bottomHstRef = ref<any>(null) topDbSettings,
let rowSchema: any = {level: null,__children: []} topContextMenuItems,
calcTableData,
// 根据 columns 的 data 字段生成对象结构 loadData: loadTopData,
topColumns.value.forEach((col: any) => { loadAllowedCategories,
if (col.data && col.data !== 'level' && col.data !== '__children') { loadTopDataClear
rowSchema[col.data] = null } = useTopTable(catalogItemId, mergedId, loadBottomData)
const {
calcDialogVisible,
currentCalcValue,
openDialog: openCalcDialog,
handleConfirm: handleCalcConfirm
} = useCalcDialog(topHstRef)
const {
visible,
buttonRef,
popoverRef,
popoverTableData,
popoverColumns,
selectRender,
rowEventHandlers,
handleTableEndReached,
createCallbacks
} = useItemSelect(mergedId, catalogItemId)
const itemCallbacks = createCallbacks()
topColumns.push({
type: 'text',
data: 'calcBase.formula',
title: '计算基数',
renderer: calcRenderer,
calcCallback: (_currentRowData: any, _value: any, row: number) => {
openCalcDialog(row)
} }
}) })
// console.log(rowSchema)
const topMock = ()=>{ updateNameColumn(selectRender, itemCallbacks)
// 生成带层级结构的模拟数据
const categories = ['人', '人机', '材', '机'] const handleCategorySelect = async (node: any) => {
const units = ['台', '个', 't', 'm³', 'm²', 'kg'] console.log(node)
await categoryHandleSelect(node)
const mockData = [] // 同步更新 detailTree 的 catalogsTreeId
let codeCounter = 1 detailCatalogsTreeId.value = node.id
await loadDetailTree(node.id)
// 生成5个父级项目 await loadTopDataClear()
for (let i = 0; i < 5; i++) { detailTreeRef?.value.setCurrentKey(null)
const parentCode = `MAC${String(codeCounter++).padStart(6, '0')}` }
const parent = {
code: parentCode, const handleDetailSelect = async (node: any) => {
name: `机械设备分类${i + 1}`, const id = detailHandleSelect(node)
spec: `规格${i + 1}`, if (id) {
category: categories[i % categories.length],
unit: units[i % units.length],
taxRate: `${(Math.random() * 0.13 + 0.03).toFixed(2)}`,
priceExTax: (Math.random() * 10000 + 1000).toFixed(2),
priceInTax: (Math.random() * 11000 + 1100).toFixed(2),
priceExTaxComp: (Math.random() * 10500 + 1050).toFixed(2),
priceInTaxComp: (Math.random() * 11500 + 1150).toFixed(2),
calcBase: (Math.random() * 100 + 1).toFixed(2),
level: String(i),
__children: []
}
// 为每个父级生成2-4个子项 await loadAllowedCategories()
const childCount = Math.floor(Math.random() * 3) + 2 await loadTopData(id)
for (let j = 0; j < childCount; j++) {
const childCode = `${parentCode}-${String(j + 1).padStart(3, '0')}`
const child = {
code: childCode,
name: `${parent.name}-子项${j + 1}`,
spec: `规格${i + 1}-${j + 1}`,
category: categories[(i + j) % categories.length],
unit: units[(i + j) % units.length],
taxRate: `${(Math.random() * 0.13 + 0.03).toFixed(2)}`,
priceExTax: (Math.random() * 5000 + 500).toFixed(2),
priceInTax: (Math.random() * 5500 + 550).toFixed(2),
priceExTaxComp: (Math.random() * 5200 + 520).toFixed(2),
priceInTaxComp: (Math.random() * 5700 + 570).toFixed(2),
calcBase: (Math.random() * 50 + 1).toFixed(2),
level: `${i}-${j + 1}`,
__children: []
}
// 为部分子项生成孙项
if (j === 0 && i < 3) {
const grandChildCount = Math.floor(Math.random() * 2) + 1
for (let k = 0; k < grandChildCount; k++) {
const grandChild = {
code: `${childCode}-${String(k + 1).padStart(2, '0')}`,
name: `${child.name}-明细${k + 1}`,
spec: `规格${i + 1}-${j + 1}-${k + 1}`,
category: categories[(i + j + k) % categories.length],
unit: units[(i + j + k) % units.length],
taxRate: `${(Math.random() * 0.13 + 0.03).toFixed(2)}`,
priceExTax: (Math.random() * 2000 + 200).toFixed(2),
priceInTax: (Math.random() * 2200 + 220).toFixed(2),
priceExTaxComp: (Math.random() * 2100 + 210).toFixed(2),
priceInTaxComp: (Math.random() * 2300 + 230).toFixed(2),
calcBase: (Math.random() * 20 + 1).toFixed(2),
level: `${i}-${j + 1}-${k + 1}`,
__children: []
}
child.__children.push(grandChild)
}
}
parent.__children.push(child)
}
mockData.push(parent)
} }
return mockData
} }
let topDbSettings = { const onResizeEnd = (_index: number, sizes: number[]) => {
data: topMock(),
dataSchema: rowSchema,
colWidths: 120,
columns: topColumns.value,
// preventOverflow: 'horizontal',
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: {}
}
},
}
const bootomMock = ()=>{
// 生成模拟数据
const categories = ['人', '人机', '材', '机']
const units = ['台', '个', 't', 'm³', 'm²', 'kg']
const mockData = Array.from({ length: 30 }, (_, index) => ({
code: `DTL${String(index + 1).padStart(6, '0')}`,
name: `明细项目${index + 1}`,
spec: `规格${index + 1}`,
category: categories[index % categories.length],
unit: units[index % units.length],
taxRate: `${(Math.random() * 0.13 + 0.03).toFixed(2)}`,
priceExTax: (Math.random() * 5000 + 500).toFixed(2),
priceExTax1: (Math.random() * 5200 + 520).toFixed(2),
priceExTax2: (Math.random() * 5800 + 580).toFixed(2),
priceExTax3: (Math.random() * 10 + 1).toFixed(2),
priceExTax4: (Math.random() * 60000 + 6000).toFixed(2),
priceExTax5: (Math.random() * 55000 + 5500).toFixed(2),
priceExTax6: (Math.random() * 62000 + 6200).toFixed(2),
priceExTax7: (Math.random() * 58000 + 5800).toFixed(2),
calcBase: (Math.random() * 100 + 1).toFixed(2)
}))
return mockData;
}
let bottomDbSettings = {
columns: bottomColumns.value,
}
const categoryHandleSelect = (node: Tree) => {
console.log('categoryhandleSelect',node)
}
const detailHandleSelect = (node: Tree) => {
if (topHstRef.value && typeof topHstRef.value.loadData === 'function') {
// console.log('hstData.value',hstData.value)
// topHstRef.value.loadData(topHstData.value)
}
// // 访问内部的 hotTableComponent
// if (dbHstRef.value?.hotTableComponent) {
// console.log('hotTableComponent', dbHstRef.value.hotTableComponent)
// }
// // 访问 hotInstance
// if (dbHstRef.value?.hotInstance) {
// dbHstRef.value?.hotInstance.loadData(topHstData.value)
// console.log('hotInstance', dbHstRef.value.hotInstance)
// }
}
// watch(
// () => topContainerHeight.value,
// (val) => {
// console.log('topContainerHeight', val);
// },
// { immediate: true },
// )
function onResizeEnd(index: number, sizes: number[]) {
// 通过 hotInstance 来操作 Handsontable
console.log(index,sizes)
onTopHeight(sizes[0]) onTopHeight(sizes[0])
onBottomHeight(sizes[1]) onBottomHeight(sizes[1])
} }
function onTopHeight(height: number){
const onTopHeight = (height: number) => {
if (topHstRef.value?.hotInstance) { if (topHstRef.value?.hotInstance) {
topHstRef.value.hotInstance.updateSettings({ topHstRef.value.hotInstance.updateSettings({ height: height - 8 })
height: height,
})
// topHstRef.value.loadData(topMock())
// 更新 code 列的宽度
topHstRef.value.updateCodeColWidth()
console.log('onResizeEnd-onTopHeight',height, 'codeColWidth:', topHstRef.value.codeColWidth);
}
}
function onBottomHeight(height: number){
if (bottomHstRef.value?.hotInstance) {
bottomHstRef.value.hotInstance.updateSettings({
height: height
})
bottomHstRef.value.loadData(bootomMock())
bottomHstRef.value.hotInstance.render()
console.log('onResizeEnd-bottomHstRef',height);
} }
} }
const onBottomHeight = (height: number) => {
updateBottomHeight(height)
}
onMounted(async () => {
await loadCategoryTree()
onMounted(() => {
setTimeout(() => { setTimeout(() => {
onTopHeight(topContainerHeight.value) onTopHeight(topContainerHeight.value)
onBottomHeight(bottomContainerHeight.value) onBottomHeight(bottomContainerHeight.value)
}, 200); }, 200)
}) })
onUnmounted(() => {
})
</script> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<ElSplitter > <ElSplitter>
<ElSplitterPanel collapsible size="15%" :min="200"> <ElSplitterPanel size="15%" :min="100">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef"> <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" /> <DbTree
:height="containerHeight"
:data="categoryTreeData"
@select="handleCategorySelect"
:defaultExpandedKeys="2"
:search="false"
/>
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
<ElSplitterPanel collapsible size="15%" :min="200">
<ElSplitterPanel size="15%" :min="100">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full"> <ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full">
<DbTree :height="containerHeight" :data="detailTreeData" @select="detailHandleSelect" :defaultExpandedKeys="2" :search="false" /> <DbTree
ref="detailTreeRef"
:height="containerHeight"
:data="detailTreeData"
@select="handleDetailSelect"
@edit-save="handleCatalogItemEditSave"
:defaultExpandedKeys="2"
:search="false"
:context-menu-handler="catalogueMenuHandler"
/>
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
<ElSplitterPanel >
<ElSplitterPanel :min="200">
<ElSplitter layout="vertical" @resize-end="onResizeEnd"> <ElSplitter layout="vertical" @resize-end="onResizeEnd">
<ElSplitterPanel collapsible size="60%" :min="200"> <ElSplitterPanel size="60%" :min="200">
<ElCard class="w-full h-full" body-class="!p-0 h-full" ref="topContainerRef"> <ElCard class="w-full h-full" body-class="!p-0 h-full" ref="topContainerRef">
<DbHst ref="topHstRef" :settings="topDbSettings"></DbHst> <DbHst ref="topHstRef" :settings="topDbSettings" :contextMenuItems="topContextMenuItems" />
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
<ElSplitterPanel collapsible :min="200">
<ElSplitterPanel :min="200">
<ElCard class="w-full h-full" body-class="!p-0 h-full" ref="bottomContainerRef"> <ElCard class="w-full h-full" body-class="!p-0 h-full" ref="bottomContainerRef">
<DbHst ref="bottomHstRef" :settings="bottomDbSettings"></DbHst> <DbHst ref="bottomHstRef" :settings="bottomDbSettings" :contextMenuItems="bottomContextMenuItems" />
</ElCard> </ElCard>
</ElSplitterPanel> </ElSplitterPanel>
</ElSplitter> </ElSplitter>
</ElSplitterPanel> </ElSplitterPanel>
</ElSplitter> </ElSplitter>
</Page>
<ElPopover
ref="popoverRef"
:virtual-ref="buttonRef"
virtual-triggering
:visible="visible"
placement="bottom"
:width="955"
:popper-style="{ height: '380px' }"
>
<ElTableV2
:columns="popoverColumns"
:data="popoverTableData"
:width="930"
:height="350"
fixed
:row-height="25"
:header-height="25"
:row-event-handlers="rowEventHandlers"
:cell-props="{ style: { background: 'transparent !important' } }"
:teleported="false"
@end-reached="handleTableEndReached"
/>
</ElPopover>
<DbCalc
v-model="calcDialogVisible"
:current-value="currentCalcValue"
@confirm="handleCalcConfirm"
:table-data="calcTableData"
:table-column="[
{ prop: 'id', label: '序号' },
{ prop: 'name', label: '名称' },
{ prop: 'code', label: '类型' }
]"
table-prop="code"
mode="resource"
/>
</Page>
</template> </template>
<style lang="css"> <style lang="css">
</style> </style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,7 @@ watch(
console.log('MarketMaterials newHeight', newHeight) console.log('MarketMaterials newHeight', newHeight)
if (newHeight && hstRef.value?.hotInstance) { if (newHeight && hstRef.value?.hotInstance) {
hstRef.value.hotInstance.updateSettings({ hstRef.value.hotInstance.updateSettings({
height: newHeight - 50 - 15// 减去 tabs 头部和 padding 的高度,滚动条 height: newHeight - 50// 减去 tabs 头部和 padding 的高度,滚动条
}) })
hstRef.value.hotInstance.render() hstRef.value.hotInstance.render()
} }

View File

@@ -0,0 +1,43 @@
// import { ref } from 'vue'
// import { getDictOptions } from '@vben/hooks';
// import { DICT_TYPE } from '@vben/constants';
// const settingRender = (
// instance: any,
// TD: HTMLTableCellElement,
// row: number,
// col: number,
// prop: any,
// value: any,
// cellProperties: any
// ) => {
// TD.innerHTML = ''
// const container = document.createElement('div')
// container.style.display = 'flex'
// container.style.alignItems = 'center'
// container.style.justifyContent = 'center'
// container.style.cursor = 'pointer'
// container.style.color = '#006be6'
// container.style.padding = '0 5px'
// const textSpan = document.createElement('span')
// textSpan.textContent = '编辑'
// container.addEventListener('click', (e) => {
// e.stopPropagation()
// })
// container.appendChild(textSpan)
// TD.appendChild(container)
// return TD
// }
// export const columns = ref<any[]>([
// { type: 'text', data: 'sorderId', title: '序号' },
// { type: 'text', data: 'name', title: '名称' },
// { type: 'text', data: 'value', title: '定额值' },
// { type: 'numeric', data: 'coefficient2', title: '调整' },
// { type: 'numeric', data: 'coefficient1', title: '调整内容' },
// { type: 'text', data: 'setting', title: '设置', renderer: settingRender, readOnly: true }
// ])

View File

@@ -1,56 +1,152 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue' import { getAdjustmentCombinedList } from '#/api/database/quota';
import { DbHst } from '#/components/db-hst' import { DbHst } from '#/components/db-hst';
import { nextTick, onMounted, ref, watch } from 'vue';
const props = defineProps<{ const props = defineProps<{
height?: number height?: number
quotaItemId?: number
}>() }>()
const topHstRef = ref<any>(null) const topHstRef = ref<any>(null)
const bottomHstRef = ref<any>(null)
// 第一个表格:调整、附注名称、附注内容 // 第一个表格:序号、名称、调整
const topColumns = ref<any[]>([ const topColumns = ref<any[]>([
{ type: 'checkbox', data: 'adjustment', title: '调整' }, {
{ type: 'text', data: 'noteName', title: '附注名称' }, type: 'text',
{ type: 'text', data: 'noteContent', title: '附注内容' } data: 'sortOrder',
title: '序号',
readOnly: true,
width: 80,
renderer: (_instance: any, td: HTMLTableCellElement, row: number) => {
td.innerHTML = String(row + 1)
td.style.textAlign = 'center'
td.style.color = '#909399'
return td
}
},
{ type: 'text', data: 'name', title: '名称', readOnly: true },
{
type: 'text',
data: 'adjustment',
title: '调整',
className: 'htCenter',
renderer: (instance: any, td: HTMLTableCellElement, row: number, _col: number, _prop: string, value: any, cellProperties: any) => {
const rowData = instance.getSourceDataAtRow(row)
const adjustmentType = rowData?.adjustmentType
// 清空单元格
td.innerHTML = ''
td.style.textAlign = 'center'
td.style.verticalAlign = 'middle'
// 根据调整类型决定显示方式
if (adjustmentType === 'dynamic_merge' || adjustmentType === 'dynamic_adjust') {
// 录入框:使用默认的文本渲染(双击可编辑)
cellProperties.readOnly = false
td.innerHTML = value || ''
return td
} else {
// 复选框:居中显示,禁止双击编辑
cellProperties.readOnly = true
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.checked = value === true || value === 1 || value === '1'
checkbox.style.cssText = 'cursor: pointer;'
// 监听复选框变化
checkbox.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement
instance.setDataAtRowProp(row, 'adjustment', target.checked)
})
// 阻止双击事件
td.addEventListener('dblclick', (e) => {
e.stopPropagation()
})
td.appendChild(checkbox)
return td
}
}
},
]) ])
const topMockData = () => { const topSettings = ref({
return Array.from({ length: 10 }, (_, index) => ({ data: [],
adjustment: index % 2 === 0, // checkbox 类型使用 boolean 值
noteName: `附注${index + 1}`,
noteContent: `附注内容说明${index + 1}`
}))
}
const topSettings = {
data: topMockData(),
columns: topColumns.value, columns: topColumns.value,
colWidths: 150, colWidths: 150,
height: 135 height: 135,
} readOnly: false, // 改为可编辑,因为调整字段需要编辑
})
// 第二个表格:名称、定额值、实际值 // 加载调整设置与明细的组合列表数据
const bottomColumns = ref<any[]>([ const loadAdjustmentDetails = async () => {
{ type: 'text', data: 'name', title: '名称' }, if (!props.quotaItemId) {
{ type: 'text', data: 'quotaValue', title: '定额值' }, console.warn('quotaItemId is required')
{ type: 'text', data: 'actualValue', title: '实际值' } // 清空表格
]) if (topHstRef.value?.hotInstance) {
topHstRef.value.hotInstance.loadData([])
}
return
}
const bottomMockData = () => { try {
return Array.from({ length: 10 }, (_, index) => ({ console.log('开始加载调整明细列表, quotaItemId:', props.quotaItemId)
name: `项目${index + 1}`, const response = await getAdjustmentCombinedList(props.quotaItemId)
quotaValue: (Math.random() * 100 + 10).toFixed(2), console.log('接口返回数据:', response)
actualValue: (Math.random() * 100 + 10).toFixed(2)
})) // 组合列表返回的是调整设置数组,每个设置包含其明细
} // 需要将其展平为表格行数据
const flattenedData: any[] = []
const bottomSettings = {
data: bottomMockData(), if (response && Array.isArray(response)) {
columns: bottomColumns.value, response.forEach((setting: any) => {
colWidths: 150, console.log('处理调整设置:', setting)
height: 135
// 从 adjustmentRules 中提取 type
const adjustmentType = setting.adjustmentRules?.type || setting.adjustmentType
console.log('提取的 adjustmentType:', adjustmentType)
// 如果有明细,展开明细
if (setting.details && Array.isArray(setting.details) && setting.details.length > 0) {
setting.details.forEach((detail: any, index: number) => {
flattenedData.push({
sortOrder: detail.sortOrder || (index + 1),
name: setting.name, // 使用调整设置的名称
quotaValue: setting.quotaValue, // 使用调整设置的定额值
adjustmentType: adjustmentType, // 使用从 adjustmentRules 提取的 type
adjustmentCode: detail.adjustmentCode, // 明细的调整代码
adjustment: detail.adjustment, // 明细的调整内容
settingId: setting.id,
detailId: detail.id
})
})
} else {
// 如果没有明细,只显示调整设置本身
flattenedData.push({
sortOrder: setting.sortOrder || 0,
name: setting.name,
quotaValue: setting.quotaValue,
adjustmentType: adjustmentType, // 使用从 adjustmentRules 提取的 type
adjustmentCode: '',
adjustment: setting.adjustmentContent || '', // 使用调整设置的调整内容
settingId: setting.id,
detailId: null
})
}
})
}
console.log('展平后的数据:', flattenedData)
// 更新表格数据
if (topHstRef.value?.hotInstance) {
topHstRef.value.hotInstance.loadData(flattenedData)
}
} catch (error) {
console.error('Failed to load adjustment combined list:', error)
}
} }
watch( watch(
@@ -58,41 +154,50 @@ watch(
(newHeight) => { (newHeight) => {
console.log('QuotaAdjustment newHeight', newHeight) console.log('QuotaAdjustment newHeight', newHeight)
if (newHeight) { if (newHeight) {
const tableHeight = (newHeight - 50 - 15) / 2 // 平均分配给两个表格 const tableHeight = newHeight - 50
nextTick(() => {
if (topHstRef.value?.hotInstance) { if (topHstRef.value?.hotInstance) {
topHstRef.value.hotInstance.updateSettings({ topHstRef.value.hotInstance.updateSettings({
height: tableHeight height: tableHeight
}) })
topHstRef.value.hotInstance.render() topHstRef.value.hotInstance.render()
} }
})
if (bottomHstRef.value?.hotInstance) {
bottomHstRef.value.hotInstance.updateSettings({
height: tableHeight
})
bottomHstRef.value.hotInstance.render()
}
} }
}, },
{ immediate: true } { immediate: true }
) )
watch(
() => props.quotaItemId,
(newQuotaItemId) => {
console.log('QuotaAdjustment: quotaItemId changed to', newQuotaItemId)
if (newQuotaItemId) {
loadAdjustmentDetails()
} else {
// 清空表格
if (topHstRef.value?.hotInstance) {
topHstRef.value.hotInstance.loadData([])
}
}
}
// 移除 immediate: true避免与 onMounted 重复调用
)
onMounted(() => { onMounted(() => {
console.log('QuotaAdjustment mounted') console.log('QuotaAdjustment mounted, quotaItemId:', props.quotaItemId)
// onMounted 时调用一次
if (props.quotaItemId) {
loadAdjustmentDetails()
}
}) })
defineExpose({ topHstRef, bottomHstRef }) defineExpose({ topHstRef, loadAdjustmentDetails })
</script> </script>
<template> <template>
<div class="w-full h-full flex flex-col gap-2"> <div class="w-full h-full flex flex-col gap-2">
<div class="flex-1"> <DbHst ref="topHstRef" :settings="topSettings" />
<DbHst ref="topHstRef" :settings="topSettings" />
</div>
<div class="flex-1">
<DbHst ref="bottomHstRef" :settings="bottomSettings" />
</div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,113 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { ref } from 'vue';
//调整人材机消耗量 表头:名称,类别,系数
export const tab1Columns = ref<any[]>([
{ type: 'text', data: 'name', title: '名称' },
{
type: 'text',
data: 'category',
title: '类别',
renderer: 'db-dropdown',
source: [
{ label: '人工', value: '人工' },
{ label: '材料', value: '材料' },
{ label: '机械', value: '机械' }
]
},
{
type: 'numeric',
data: 'coefficient',
title: '系数',
numericFormat: {
pattern: '0,0.000',
culture: 'en-US'
},
validator: (value: any, callback: (valid: boolean) => void) => {
// 允许空值、数字(包括负数和小数)
if (value === null || value === undefined || value === '') {
callback(true)
return
}
// 验证是否为有效数字(支持负数和小数)
const numValue = Number(value)
callback(!isNaN(numValue))
}
},
])
//动态合并定额 表头:序号,编码,名称,最小值,最大值,分母值,基础值,取值规则
export const tab2Columns = ref<any[]>([
{
type: 'text',
data: 'index',
title: '序号',
readOnly: true,
renderer: (instance: any, td: HTMLTableCellElement, row: number) => {
td.innerHTML = String(row + 1)
td.style.textAlign = 'center'
td.style.color = '#909399'
return td
}
},
{ type: 'text', data: 'code', title: '编码' },
{ type: 'text', data: 'name', title: '名称' },
{ type: 'numeric', data: 'minValue', title: '最小值' },
{ type: 'numeric', data: 'maxValue', title: '最大值' },
{ type: 'numeric', data: 'denominatorValue', title: '分母值' },
{ type: 'numeric', data: 'baseValue', title: '基础值' },
{ type: 'text', data: 'valueRule', title: '取值规则',
renderer: 'db-dropdown',
source: getDictOptions(DICT_TYPE.DYNAMIC_MERGE_RULE, 'string')
},
])
//增减材料消耗量 表头:序号,编码,名称,增减定额消耗量数值
export const tab3Columns = ref<any[]>([
{
type: 'text',
data: 'index',
title: '序号',
readOnly: true,
renderer: (instance: any, td: HTMLTableCellElement, row: number) => {
td.innerHTML = String(row + 1)
td.style.textAlign = 'center'
td.style.color = '#909399'
return td
}
},
{ type: 'text', data: 'code', title: '编码' },
{ type: 'text', data: 'name', title: '名称' },
{
type: 'numeric',
data: 'consumptionValue',
title: '增减定额消耗量数值',
numericFormat: {
pattern: '0,0.000',
culture: 'en-US'
},
validator: (value: any, callback: (valid: boolean) => void) => {
// 允许空值、数字(包括负数和小数)
if (value === null || value === undefined || value === '') {
callback(true)
return
}
// 验证是否为有效数字(支持负数和小数)
const numValue = Number(value)
callback(!isNaN(numValue))
}
},
])
//动态调整人材机消耗量 表头:类别,系数设置,最小值,最大值。分母值,基础值,取值规则
export const tab4Columns = ref<any[]>([
{ type: 'text', data: 'category', title: '类别' },
{ type: 'numeric', data: 'coefficientSetting', title: '系数设置' },
{ type: 'numeric', data: 'minValue', title: '最小值' },
{ type: 'numeric', data: 'maxValue', title: '最大值' },
{ type: 'numeric', data: 'denominatorValue', title: '分母值' },
{ type: 'numeric', data: 'baseValue', title: '基础值' },
{ type: 'text', data: 'valueRule', title: '取值规则' ,
renderer: 'db-dropdown',
source: getDictOptions(DICT_TYPE.DYNAMIC_TALENT_MATERIAL_MACHINE_RULE, 'string')
},
])

View File

@@ -1,148 +1,534 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { createAdjustmentSetting, deleteAdjustmentSetting, getAdjustmentSettingList, updateAdjustmentSetting } from '#/api/database/quota/index';
import { ElDialog,ElTabPane, ElTable ,ElTableColumn,ElInput,ElButton} from 'element-plus'; import { DbHst } from '#/components/db-hst';
import { DbHst } from '#/components/db-hst' import { ElButton, ElDialog, ElMessage } from 'element-plus';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { tab1Columns, tab2Columns, tab3Columns, tab4Columns } from './QuotaAdjustmentSetting';
const props = defineProps<{ const props = defineProps<{
height?: number height?: number
quotaItemId?: number | null
}>() }>()
const hstRef = ref<any>(null) const hstRef = ref<any>(null)
const topHstRef = ref<any>(null)
const adjustmentSettingsData = ref<any[]>([])
// 加载调整设置列表
const loadAdjustmentSettings = async (quotaItemId: number) => {
try {
console.log('开始加载调整设置列表, quotaItemId:', quotaItemId)
const res = await getAdjustmentSettingList(quotaItemId)
console.log('调整设置列表原始数据:', res)
if (res && Array.isArray(res)) {
adjustmentSettingsData.value = res.map(item => ({
id: item.id,
name: item.name || '',
adjustType: item.adjustmentType || '',
adjustRule: item.adjustmentRules ? JSON.stringify(item.adjustmentRules) : ''
}))
console.log('调整设置列表转换后数据:', adjustmentSettingsData.value)
// 更新表格数据
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.loadData(adjustmentSettingsData.value)
}
} else {
console.warn('调整设置列表数据为空或格式不正确')
adjustmentSettingsData.value = []
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.loadData([])
}
}
} catch (error) {
console.error('加载调整设置列表失败:', error)
adjustmentSettingsData.value = []
}
}
// 弹窗相关 // 弹窗相关
const dialogVisible = ref(false) const dialogVisible = ref(false)
const activeTab = ref('tab1') const activeTab = ref('tab1Settings')
const currentCell = reactive({ row: 0, col: 0 }) const currentCell = reactive({ row: 0, col: 0 })
// Tab 标签映射 // Tab 标签映射
const tabLabels: Record<string, string> = { const tabLabels: Record<string, string> = {
'tab1': '调整工料机系数', 'tab1Columns': '调整人材机消耗量',
'tab2': '动态合并定额', 'tab2Columns': '动态合并定额',
'tab3': '增减耗量', 'tab3Columns': '增减材料消耗量',
'tab4': '动态调整人材机消耗量' 'tab4Columns': '动态调整人材机消耗量'
}
const typeLists: any = [
{label:'调整人材机消耗量',value: 1,tab:'tab1Columns'},
{label:'动态合并定额',value: 2,tab:'tab2Columns'},
{label:'增减材料消耗量',value: 3,tab:'tab3Columns'},
{label:'动态调整人材机消耗量',value: 4,tab:'tab4Columns'}
]
// 列配置映射
const tabColumnsMap: Record<string, any> = {
'tab1Columns': tab1Columns,
'tab2Columns': tab2Columns,
'tab3Columns': tab3Columns,
'tab4Columns': tab4Columns
} }
// Tab1: 调整工料机系数 const dbSettings = ref<any>({
const coefficientData = ref([ data: [],
{ category: '人工', coefficient: 2, selected: false }, columns: [],
{ category: '材料', coefficient: 1.5, selected: false }, colWidths: 150,
{ category: '机具', coefficient: 1.5, selected: false }, height: 270
{ category: '其他', coefficient: 2, selected: false } })
])
// Tab2: 动态合并定额
const quotaMergeData = ref([
{ code: '', minValue: '', maxValue: '', denominatorValue: '', baseValue: '', rule: '' }
])
// Tab3: 增减耗量
const consumptionData = ref([
{ code: '', name: '', value: '' }
])
// Tab4: 动态调整人材机消耗量
const dynamicAdjustData = ref([
{ name: '', category: '', coefficient: '', minValue: '', maxValue: '', denominatorValue: '', baseValue: '', rule: '' }
])
const tabDialog = ( hot: any,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any)=>{
TD.innerHTML = ''
const container = document.createElement('div')
container.style.display = 'flex'
container.style.alignItems = 'center'
container.style.justifyContent = 'space-between'
container.style.padding = '0 5px'
const textSpan = document.createElement('span')
textSpan.textContent = value || ''
const iconSpan = document.createElement('span')
iconSpan.innerHTML = '▼'
iconSpan.style.cursor = 'pointer'
iconSpan.style.marginLeft = '5px'
iconSpan.style.fontSize = '12px'
iconSpan.style.color = '#909399'
iconSpan.addEventListener('click', (e) => {
e.stopPropagation()
currentCell.row = row
currentCell.col = col
dialogVisible.value = true
})
container.appendChild(textSpan)
container.appendChild(iconSpan)
TD.appendChild(container)
return TD;
}
const handleClose = () => { const handleClose = () => {
dialogVisible.value = false dialogVisible.value = false
} }
const handleConfirm = () => { const handleConfirm = () => {
if (hstRef?.value.hotInstance) { if (!topHstRef.value?.hotInstance || !hstRef.value?.hotInstance) {
const tabLabel = tabLabels[activeTab.value] ElMessage.error('表格实例未初始化')
hstRef?.value.hotInstance.setDataAtCell(currentCell.row, currentCell.col, tabLabel) return
} }
dialogVisible.value = false
}
const addRow = (tabName: string) => { try {
switch(tabName) { // 获取弹窗内表格的数据
case 'tab2': const dialogData = topHstRef.value.hotInstance.getData()
quotaMergeData.value.push({ code: '', minValue: '', maxValue: '', denominatorValue: '', baseValue: '', rule: '' })
break // 过滤掉空行
case 'tab3': const validData = dialogData.filter((row: any) => {
consumptionData.value.push({ code: '', name: '', value: '' }) return row && row.some((cell: any) => cell !== null && cell !== '')
break })
case 'tab4':
dynamicAdjustData.value.push({ name: '', category: '', coefficient: '', minValue: '', maxValue: '', denominatorValue: '', baseValue: '', rule: '' }) if (validData.length === 0) {
break ElMessage.warning('请至少填写一行数据')
return
}
// 根据当前 tab 构建对应的 JSON 结构
let adjustmentRules: any = {}
switch(activeTab.value) {
case 'tab1Columns': // 调整人材机消耗量
adjustmentRules = {
type: 'adjust_coefficient',
items: validData.map((row: any) => ({
name: row[0] || '',
category: row[1] || '',
coefficient: row[2] || 0
}))
}
break
case 'tab2Columns': // 动态合并定额
adjustmentRules = {
type: 'dynamic_merge',
items: validData.map((row: any) => ({
// 不保存序号,序号是自动生成的
code: row[1] || '',
name: row[2] || '',
minValue: row[3] || 0,
maxValue: row[4] || 0,
denominatorValue: row[5] || 0,
baseValue: row[6] || 0,
valueRule: row[7] || ''
}))
}
break
case 'tab3Columns': // 增减材料消耗量
adjustmentRules = {
type: 'consumption_adjustment',
items: validData.map((row: any) => ({
// 不保存序号,序号是自动生成的
code: row[1] || '',
name: row[2] || '',
consumptionValue: row[3] || 0
}))
}
break
case 'tab4Columns': // 动态调整人材机消耗量
adjustmentRules = {
type: 'dynamic_adjust',
items: validData.map((row: any) => ({
category: row[0] || '',
coefficientSetting: row[1] || 0,
minValue: row[2] || 0,
maxValue: row[3] || 0,
denominatorValue: row[4] || 0,
baseValue: row[5] || 0,
valueRule: row[6] || ''
}))
}
break
}
// 将 JSON 对象转换为字符串
const adjustRuleStr = JSON.stringify(adjustmentRules)
// 更新主表当前行的 adjustRule 字段
hstRef.value.hotInstance.setDataAtRowProp(currentCell.row, 'adjustRule', adjustRuleStr)
// 关闭弹窗
dialogVisible.value = false
ElMessage.success('保存成功')
} catch (error) {
console.error('保存失败:', error)
ElMessage.error('保存失败')
} }
} }
const settingRender = (
instance: any,
TD: HTMLTableCellElement,
row: number,
col: number,
_prop: any,
_value: any,
_cellProperties: any
) => {
TD.innerHTML = ''
const container = document.createElement('div')
container.style.display = 'flex'
container.style.alignItems = 'center'
container.style.justifyContent = 'center'
container.style.cursor = 'pointer'
container.style.color = '#006be6'
container.style.padding = '0 5px'
const textSpan = document.createElement('span')
textSpan.textContent = '编辑'
container.addEventListener('click', (e) => {
e.stopPropagation()
const rowObj = instance.getSourceDataAtRow(row)
if(rowObj.adjustType) {
// 根据 adjustType 找到对应的 tab
const typeItem = typeLists.find((item: any) => item.value == rowObj.adjustType)
if(typeItem && typeItem.tab) {
activeTab.value = typeItem.tab
// 更新 dbSettings 的列配置
updateDbSettings(typeItem.tab)
// 加载已有的调整规则数据
loadAdjustmentRulesData(rowObj.adjustRule, typeItem.tab)
}
currentCell.row = row
currentCell.col = col
dialogVisible.value = true
}
})
container.appendChild(textSpan)
TD.appendChild(container)
return TD
}
// 加载调整规则数据到弹窗表格
const loadAdjustmentRulesData = (adjustRuleStr: string, tabName: string) => {
try {
if (!adjustRuleStr) {
// 如果没有数据,加载空数组
dbSettings.value.data = []
return
}
const adjustmentRules = JSON.parse(adjustRuleStr)
let tableData: any[] = []
switch(tabName) {
case 'tab1Columns': // 调整人材机消耗量
if (adjustmentRules.items && Array.isArray(adjustmentRules.items)) {
tableData = adjustmentRules.items.map((item: any) => ({
name: item.name || '',
category: item.category || '',
coefficient: item.coefficient || 0
}))
}
break
case 'tab2Columns': // 动态合并定额
if (adjustmentRules.items && Array.isArray(adjustmentRules.items)) {
tableData = adjustmentRules.items.map((item: any) => ({
// 序号不需要加载,会自动生成
code: item.code || '',
name: item.name || '',
minValue: item.minValue || 0,
maxValue: item.maxValue || 0,
denominatorValue: item.denominatorValue || 0,
baseValue: item.baseValue || 0,
valueRule: item.valueRule || ''
}))
}
break
case 'tab3Columns': // 增减材料消耗量
if (adjustmentRules.items && Array.isArray(adjustmentRules.items)) {
tableData = adjustmentRules.items.map((item: any) => ({
// 序号不需要加载,会自动生成
code: item.code || '',
name: item.name || '',
consumptionValue: item.consumptionValue || 0
}))
}
break
case 'tab4Columns': // 动态调整人材机消耗量
if (adjustmentRules.items && Array.isArray(adjustmentRules.items)) {
tableData = adjustmentRules.items.map((item: any) => ({
category: item.category || '',
coefficientSetting: item.coefficientSetting || 0,
minValue: item.minValue || 0,
maxValue: item.maxValue || 0,
denominatorValue: item.denominatorValue || 0,
baseValue: item.baseValue || 0,
valueRule: item.valueRule || ''
}))
}
break
}
dbSettings.value.data = tableData
// 等待 DOM 更新后加载数据
nextTick(() => {
if (topHstRef.value?.hotInstance) {
topHstRef.value.hotInstance.loadData(tableData)
}
})
} catch (error) {
console.error('加载调整规则数据失败:', error)
dbSettings.value.data = []
}
}
// 更新 dbSettings 配置
const updateDbSettings = (tabName: string) => {
const columns = tabColumnsMap[tabName]
if (columns) {
dbSettings.value = {
data: [],
columns: columns.value,
colWidths: 150,
height: 270
}
// 等待 DOM 更新后重新渲染表格
nextTick(() => {
if (topHstRef.value?.hotInstance) {
// topHstRef.value.hotInstance.updateSettings({
// columns : columns.value,
// data: []
// })
// topHstRef.value.hotInstance.loadData([])
topHstRef.value.hotInstance.render()
}
})
}
}
const columns = ref<any[]>([ const columns = ref<any[]>([
{
type: 'text',
data: 'index',
title: '序号',
width: 60,
readOnly: true,
renderer: (_instance: any, td: HTMLTableCellElement, row: number) => {
td.innerHTML = String(row + 1)
td.style.textAlign = 'center'
td.style.color = '#909399'
return td
}
},
{ type: 'text', data: 'name', title: '名称' }, { type: 'text', data: 'name', title: '名称' },
{ type: 'text', data: 'quotaValue', title: '定额值' }, { type: 'text', data: 'adjustType', title: '调整类型', renderer: 'db-dropdown', width: 200,
{ type: 'text', data: 'adjustContent', title: '调整内容' }, source: typeLists,
{ type: 'text', data: 'adjustType', title: '调整类型', renderer: tabDialog, width: 200}, onAfterSelect: (_instance: any, row: number, column: number, oldValue: any, newValue: string, _optData?: any) => {
{ type: 'text', data: 'adjustRule', title: '调整规则' } console.log('选择后回调:', { row, column, oldValue, newValue })
if(newValue == oldValue){
return
}
}
},
{ type: 'text', data: 'adjustRule', title: '设置', renderer: settingRender, readOnly: true }
]) ])
const mockData = () => {
const types = ['人工调整', '材料调整', '机械调整', '综合调整']
const rules = ['系数调整', '价格调整', '数量调整', '比例调整']
return Array.from({ length: 10 }, (_, index) => ({
name: `调整项${index + 1}`,
quotaValue: (Math.random() * 100 + 10).toFixed(2),
adjustContent: `调整内容说明${index + 1}`,
adjustType: '',
adjustRule: rules[index % rules.length]
}))
}
const settings = { const settings = {
data: mockData(), data: [], // 初始为空,等待从接口加载
columns: columns.value, columns: columns.value,
colWidths: 120, colWidths: 120,
height: 270 height: 170,
contextMenu: {
items: {
row_above: {
name: '在上方插入行',
callback: function(_key: string, selection: any[]) {
const selectedRow = selection[0].start.row
this.alter('insert_row_above', selectedRow, 1)
},
},
row_below: {
name: '在下方插入行',
callback: function(_key: string, selection: any[]) {
const selectedRow = selection[0].start.row
this.alter('insert_row_below', selectedRow, 1)
},
},
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)
// 调用删除 API
deleteAdjustmentSetting(rowData.id).then(res => {
console.log('删除调整设置成功', res)
ElMessage.success('删除成功')
}).catch(err => {
console.error('删除调整设置失败:', err)
ElMessage.error('删除失败')
})
} else {
// 没有 ID 直接删除
this.alter('remove_row', selectedRow, 1)
}
},
}
}
},
afterChange(changes: any, source: string) {
console.log('afterChange', changes, source)
// 忽略特定来源的变更
if (!changes || source === 'loadData' || source === 'updateId') {
return
}
const row = changes[0][0]
const rowData = this.getSourceDataAtRow(row)
// 检查必填字段是否都已填写
if (!rowData.name || !rowData.adjustType) {
console.log('必填字段未完整,跳过保存')
return
}
// 检查是否选择了定额子目
if (!props.quotaItemId) {
ElMessage.error('请先选择定额子目')
return
}
// 构建保存数据
const saveData: any = {
quotaItemId: props.quotaItemId,
name: rowData.name || '',
adjustmentType: rowData.adjustType || '',
adjustmentRules: rowData.adjustRule ? JSON.parse(rowData.adjustRule) : {}
}
if (rowData.id == null) {
// 调用创建接口
console.log('afterChange-create', saveData)
createAdjustmentSetting(saveData).then(res => {
console.log('createAdjustmentSetting', res)
// 更新当前行ID
this.setDataAtRowProp(row, 'id', res, 'updateId')
ElMessage.success('新增成功')
}).catch(err => {
console.error('新增失败', err)
ElMessage.error('新增失败')
})
} else {
// 调用更新接口
saveData.id = rowData.id
console.log('afterChange-update', saveData)
updateAdjustmentSetting(saveData).then(res => {
console.log('updateAdjustmentSetting', res)
ElMessage.success('更新成功')
}).catch(err => {
console.error('更新失败', err)
ElMessage.error('更新失败')
})
}
}
} }
// 计算属性:根据是否选择了定额子目来决定是否显示右键菜单
const contextMenuItems = computed(() => {
// 如果没有选择定额子目,返回空数组(不显示右键菜单)
if (!props.quotaItemId) {
return []
}
// 如果选择了定额子目,返回完整的右键菜单
return [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
//TODO 指定的tab
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]
})
const dialogContextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
//TODO 指定的tab
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]
// 监听 quotaItemId 变化
watch(
() => props.quotaItemId,
(newQuotaItemId) => {
console.log('QuotaAdjustmentSetting: quotaItemId 变化:', newQuotaItemId)
if (newQuotaItemId) {
loadAdjustmentSettings(newQuotaItemId)
} else {
// 清空数据
adjustmentSettingsData.value = []
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.loadData([])
}
}
},
{ immediate: true }
)
watch( watch(
() => props.height, () => props.height,
(newHeight) => { (newHeight) => {
console.log('QuotaAdjustmentQuery newHeight', newHeight) console.log('QuotaAdjustmentQuery newHeight', newHeight)
nextTick(() => {
if (newHeight && hstRef.value?.hotInstance) { if (newHeight && hstRef.value?.hotInstance) {
hstRef.value.hotInstance.updateSettings({ hstRef.value.hotInstance.updateSettings({
height: newHeight - 50 - 15// 减去 tabs 头部和 padding 的高度,滚动条 height: newHeight - 50// 减去 tabs 头部和 padding 的高度,滚动条
}) })
hstRef.value.hotInstance.render() hstRef.value.hotInstance.render()
} }
})
}, },
{ immediate: true } { immediate: true }
) )
@@ -154,18 +540,20 @@ defineExpose({ hstRef })
</script> </script>
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full" @contextmenu.prevent>
<DbHst ref="hstRef" :settings="settings" /> <DbHst ref="hstRef" :settings="settings" :contextMenuItems="contextMenuItems"/>
<ElDialog <ElDialog
v-model="dialogVisible" v-model="dialogVisible"
title="调数类型" :title="tabLabels[activeTab] || '调整设置'"
width="50%" width="60%"
:before-close="handleClose" :before-close="handleClose"
:destroy-on-close="true"
> >
<ElTabs v-model="activeTab"> <DbHst v-if="dialogVisible" ref="topHstRef" :settings="dbSettings" :contextMenuItems="dialogContextMenuItems"></DbHst>
<!-- Tab1: 调整工料机系数 --> <!-- <ElTabs v-model="activeTab" :tab-position="'top'">
<ElTabPane label="调整工料机系数" name="tab1">
<ElTabPane v-if="activeTab === 'tab1'" label="调整工料机系数" name="tab1">
<ElTable :data="coefficientData" border style="width: 50%"> <ElTable :data="coefficientData" border style="width: 50%">
<ElTableColumn type="selection" width="55" /> <ElTableColumn type="selection" width="55" />
<ElTableColumn prop="category" label="类别" width="200" /> <ElTableColumn prop="category" label="类别" width="200" />
@@ -177,8 +565,7 @@ defineExpose({ hstRef })
</ElTable> </ElTable>
</ElTabPane> </ElTabPane>
<!-- Tab2: 动态合并定额 --> <ElTabPane v-if="activeTab === 'tab2'" label="动态合并定额" name="tab2">
<ElTabPane label="动态合并定额" name="tab2">
<ElTable :data="quotaMergeData" border style="width: 100%"> <ElTable :data="quotaMergeData" border style="width: 100%">
<ElTableColumn prop="code" label="编码" width="120"> <ElTableColumn prop="code" label="编码" width="120">
<template #default="scope"> <template #default="scope">
@@ -214,8 +601,8 @@ defineExpose({ hstRef })
<ElButton @click="addRow('tab2')" style="margin-top: 10px">添加行</ElButton> <ElButton @click="addRow('tab2')" style="margin-top: 10px">添加行</ElButton>
</ElTabPane> </ElTabPane>
<!-- Tab3: 增减耗量 -->
<ElTabPane label="增减耗量" name="tab3"> <ElTabPane v-if="activeTab === 'tab3'" label="增减耗量" name="tab3">
<ElTable :data="consumptionData" border style="width: 100%"> <ElTable :data="consumptionData" border style="width: 100%">
<ElTableColumn prop="code" label="编码" width="200"> <ElTableColumn prop="code" label="编码" width="200">
<template #default="scope"> <template #default="scope">
@@ -236,8 +623,8 @@ defineExpose({ hstRef })
<ElButton @click="addRow('tab3')" style="margin-top: 10px">添加行</ElButton> <ElButton @click="addRow('tab3')" style="margin-top: 10px">添加行</ElButton>
</ElTabPane> </ElTabPane>
<!-- Tab4: 动态调整人材机消耗量 -->
<ElTabPane label="动态调整人材机消耗量" name="tab4"> <ElTabPane v-if="activeTab === 'tab4'" label="动态调整人材机消耗量" name="tab4">
<ElTable :data="dynamicAdjustData" border style="width: 100%"> <ElTable :data="dynamicAdjustData" border style="width: 100%">
<ElTableColumn prop="name" label="名称" width="120"> <ElTableColumn prop="name" label="名称" width="120">
<template #default="scope"> <template #default="scope">
@@ -286,7 +673,7 @@ defineExpose({ hstRef })
</ElTable> </ElTable>
<ElButton @click="addRow('tab4')" style="margin-top: 10px">添加行</ElButton> <ElButton @click="addRow('tab4')" style="margin-top: 10px">添加行</ElButton>
</ElTabPane> </ElTabPane>
</ElTabs> </ElTabs> -->
<template #footer> <template #footer>
<span class="dialog-footer"> <span class="dialog-footer">
@@ -296,7 +683,7 @@ defineExpose({ hstRef })
</template> </template>
</ElDialog> </ElDialog>
</div> </div>
</template> </template>
<style scoped> <style scoped>

View File

@@ -1,53 +1,508 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue' import { createQuotaResource, deleteQuotaResource, getAvailableResourceList, getQuotaResourceList, updateQuotaResource } from '#/api/database/quota/index'
import { DbHst } from '#/components/db-hst' import { DbHst } from '#/components/db-hst'
import { useParentChildLineNestedRowsFalse } from '#/components/db-hst/nestedRows'
import { createPopoverCellRenderer, usePopoverClickOutside } from '#/components/db-hst/popover'
import DbHstPopover from '#/components/db-hst/popover.vue'
import { DICT_TYPE } from '@vben/constants'
import { getDictOptions } from '@vben/hooks'
import type { RowEventHandlers } from 'element-plus'
import { ElMessage } from 'element-plus'
import { computed, h, nextTick, onMounted, reactive, ref, watch } from 'vue'
const props = defineProps<{ const props = defineProps<{
height?: number height?: number
quotaItemId?: number | null
}>() }>()
const hstRef = ref<any>(null) const hstRef = ref<any>(null)
//const quotaResourcesData = ref<any[]>([])
const visible = ref(false)
const buttonRef = ref<HTMLElement | null>()
const popoverComponentRef = ref<any>(null)
const columns = ref<any[]>([ const onClickOutside = () => {
{ type: 'text', data: 'code', title: '编码' }, popoverComponentRef.value?.popoverRef?.hide()
{ type: 'text', data: 'name', title: '名称' }, }
{ type: 'text', data: 'spec', title: '型号规格' }, // 使用 popover 点击外部关闭的 Hook
{ type: 'text', data: 'unit', title: '单位' }, usePopoverClickOutside(visible, onClickOutside)
{ type: 'text', data: 'category', title: '类别' },
{ type: 'text', data: 'taxRate', title: '税率' }, // 分页状态管理
{ type: 'text', data: 'basePriceExTax', title: '除税基价' }, const popoverPagination = reactive({
{ type: 'text', data: 'basePriceInTax', title: '含税基价' }, pageNo: 1,
{ type: 'text', data: 'compilePriceExTax', title: '除税编制价' }, pageSize: 20,
{ type: 'text', data: 'compilePriceInTax', title: '含税编制价' }, total: 0,
{ type: 'text', data: 'quotaQuantity', title: '定额消耗量' }, loading: false,
{ type: 'text', data: 'adjustQuantity', title: '调整消耗量' } hasMore: true
})
const getTableDataList = async (_query?: any, append: boolean = false) => {
if (popoverPagination.loading || !props.quotaItemId) return
popoverPagination.loading = true
try {
// 构建查询参数
const queryParams: { code?: string; name?: string; spec?: string } = {}
if (_query?.code) queryParams.code = _query.code
if (_query?.name) queryParams.name = _query.name
if (_query?.spec) queryParams.spec = _query.spec
const res: any = await getAvailableResourceList(props.quotaItemId, queryParams)
console.log('getAvailableResourceList==', res)
const dataList = Array.isArray(res) ? res : (res?.list || [])
const total = Array.isArray(res) ? res.length : (res?.total || 0)
popoverPagination.total = total
popoverPagination.hasMore = (popoverPagination.pageNo * popoverPagination.pageSize) < popoverPagination.total
if (append) {
// 追加数据(滚动加载)
popoverTableData.value = [...popoverTableData.value, ...dataList]
} else {
// 替换数据(首次加载或搜索)
popoverTableData.value = dataList
}
} finally {
popoverPagination.loading = false
}
}
// 滚动到底部加载更多
const handleTableEndReached = () => {
if (popoverPagination.hasMore && !popoverPagination.loading) {
popoverPagination.pageNo++
getTableDataList({}, true) // append = true
}
}
const unitLabelByValue = computed(() => new Map(getDictOptions(DICT_TYPE.MATERIAL_UNIT, 'string').map((option) => [option.value, option.label])))
const columnsStyle = {
borderLeft: '1px solid #e4e4e7',
background: '#f0f0f0',
color: '#373737',
fontWeight: 400
}
// 为 ElTableV2 定义 columns
const popoverColumns = computed(() => [
{ key: 'code', dataKey: 'code', title: '编码', width: 120 },
{ key: 'name', dataKey: 'name', title: '名称', width: 150 },
{ key: 'spec', dataKey: 'spec', title: '型号规格', width: 120 },
{ key: 'type', dataKey: 'type', title: '类别', width: 60 },
{ key: 'unit', dataKey: 'unit', title: '单位', width: 60, cellRenderer: ({ cellData }: { cellData: any }) => {
const label = cellData == null ? '' : unitLabelByValue.value.get(cellData) ?? String(cellData)
return h('span', label)
} },
{ key: 'taxRate', dataKey: 'taxRate', title: '税率', width: 60 },
{ key: 'taxExclBasePrice', dataKey: 'taxExclBasePrice', title: '除税基价', width: 90 },
{ key: 'taxInclBasePrice', dataKey: 'taxInclBasePrice', title: '含税基价', width: 90 },
{ key: 'taxExclCompilePrice', dataKey: 'taxExclCompilePrice', title: '除税编制价', width: 90 },
{ key: 'taxInclCompilePrice', dataKey: 'taxInclCompilePrice', title: '含税编制价', width: 90 },
].map(item => ({
...item,
style: {
...columnsStyle,
...(item.key === 'taxInclCompilePrice' ? { borderRight: '1px solid #e4e4e7' } : {})
}
})))
const rowEventHandlers: RowEventHandlers = {
onClick: ({ rowIndex, event, rowData }) => {
console.log('点击了行:', rowIndex, rowData, itemSelectPopover.value)
event.stopPropagation()
visible.value = false
onClickOutside()
const { row, currentRowData, instance } = itemSelectPopover.value
// 使用 setDataAtRowProp 的数组格式批量更新(一次调用,避免多次渲染)
// 格式: [[row, prop, value], [row, prop, value], ...]
setTimeout(async () => {
instance.setDataAtRowProp([
[row, 'code', rowData.code || ''],
[row, 'name', rowData.name || ''],
[row, 'spec', rowData.spec || ''],
[row, 'unit', rowData.unit || ''],
[row, 'category', rowData.type || ''], // 使用 type 字段material/labor/machine
[row, 'taxRate', rowData.taxRate || 0],
[row, 'basePriceExTax', rowData.taxExclBasePrice || 0],
[row, 'basePriceInTax', rowData.taxInclBasePrice || 0],
[row, 'compilePriceExTax', rowData.taxExclCompilePrice || 0],
[row, 'compilePriceInTax', rowData.taxInclCompilePrice || 0],
[row, 'resourceItemId', rowData.id],
], 'selectResource')
instance.render()
// 如果是新行没有ID调用创建接口
if (currentRowData.id == null) {
try {
const createData = {
quotaItemId: props.quotaItemId,
resourceItemId: rowData.id,
dosage: currentRowData.quotaQuantity || 0,
attributes: {}
}
const newId = await createQuotaResource(createData)
console.log('创建定额工料机组成成功, ID:', newId)
// 更新行ID
instance.setDataAtRowProp([
[row, 'id', newId],
], 'updateId')
ElMessage.success('添加成功')
} catch (error) {
console.error('创建定额工料机组成失败:', error)
ElMessage.error('添加失败')
}
} else {
// 如果已有ID调用更新接口
try {
const updateData = {
id: currentRowData.id,
quotaItemId: props.quotaItemId,
resourceItemId: rowData.id,
dosage: currentRowData.quotaQuantity || 0,
attributes: {}
}
await updateQuotaResource(updateData)
console.log('更新定额工料机组成成功')
ElMessage.success('更新成功')
} catch (error) {
console.error('更新定额工料机组成失败:', error)
ElMessage.error('更新失败')
}
}
}, 200)
},
}
const popoverTableData = ref<any[]>([])
const selectRender = createPopoverCellRenderer({
visible,
buttonRef,
})
const { load, codeRenderer, handleRowOperation } = useParentChildLineNestedRowsFalse({
getHotInstance: () => hstRef.value.hotInstance,
})
const itemSelectPopover = ref<any>()
// 工料机类型映射
const resourceTypeMap = new Map([
['material', '材料'],
['labor', '人工'],
['machine', '机械']
]) ])
const mockData = () => { const columns = ref<any[]>([
const categories = ['人', '人机', '材', '机'] { type: 'text', data: 'code', title: '编码', renderer: codeRenderer, allowInvalid: true, autoWidth: true },
const units = ['工日', 't', 'm³', 'kg', 'm²'] {
return Array.from({ length: 20 }, (_, index) => ({ type: 'text',
code: `SUB${String(index + 1).padStart(6, '0')}`, data: 'name',
name: `子目项${index + 1}`, title: '名称',
spec: `规格${index + 1}`, readOnly: true,
unit: units[index % units.length], renderer: selectRender,
category: categories[index % categories.length], focusCallback: async (row: any, _col: any, _prop: any, _value: any, rowData: any, instance: any, _td: any) => {
taxRate: `${(Math.random() * 5 + 8).toFixed(2)}%`, // 获得焦点时加载第一页数据
basePriceExTax: (Math.random() * 1000 + 100).toFixed(2), popoverPagination.pageNo = 1
basePriceInTax: (Math.random() * 1100 + 110).toFixed(2), popoverPagination.hasMore = true
compilePriceExTax: (Math.random() * 1200 + 120).toFixed(2), await getTableDataList({}, false)
compilePriceInTax: (Math.random() * 1300 + 130).toFixed(2), console.log('focusCallback-popoverTableData 已加载:', popoverTableData.value)
quotaQuantity: (Math.random() * 10 + 1).toFixed(3), itemSelectPopover.value = { row, currentRowData: rowData, instance }
adjustQuantity: (Math.random() * 10 + 1).toFixed(3) },
})) selectCallback: async (row: any, _col: any, _prop: any, _value: any, rowData: any, instance: any, _td: any) => {
// 点击搜索图标时重置分页并加载第一页
popoverPagination.pageNo = 1
popoverPagination.hasMore = true
await getTableDataList({}, false)
console.log('selectCallback-popoverTableData 已更新:', popoverTableData.value)
itemSelectPopover.value = { row, currentRowData: rowData, instance }
},
inputCallback: async (row: any, _col: any, _prop: any, value: any, rowData: any, instance: any, _td: any) => {
// 输入时重置分页并根据输入值过滤数据
popoverPagination.pageNo = 1
popoverPagination.hasMore = true
await getTableDataList({ name: value.trim() }, false)
console.log('inputCallback 根据输入过滤后的数据:', popoverTableData.value)
itemSelectPopover.value = { row, currentRowData: rowData, instance }
}
},
{ type: 'text', data: 'spec', title: '型号规格', readOnly: true },
{
type: 'text',
data: 'unit',
title: '单位',
readOnly: true,
renderer: function(instance: any, td: any, row: any, col: any, prop: any, value: any, cellProperties: any) {
// 使用字典将单位值转换为标签
const label = value == null ? '' : unitLabelByValue.value.get(value) ?? String(value)
td.innerHTML = label
return td
}
},
{
type: 'text',
data: 'category',
title: '类别',
readOnly: true,
renderer: function(instance: any, td: any, row: any, col: any, prop: any, value: any, cellProperties: any) {
// 将 resourceType 转换为中文
const label = value == null ? '' : resourceTypeMap.get(value) ?? String(value)
td.innerHTML = label
return td
}
},
{ type: 'numeric', data: 'taxRate', title: '税率', readOnly: true },
{ type: 'numeric', data: 'basePriceExTax', title: '除税基价', readOnly: true },
{ type: 'numeric', data: 'basePriceInTax', title: '含税基价', readOnly: true },
{ type: 'numeric', data: 'compilePriceExTax', title: '除税编制价', readOnly: true },
{ type: 'numeric', data: 'compilePriceInTax', title: '含税编制价', readOnly: true },
{ type: 'numeric', data: 'quotaQuantity', title: '定额消耗量' },
{ type: 'numeric', data: 'adjustQuantity', title: '调整消耗量' },
{ type: 'numeric', data: 'totalBasePriceExTax', title: '除税基价合价', readOnly: true },
{ type: 'numeric', data: 'totalBasePriceInTax', title: '含税基价合价', readOnly: true },
{ type: 'numeric', data: 'totalCompilePriceExTax', title: '除税编制价合价', readOnly: true },
{ type: 'numeric', data: 'totalCompilePriceInTax', title: '含税编制价合价', readOnly: true },
{ type: 'text', data: 'calcBase.formula', title: '计算基数', readOnly: true }
])
// 递归转换数据,将 mergedItems 转换为 __children
const transformItem = (item: any): any => {
const transformed: any = {
...item,
code: item.resourceCode || '', // 工料机编码
name: item.resourceName || '', // 工料机名称
spec: item.resourceSpec || '', // 型号规格
unit: item.resourceUnit || '', // 工料机单位
category: item.resourceType || '', // 类别:使用 resourceTypematerial/labor/machine
taxRate: item.resourceTaxRate || 0, // 税率
basePriceExTax: item.resourceTaxExclBasePrice || 0, // 除税基价
basePriceInTax: item.resourceTaxInclBasePrice || 0, // 含税基价
compilePriceExTax: item.resourceTaxExclCompilePrice || 0, // 除税编制价
compilePriceInTax: item.resourceTaxInclCompilePrice || 0, // 含税编制价
quotaQuantity: item.dosage || 0, // 定额消耗量
adjustQuantity: item.attributes?.adjustQuantity || 0, // 调整消耗量
// 虚拟字段:合价 = 单价 × 定额消耗量
totalBasePriceExTax: Number(((item.resourceTaxExclBasePrice || 0) * (item.dosage || 0)).toFixed(2)), // 除税基价合价
totalBasePriceInTax: Number(((item.resourceTaxInclBasePrice || 0) * (item.dosage || 0)).toFixed(2)), // 含税基价合价
totalCompilePriceExTax: Number(((item.resourceTaxExclCompilePrice || 0) * (item.dosage || 0)).toFixed(2)), // 除税编制价合价
totalCompilePriceInTax: Number(((item.resourceTaxInclCompilePrice || 0) * (item.dosage || 0)).toFixed(2)), // 含税编制价合价
calcBase: item.calcBase || {}, // 计算基数
resourceItemId: item.resourceItemId, // 保存原始工料机项ID
quotaItemId: item.quotaItemId, // 保存定额子目ID
attributes: item.attributes || {},
isMerged: item.isMerged,
parent: false
}
// 如果存在 mergedItems递归转换为 __children
if (item.mergedItems && Array.isArray(item.mergedItems) && item.mergedItems.length > 0) {
transformed.__children = item.mergedItems.map(transformItem)
}
return transformed
} }
// 加载工料机组成列表
const loadQuotaResources = async (quotaItemId: number) => {
try {
console.log('SubItemMaterials: 开始加载工料机组成列表, quotaItemId:', quotaItemId)
const res = await getQuotaResourceList(quotaItemId)
console.log('SubItemMaterials: 工料机组成列表原始数据:', res)
if (res && Array.isArray(res)) {
// 转换数据格式以匹配表格列,并递归处理 mergedItems
const transformedData = res.map(transformItem)
// 计算合价
transformedData.forEach(item => {
// 除税基价合价 = 除税基价 * 定额消耗量
item.priceExTax5 = Number((item.priceExTax * item.priceExTax3).toFixed(2))
// 如果有损耗率,需要考虑损耗
const lossRate = item.attributes?.loss_rate || 0
if (lossRate > 0) {
item.priceExTax5 = Number((item.priceExTax * item.priceExTax3 * (1 + lossRate)).toFixed(2))
}
item.parent = true
})
//quotaResourcesData.value = transformedData
console.log('SubItemMaterials: 工料机组成列表转换后数据:', transformedData)
load(transformedData)
} else {
console.warn('SubItemMaterials: 工料机组成列表数据为空或格式不正确')
load([])
}
} catch (error) {
console.error('SubItemMaterials: 加载工料机组成列表失败:', error)
load([])
}
}
let rowSchema: any = {__id: null, level: null,__children: [], calcBase: {}}
// 根据 columns 的 data 字段生成对象结构
columns.value.forEach((col: any) => {
if (col.data && col.data !== 'level' && col.data !== '__children' && col.data !== '__id') {
rowSchema[col.data] = null
}
})
const settings = { const settings = {
data: mockData(), data: [], // 初始为空,等待从接口加载
dataSchema: rowSchema,
columns: columns.value, columns: columns.value,
colWidths: 120, colWidths: 120,
height: 270 height: 270,
rowHeaders: false,
nestedRows: false,
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() {
let selected = this.getSelected()
const row = selected[0][0]
const rowData = this.getSourceDataAtRow(row)
// console.log(rowData)
if (rowData?.id) {
// 调用删除 API
deleteQuotaResource(rowData.id).then(res => {
console.log('删除定额工料机组成成功', res)
ElMessage.success('删除成功')
handleRowOperation(this, 'delete')
}).catch(err => {
console.error('删除定额工料机组成失败:', err)
ElMessage.error('删除失败')
})
} else {
// 没有 ID 直接删除
handleRowOperation(this, 'delete')
}
}
},
// separator3: '---------',
// undo: {},
// redo: {}
}
},
afterChange(changes: any, source: string) {
console.log('afterChange', changes, source)
// 忽略特定来源的变更
if (!changes || source === 'loadData' || source === 'updateId' || source === 'selectResource' || source === 'updateTotal') {
return
}
// 只处理 quotaQuantity 和 adjustQuantity 字段的变更
changes.forEach(([row, prop, oldValue, newValue]: any) => {
if (prop === 'quotaQuantity' || prop === 'adjustQuantity') {
const rowData = this.getSourceDataAtRow(row)
// 当定额消耗量改变时,重新计算合价
if (prop === 'quotaQuantity') {
const dosage = newValue || 0
const basePriceExTax = rowData.basePriceExTax || 0
const basePriceInTax = rowData.basePriceInTax || 0
const compilePriceExTax = rowData.compilePriceExTax || 0
const compilePriceInTax = rowData.compilePriceInTax || 0
// 更新合价字段
this.setDataAtRowProp([
[row, 'totalBasePriceExTax', Number((basePriceExTax * dosage).toFixed(2))],
[row, 'totalBasePriceInTax', Number((basePriceInTax * dosage).toFixed(2))],
[row, 'totalCompilePriceExTax', Number((compilePriceExTax * dosage).toFixed(2))],
[row, 'totalCompilePriceInTax', Number((compilePriceInTax * dosage).toFixed(2))]
], 'updateTotal')
}
// 如果有ID调用更新接口
if (rowData?.id && props.quotaItemId) {
const updateData = {
id: rowData.id,
quotaItemId: props.quotaItemId,
resourceItemId: rowData.resourceItemId,
dosage: rowData.quotaQuantity || 0,
attributes: {
adjustQuantity: rowData.adjustQuantity || 0
}
}
updateQuotaResource(updateData).then(() => {
console.log('更新定额消耗量成功')
}).catch(err => {
console.error('更新定额消耗量失败:', err)
ElMessage.error('更新失败')
})
}
}
})
}
} }
// 计算属性:根据是否选择了定额子目来决定是否显示右键菜单
const contextMenuItems = computed(() => {
// 如果没有选择定额子目,返回空数组(不显示右键菜单)
if (!props.quotaItemId) {
return []
}
// 如果选择了定额子目,返回完整的右键菜单
return [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 执行新增行操作
handleRowOperation(hotInstance, 'append')
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
}
]
})
const clear = ()=>{
load([])
}
// 监听 quotaItemId 变化
/*watch(
() => props.quotaItemId,
(oldQuotaItemId,newQuotaItemId) => {
console.log('SubItemMaterials: quotaItemId 变化:', oldQuotaItemId,newQuotaItemId)
if (newQuotaItemId) {
// loadQuotaResources(newQuotaItemId)
} else {
// 清空数据
// quotaResourcesData.value = []
if (hstRef.value?.hotInstance) {
// hstRef.value.hotInstance.loadData([])
load([])
}
}
},
{ immediate: true,deep: true }
)*/
watch( watch(
() => props.height, () => props.height,
@@ -55,7 +510,7 @@ watch(
console.log('SubItemMaterials newHeight', newHeight) console.log('SubItemMaterials newHeight', newHeight)
if (newHeight && hstRef.value?.hotInstance) { if (newHeight && hstRef.value?.hotInstance) {
hstRef.value.hotInstance.updateSettings({ hstRef.value.hotInstance.updateSettings({
height: newHeight - 50 - 15// 减去 tabs 头部和 padding 的高度,滚动条 height: newHeight - 50// 减去 tabs 头部和 padding 的高度,滚动条
}) })
hstRef.value.hotInstance.render() hstRef.value.hotInstance.render()
} }
@@ -64,17 +519,29 @@ watch(
) )
onMounted(() => { onMounted(() => {
console.log('SubItemMaterials mounted') console.log('SubItemMaterials mounted, quotaItemId:', props.quotaItemId)
if(props.quotaItemId){
loadQuotaResources(props.quotaItemId)
}
}) })
defineExpose({ hstRef }) defineExpose({ hstRef, loadQuotaResources, clear })
</script> </script>
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full" @contextmenu.prevent>
<DbHst ref="hstRef" :settings="settings" /> <DbHst ref="hstRef" :settings="settings" :contextMenuItems="contextMenuItems" />
</div> </div>
<DbHstPopover
ref="popoverComponentRef"
:visible="visible"
:button-ref="buttonRef"
:columns="popoverColumns"
:data="popoverTableData"
:row-event-handlers="rowEventHandlers"
@end-reached="handleTableEndReached"
/>
</template> </template>
<style scoped> <style scoped>
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { defineConfig } from '@vben/vite-config'; import { defineConfig } from '@vben/vite-config';
import { codeInspectorPlugin } from 'code-inspector-plugin';
import ElementPlus from 'unplugin-element-plus/vite'; import ElementPlus from 'unplugin-element-plus/vite';
export default defineConfig(async () => { export default defineConfig(async () => {
@@ -10,6 +11,9 @@ export default defineConfig(async () => {
ElementPlus({ ElementPlus({
format: 'esm', format: 'esm',
}), }),
codeInspectorPlugin({
bundler: 'vite',
}),
], ],
server: { server: {
proxy: { proxy: {

View File

@@ -78,6 +78,7 @@
"@vitejs/plugin-vue-jsx": "catalog:", "@vitejs/plugin-vue-jsx": "catalog:",
"@vue/test-utils": "catalog:", "@vue/test-utils": "catalog:",
"autoprefixer": "catalog:", "autoprefixer": "catalog:",
"code-inspector-plugin": "^1.3.4",
"cross-env": "catalog:", "cross-env": "catalog:",
"cspell": "catalog:", "cspell": "catalog:",
"happy-dom": "catalog:", "happy-dom": "catalog:",

106
pnpm-lock.yaml generated
View File

@@ -656,6 +656,9 @@ importers:
autoprefixer: autoprefixer:
specifier: 'catalog:' specifier: 'catalog:'
version: 10.4.22(postcss@8.5.6) version: 10.4.22(postcss@8.5.6)
code-inspector-plugin:
specifier: ^1.3.4
version: 1.3.4
cross-env: cross-env:
specifier: 'catalog:' specifier: 'catalog:'
version: 7.0.3 version: 7.0.3
@@ -2689,6 +2692,24 @@ packages:
resolution: {integrity: sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==} resolution: {integrity: sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@code-inspector/core@1.3.4':
resolution: {integrity: sha512-AUFtDH/hngBHrNVwW1z44ogZkaGhfmFQJZjyQrSCp+mERVQqa4QNGQpRqiIEWVCwJ2e3GCuwxeAr/k48r1nscA==}
'@code-inspector/esbuild@1.3.4':
resolution: {integrity: sha512-VVZLPnaUNtX4fm07bKBkdIn2t1H0jPzah3fHd/CtNO82mnT1H3hZCuvtlO4jQZnErWC1KYJUEDrEe4T5TA5giQ==}
'@code-inspector/mako@1.3.4':
resolution: {integrity: sha512-SvjZSfLXgiWDMmJ9+YfPqbE2WVbXCNPjREclEJfqkM2qS/oRPmHw/O81p5wh6dN48gzVadLKEOPtSE0+FURJgQ==}
'@code-inspector/turbopack@1.3.4':
resolution: {integrity: sha512-zsv2ppMFedNZetrlN4PEW4B2vAheu3yUfrmSKfZlXEb8YT378sq+49+57aP/E1Q8cHRzowy4GItuPKwAy7TTVQ==}
'@code-inspector/vite@1.3.4':
resolution: {integrity: sha512-BcRnQFwt8yQ4CcbN7yPf/Vmon3yfS5lPpcH0QAcjD03r61if5fmix29f65Rf/WxzlVyBkk7148xaIVQ3iT2Yjg==}
'@code-inspector/webpack@1.3.4':
resolution: {integrity: sha512-lqsDOSmKXgOYvlurWL4SHaNItNCNZDVbFJlroM4ECnOGt/iNUI0UwaFxn9U5NeKff7fnTmdH8Hrz2IaMR4FVTg==}
'@codemirror/autocomplete@6.20.0': '@codemirror/autocomplete@6.20.0':
resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
@@ -5496,6 +5517,10 @@ packages:
resolution: {integrity: sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==} resolution: {integrity: sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
chalk@4.1.1:
resolution: {integrity: sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==}
engines: {node: '>=10'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -5614,6 +5639,9 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
code-inspector-plugin@1.3.4:
resolution: {integrity: sha512-735rAAc655oSAMd/6+PIsjpgB5jwugDISom9WFwhNUbOuFHiL2PYwshMmfIhAtOgECl+7g6o50rBIIYwCEa8xg==}
codemirror@5.65.20: codemirror@5.65.20:
resolution: {integrity: sha512-i5dLDDxwkFCbhjvL2pNjShsojoL3XHyDwsGv1jqETUoW+lzpBKKqNTUWgQwVAOa0tUm4BwekT455ujafi8payA==} resolution: {integrity: sha512-i5dLDDxwkFCbhjvL2pNjShsojoL3XHyDwsGv1jqETUoW+lzpBKKqNTUWgQwVAOa0tUm4BwekT455ujafi8payA==}
@@ -7788,6 +7816,9 @@ packages:
resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==} resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==}
engines: {node: '>=18'} engines: {node: '>=18'}
launch-ide@1.3.1:
resolution: {integrity: sha512-opTthrpkuhi1Y8yFn6TWUeycyiI1aiZpVuTV4HQFUfVut7nMYGr5nQ8heYHrRJH2KYISLVYwz+QFRNZxFlbQmA==}
lazystream@1.0.1: lazystream@1.0.1:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'} engines: {node: '>= 0.6.3'}
@@ -8724,6 +8755,10 @@ packages:
popmotion@11.0.5: popmotion@11.0.5:
resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==} resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==}
portfinder@1.0.38:
resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==}
engines: {node: '>= 10.12'}
possible-typed-array-names@1.1.0: possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -12059,6 +12094,48 @@ snapshots:
dependencies: dependencies:
mime: 3.0.0 mime: 3.0.0
'@code-inspector/core@1.3.4':
dependencies:
'@vue/compiler-dom': 3.5.25
chalk: 4.1.2
dotenv: 16.6.1
launch-ide: 1.3.1
portfinder: 1.0.38
transitivePeerDependencies:
- supports-color
'@code-inspector/esbuild@1.3.4':
dependencies:
'@code-inspector/core': 1.3.4
transitivePeerDependencies:
- supports-color
'@code-inspector/mako@1.3.4':
dependencies:
'@code-inspector/core': 1.3.4
transitivePeerDependencies:
- supports-color
'@code-inspector/turbopack@1.3.4':
dependencies:
'@code-inspector/core': 1.3.4
'@code-inspector/webpack': 1.3.4
transitivePeerDependencies:
- supports-color
'@code-inspector/vite@1.3.4':
dependencies:
'@code-inspector/core': 1.3.4
chalk: 4.1.1
transitivePeerDependencies:
- supports-color
'@code-inspector/webpack@1.3.4':
dependencies:
'@code-inspector/core': 1.3.4
transitivePeerDependencies:
- supports-color
'@codemirror/autocomplete@6.20.0': '@codemirror/autocomplete@6.20.0':
dependencies: dependencies:
'@codemirror/language': 6.11.3 '@codemirror/language': 6.11.3
@@ -15179,6 +15256,11 @@ snapshots:
dependencies: dependencies:
chalk: 5.6.2 chalk: 5.6.2
chalk@4.1.1:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -15336,6 +15418,18 @@ snapshots:
cluster-key-slot@1.1.2: {} cluster-key-slot@1.1.2: {}
code-inspector-plugin@1.3.4:
dependencies:
'@code-inspector/core': 1.3.4
'@code-inspector/esbuild': 1.3.4
'@code-inspector/mako': 1.3.4
'@code-inspector/turbopack': 1.3.4
'@code-inspector/vite': 1.3.4
'@code-inspector/webpack': 1.3.4
chalk: 4.1.1
transitivePeerDependencies:
- supports-color
codemirror@5.65.20: {} codemirror@5.65.20: {}
codemirror@6.65.7: {} codemirror@6.65.7: {}
@@ -17678,6 +17772,11 @@ snapshots:
dependencies: dependencies:
package-json: 10.0.1 package-json: 10.0.1
launch-ide@1.3.1:
dependencies:
chalk: 4.1.2
dotenv: 16.6.1
lazystream@1.0.1: lazystream@1.0.1:
dependencies: dependencies:
readable-stream: 2.3.8 readable-stream: 2.3.8
@@ -18672,6 +18771,13 @@ snapshots:
style-value-types: 5.1.2 style-value-types: 5.1.2
tslib: 2.4.0 tslib: 2.4.0
portfinder@1.0.38:
dependencies:
async: 3.2.6
debug: 4.4.3
transitivePeerDependencies:
- supports-color
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss-antd-fixes@0.2.0(postcss@8.5.6): postcss-antd-fixes@0.2.0(postcss@8.5.6):