第二阶段代码

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

3
.gitignore vendored
View File

@@ -49,4 +49,7 @@ vite.config.ts.*
*.sln
*.sw?
.history
**/ele-docs
docs/

View File

@@ -32,4 +32,13 @@ pnpm install
# 启动项目
pnpm dev
此时,你会看到类似如下的输出,选择你需要运行的项目:
◆ Select the app you need to run [dev]:
│ ○ @vben/web-antd
│ ○ @vben/web-ele
│ ○ @vben/web-naive
│ ○ @vben/docs
│ ● @vben/playground
现在,你可以在浏览器访问

View File

@@ -4,8 +4,7 @@ VITE_PORT=5777
VITE_BASE=/
# 请求路径
#VITE_BASE_URL=http://127.0.0.1:48080
VITE_BASE_URL=http://test.yihuiyong.cn:48080
VITE_BASE_URL=http://127.0.0.1:48080
# 接口地址
VITE_GLOB_API_URL=/admin-api

View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

View File

@@ -0,0 +1,290 @@
import { requestClient } from '#/api/request';
// ==================== 类型定义 ====================
export namespace InfoPriceApi {
// ==================== 第一层:信息价树结构 ====================
export interface TreeNode {
id?: any;
enumType: string;
parentId?: any;
code: string;
name: string;
sortOrder?: number;
children?: TreeNode[];
}
export interface SwapSortRequest {
nodeId1: number;
nodeId2: number;
}
// ==================== 第二层:信息价册 ====================
export interface Book {
id?: any;
treeNodeId: any;
catalogVersion?: string;
name: string;
startTime: string;
endTime: string;
publishStatus?: string;
publishTime?: string;
attachment?: string;
[key: string]: any;
}
export interface BookPageParams {
pageNo: number;
pageSize: number;
treeNodeId?: number;
startTimeBegin?: string;
startTimeEnd?: string;
endTimeBegin?: string;
endTimeEnd?: string;
publishStatus?: string;
attachment?: boolean;
}
// ==================== 第三层:工料机分类树 ====================
export interface CategoryTreeNode {
id?: any;
bookId: any;
parentId?: any;
code: string;
name: string;
nodeType: number;
sortOrder?: number;
children?: CategoryTreeNode[];
}
// ==================== 第四层:工料机信息 ====================
export interface Resource {
id?: any;
categoryTreeId: any;
code: string;
name: string;
spec?: string;
unit: string;
priceTaxExcl: number;
taxRate: number;
priceTaxIncl: number;
categoryId?: number;
remark?: string;
sortOrder?: number;
}
export interface ResourcePageParams {
pageNo: number;
pageSize: number;
categoryTreeId?: any;
}
export interface ResourceSwapSortRequest {
itemId1: number;
itemId2: number;
}
// ==================== 第五层:工料机价格历史 ====================
export interface ResourcePrice {
id?: any;
resourceId: any;
startTime: string;
endTime: string;
priceTaxExcl: number;
taxRate: number;
priceTaxIncl: number;
}
export interface ResourcePricePageParams {
pageNo: number;
pageSize: number;
resourceId?: number;
}
}
// ==================== 【第一层】信息价树结构 API ====================
/** 获取信息价树结构 */
export function getInfoPriceTree(enumType: string) {
return requestClient.get<InfoPriceApi.TreeNode[]>(`/core/info-price/tree/tree?enumType=${enumType}`);
}
/** 获取信息价树节点详情 */
export function getInfoPriceTreeNode(id: any) {
return requestClient.get<InfoPriceApi.TreeNode>(`/core/info-price/tree/get?id=${id}`);
}
/** 创建信息价树节点 */
export function createInfoPriceTreeNode(data: InfoPriceApi.TreeNode) {
return requestClient.post('/core/info-price/tree/create', data);
}
/** 更新信息价树节点 */
export function updateInfoPriceTreeNode(data: InfoPriceApi.TreeNode) {
return requestClient.put('/core/info-price/tree/update', data);
}
/** 删除信息价树节点 */
export function deleteInfoPriceTreeNode(id: any) {
return requestClient.delete(`/core/info-price/tree/delete?id=${id}`);
}
/** 交换信息价树节点排序 */
export function swapInfoPriceTreeSort(data: InfoPriceApi.SwapSortRequest) {
return requestClient.post('/core/info-price/tree/swap-sort', data);
}
// ==================== 【第二层】信息价册 API ====================
/** 获取信息价册详情 */
export function getInfoPriceBook(id: any) {
return requestClient.get<InfoPriceApi.Book>(`/core/info-price/book/get?id=${id}`);
}
/** 分页查询信息价册 */
export function getInfoPriceBookPage(params: InfoPriceApi.BookPageParams) {
return requestClient.get<{ list: InfoPriceApi.Book[]; total: number }>('/core/info-price/book/page', { params });
}
/** 创建信息价册 */
export function createInfoPriceBook(data: InfoPriceApi.Book) {
return requestClient.post('/core/info-price/book/create', data);
}
/** 更新信息价册 */
export function updateInfoPriceBook(data: InfoPriceApi.Book) {
return requestClient.put('/core/info-price/book/update', data);
}
/** 删除信息价册 */
export function deleteInfoPriceBook(id: any) {
return requestClient.delete(`/core/info-price/book/delete?id=${id}`);
}
/** 分页查询信息价册-复制页 */
export function getInfoPriceBookCopyPage(params: InfoPriceApi.BookPageParams) {
return requestClient.get<{ list: InfoPriceApi.Book[]; total: number }>('/core/info-price/book/copyPage', { params });
}
/** 创建信息价册-复制页 */
export function createInfoPriceBookCopy(id: any) {
return requestClient.post(`/core/info-price/book/create/copy?id=${id}`);
}
/** 根据树节点ID查询全部信息价册 */
export function getInfoPriceBookList(params: Record<string, any>) {
// const params: Record<string, any> = { treeNodeId };
// if (excludeBookId !== undefined && excludeBookId !== null) {
// params.excludeBookId = excludeBookId;
// }
return requestClient.get<InfoPriceApi.Book[]>('/core/info-price/book/list', { params });
}
// ==================== 【第三层】工料机分类树 API ====================
/** 获取工料机分类树结构 */
export function getCategoryTree(bookId: number) {
return requestClient.get<InfoPriceApi.CategoryTreeNode[]>(`/core/info-price/category-tree/tree?bookId=${bookId}`);
}
/** 获取工料机分类列表(平铺) */
export function getCategoryTreeList(bookId: number) {
return requestClient.get<InfoPriceApi.CategoryTreeNode[]>(`/core/info-price/category-tree/list?bookId=${bookId}`);
}
/** 获取工料机分类节点详情 */
export function getCategoryTreeNode(id: number) {
return requestClient.get<InfoPriceApi.CategoryTreeNode>(`/core/info-price/category-tree/get?id=${id}`);
}
/** 创建工料机分类节点 */
export function createCategoryTreeNode(data: InfoPriceApi.CategoryTreeNode) {
return requestClient.post('/core/info-price/category-tree/create', data);
}
/** 更新工料机分类节点 */
export function updateCategoryTreeNode(data: InfoPriceApi.CategoryTreeNode) {
return requestClient.put('/core/info-price/category-tree/update', data);
}
/** 删除工料机分类节点 */
export function deleteCategoryTreeNode(id: number) {
return requestClient.delete(`/core/info-price/category-tree/delete?id=${id}`);
}
/** 交换工料机分类节点排序 */
export function swapCategoryTreeSort(data: InfoPriceApi.SwapSortRequest) {
return requestClient.post('/core/info-price/category-tree/swap-sort', data);
}
// ==================== 【第四层】工料机信息 API ====================
/** 获取工料机信息详情 */
export function getResource(id: any) {
return requestClient.get<InfoPriceApi.Resource>(`/core/info-price/resource/get?id=${id}`);
}
/** 分页查询工料机信息 */
export function getResourcePage(params: InfoPriceApi.ResourcePageParams) {
return requestClient.get<{ list: InfoPriceApi.Resource[]; total: number }>('/core/info-price/resource/page', { params });
}
/** 创建工料机信息 */
export function createResource(data: InfoPriceApi.Resource) {
return requestClient.post('/core/info-price/resource/create', data);
}
/** 更新工料机信息 */
export function updateResource(data: InfoPriceApi.Resource) {
return requestClient.put('/core/info-price/resource/update', data);
}
/** 删除工料机信息 */
export function deleteResource(id: number) {
return requestClient.delete(`/core/info-price/resource/delete?id=${id}`);
}
/** 交换工料机信息排序 */
export function swapResourceSort(data: InfoPriceApi.ResourceSwapSortRequest) {
return requestClient.post('/core/info-price/resource/swap-sort', data);
}
/** 批量创建信息价工料机信息 */
export function createResourceBatch(data: InfoPriceApi.Resource[]) {
return requestClient.post('/core/info-price/resource/create-batch', data);
}
/** 调用历史信息-获得信息价工料机信息分页 */
export function getResourcePageHistory(params: InfoPriceApi.ResourcePageParams) {
return requestClient.get<{ list: InfoPriceApi.Resource[]; total: number }>('/core/info-price/resource/history', { params });
}
// ==================== 【第五层】工料机价格历史 API ====================
/** 获取工料机价格历史详情 */
export function getResourcePrice(id: number) {
return requestClient.get<InfoPriceApi.ResourcePrice>(`/core/info-price/resource-price/get?id=${id}`);
}
/** 分页查询工料机价格历史 */
export function getResourcePricePage(params: InfoPriceApi.ResourcePricePageParams) {
return requestClient.get<{ list: InfoPriceApi.ResourcePrice[]; total: number }>('/core/info-price/resource-price/page', { params });
}
/** 创建工料机价格历史 */
export function createResourcePrice(data: InfoPriceApi.ResourcePrice) {
return requestClient.post('/core/info-price/resource-price/create', data);
}
/** 更新工料机价格历史 */
export function updateResourcePrice(data: InfoPriceApi.ResourcePrice) {
return requestClient.put('/core/info-price/resource-price/update', data);
}
/** 删除工料机价格历史 */
export function deleteResourcePrice(id: number) {
return requestClient.delete(`/core/info-price/resource-price/delete?id=${id}`);
}
/** 调用历史信息-获得信息价工料机价格历史分页 */
export function getResourcePricePageHistory(params: InfoPriceApi.ResourcePricePageParams) {
return requestClient.get<{ list: InfoPriceApi.ResourcePrice[]; total: number }>('/core/info-price/resource-price/history', { params });
}

View File

@@ -0,0 +1,186 @@
import { requestClient } from '#/api/request';
export namespace CardinalRateApi {
/** 目录树节点类型 */
export type CatalogNodeType = 'root' | 'province' | 'content';
/** 目录树节点属性 */
export interface CatalogNodeAttributes {
[key: string]: any;
}
/** 目录树节点 */
export interface CatalogNode {
id?: number;
parentId?: number | null;
code: string;
name: string;
nodeType: CatalogNodeType;
sortOrder: number;
attributes: CatalogNodeAttributes;
children?: CatalogNode[];
}
/** 目录节点属性 */
export interface DirectoryAttributes {
[key: string]: any;
}
/** 目录节点 */
export interface DirectoryNode {
id?: number;
calcBaseRateCatalogId: number;
parentId?: number | null;
name: string;
sortOrder: number;
attributes?: DirectoryAttributes;
children?: DirectoryNode[];
}
/** 费率项属性 */
export interface ItemAttributes {
[key: string]: any;
}
/** 费率项 */
export interface RateItem {
id?: number;
calcBaseRateDirectoryId: number;
name: string;
rate: string;
calcBase: string;
remark?: string;
sortOrder: number;
attributes?: ItemAttributes;
}
}
// ============================================================================
// 目录树接口 (第一层)
// ============================================================================
/** 获取目录树 */
export async function getCatalogTree() {
return requestClient.get<CardinalRateApi.CatalogNode[]>('/core/calc-base-rate/catalog/tree');
}
/** 创建目录树节点 */
export async function createCatalogNode(data: Omit<CardinalRateApi.CatalogNode, 'id' | 'children'>) {
return requestClient.post('/core/calc-base-rate/catalog/create', data);
}
/** 获取目录树节点详情 */
export async function getCatalogNode(id: number) {
return requestClient.get<CardinalRateApi.CatalogNode>(`/core/calc-base-rate/catalog/get?id=${id}`);
}
/** 更新目录树节点 */
export async function updateCatalogNode(data: Partial<Pick<CardinalRateApi.CatalogNode, 'id' | 'name' | 'sortOrder' | 'attributes'>>) {
return requestClient.put('/core/calc-base-rate/catalog/update', data);
}
/** 删除目录树节点 */
export async function deleteCatalogNode(id: number) {
return requestClient.delete(`/core/calc-base-rate/catalog/delete?id=${id}`);
}
// ============================================================================
// 目录接口 (第二层)
// ============================================================================
/** 获取目录树 */
export async function getDirectoryTree(calcBaseRateCatalogId: number) {
return requestClient.get<CardinalRateApi.DirectoryNode[]>(`/core/calc-base-rate/directory/tree?calcBaseRateCatalogId=${calcBaseRateCatalogId}`);
}
/** 创建目录节点 */
export async function createDirectoryNode(data: Omit<CardinalRateApi.DirectoryNode, 'id' | 'children'>) {
return requestClient.post('/core/calc-base-rate/directory/create', data);
}
/** 获取目录节点详情 */
export async function getDirectoryNode(id: number) {
return requestClient.get<CardinalRateApi.DirectoryNode>(`/core/calc-base-rate/directory/get?id=${id}`);
}
/** 更新目录节点 */
export async function updateDirectoryNode(data: Partial<Pick<CardinalRateApi.DirectoryNode, 'id' | 'name' | 'sortOrder' | 'attributes'>>) {
return requestClient.put('/core/calc-base-rate/directory/update', data);
}
/** 删除目录节点 */
export async function deleteDirectoryNode(id: number) {
return requestClient.delete(`/core/calc-base-rate/directory/delete?id=${id}`);
}
/** 强制删除目录节点(级联删除) */
export async function forceDeleteDirectoryNode(id: number) {
return requestClient.delete(`/core/calc-base-rate/directory/force-delete?id=${id}`);
}
/** 交换目录节点排序 */
export async function swapDirectorySort(nodeId1: number, nodeId2: number) {
return requestClient.post('/core/calc-base-rate/directory/swap-sort', { nodeId1, nodeId2 });
}
// ============================================================================
// 费率项接口 (第三层)
// ============================================================================
/** 获取费率项列表 */
export async function getRateItemList(calcBaseRateDirectoryId: string | number) {
return requestClient.get<CardinalRateApi.RateItem[]>(`/core/calc-base-rate/item/list?calcBaseRateDirectoryId=${calcBaseRateDirectoryId}`);
}
/** 创建费率项 */
export async function createRateItem(data: Omit<CardinalRateApi.RateItem, 'id'>) {
return requestClient.post('/core/calc-base-rate/item/create', data);
}
/** 获取费率项详情 */
export async function getRateItem(id: number) {
return requestClient.get<CardinalRateApi.RateItem>(`/core/calc-base-rate/item/get?id=${id}`);
}
/** 更新费率项 */
export async function updateRateItem(data: Partial<CardinalRateApi.RateItem> & Pick<CardinalRateApi.RateItem, 'id'>) {
return requestClient.put('/core/calc-base-rate/item/update', data);
}
/** 删除费率项 */
export async function deleteRateItem(id: number) {
return requestClient.delete(`/core/calc-base-rate/item/delete?id=${id}`);
}
// ============================================================================
// 导出所有接口
// ============================================================================
export const cardinalRateApi = {
// 目录树接口
catalog: {
getTree: getCatalogTree,
create: createCatalogNode,
get: getCatalogNode,
update: updateCatalogNode,
delete: deleteCatalogNode,
},
// 目录接口
directory: {
getTree: getDirectoryTree,
create: createDirectoryNode,
get: getDirectoryNode,
update: updateDirectoryNode,
delete: deleteDirectoryNode,
forceDelete: forceDeleteDirectoryNode,
swapSort: swapDirectorySort,
},
// 费率项接口
item: {
list: getRateItemList,
create: createRateItem,
get: getRateItem,
update: updateRateItem,
delete: deleteRateItem,
},
};

View File

@@ -0,0 +1,132 @@
import { requestClient } from '#/api/request';
export namespace UnitConfigApi {
/** 模板节点 */
export type DivisionTemplate = {
id?: number;
catalogItemId: number;
parentId?: number | null;
nodeType: string;
tabType: string;//division/measure/other/unit_summary
code: string;
name: string;
unit?: string | null;
insertPosition?: string;
referenceNodeId?: number | null;
};
/** 工作台字段设置 */
export type FieldConfig = {
id?: number;
catalogItemId: number;
fieldName: string;
fieldCode: string;
divisionHidden: boolean;
measureHidden: boolean;
otherHidden: boolean;
summaryHidden: boolean;
};
}
// ==================== 模板节点 ====================
// 创建模板节点
export function createDivisionTemplate(data: UnitConfigApi.DivisionTemplate) {
return requestClient.post('/core/config/unit-division-template/create', data);
}
// 更新模板节点
export function updateDivisionTemplate(data: UnitConfigApi.DivisionTemplate) {
return requestClient.put('/core/config/unit-division-template/update', data);
}
// 删除模板节点
export function deleteDivisionTemplate(id: number) {
return requestClient.delete(
`/core/config/unit-division-template/delete?id=${id}`,
);
}
// 获取模板树
export function getDivisionTemplateTree(
catalogItemId: number,
tabType: string,
) {
return requestClient.get('/core/config/unit-division-template/tree', {
params: { catalogItemId, tabType },
});
}
// 交换模板节点排序
export function swapDivisionTemplateSort(nodeId1: number, nodeId2: number) {
return requestClient.post(
`/core/config/unit-division-template/swap-sort?nodeId1=${nodeId1}&nodeId2=${nodeId2}`,
);
}
// ==================== 工作台字段 ====================
// 创建工作台字段设置
export function createFieldConfig(data: UnitConfigApi.FieldConfig) {
return requestClient.post('/core/config/unit-field/create', data);
}
/** 更新工作台字段设置 */
export async function updateFieldConfig(data: any) {
return requestClient.put('/core/config/unit-field/update', data);
}
/** 批量创建工作台字段设置 */
export async function batchCreateFieldConfig(data: any[]) {
return requestClient.post('/core/config/unit-field/batch-create', data);
}
/** 删除工作台字段设置 */
export async function deleteFieldConfig(id: number) {
return requestClient.delete(`/core/config/unit-field/delete?id=${id}`);
}
// 获取工作台字段列表
export function getFieldConfigList(catalogItemId: number | string) {
return requestClient.get('/core/config/unit-field/list', {
params: { catalogItemId },
});
}
// 获取隐藏字段(工作台)
export function getWorkbenchHiddenFields(catalogItemId: number) {
return requestClient.get('/core/config/unit-field/hidden-fields', {
params: { catalogItemId },
});
}
// ==================== 工料机字段 ====================
/** 创建工料机字段设置 */
export async function createResourceField(data: any) {
return requestClient.post('/core/config/unit-resource-field/create', data);
}
/** 更新工料机字段设置 */
export async function updateResourceField(data: any) {
return requestClient.put('/core/config/unit-resource-field/update', data);
}
/** 批量创建工料机字段设置 */
export async function batchResourceField(data: any[]) {
return requestClient.post('/core/config/unit-resource-field/batch-create', data);
}
/** 删除工料机字段设置 */
export async function deleteResourceField(id: number) {
return requestClient.delete(`/core/config/unit-resource-field/delete?id=${id}`);
}
// 获取工料机字段列表
export function getResourceFieldList(catalogItemId: number) {
return requestClient.get('/core/config/unit-resource-field/list', {
params: { catalogItemId },
});
}
// 获取工料机隐藏字段(工作台)
export function getResourceFieldHiddenFields(catalogItemId: number) {
return requestClient.get('/core/config/unit-resource-field/hidden-fields', {
params: { catalogItemId },
});
}

View File

@@ -0,0 +1,8 @@
import { requestClient } from '#/api/request';
const BASE_URL = '/core/config/project-tree';
/** 获取行业下拉选项(省市-行业两级结构) */
export function getIndustryOptions() {
return requestClient.get<any>(`${BASE_URL}/industry-options`);
}

View File

@@ -0,0 +1,263 @@
import { requestClient } from '#/api/request';
// ==================== 类型定义 ====================
export namespace BoqApi {
/** 节点类型 */
export type NodeType = 'province' | 'specialty' | 'directory' | 'content';
/** 【第一层】清单目录树节点 */
export interface CatalogItem {
id?: any;
code: string;
name: string;
nodeType: string; // 'province' | 'specialty' | 'root'
parentId?: number;
sortOrder?: number;
children?: CatalogItem[];
quotaCatalogItemId?: any;
}
/** 【第二层】清单项树节点 */
export interface ItemTree {
id?: any;
boqCatalogItemId: any;
parentId?: any;
code: string;
name: string;
unit?: string;
sortOrder?: number;
children?: ItemTree[];
}
/** 【第三层】清单子项 */
export interface SubItem {
id?: any;
boqItemTreeId: any;
code: string;
name: string;
unit?: string;
description?: string;
sortOrder?: number;
}
/** 【第四层】清单指引树节点 */
export interface GuideTree {
id?: any;
boqSubItemId: any;
parentId?: any;
code: string;
name: string;
unit?: string;
nodeType: string;//'directory' | 'quota'
quotaCatalogItemId?: any;
sortOrder?: number;
children?: GuideTree[];
}
/** 清单项树删除检查结果 */
export interface ItemTreeDeleteCheck {
hasChildren: boolean;
childrenCount: number;
hasSubItems: boolean;
subItemsCount: number;
hasGuides: boolean;
guidesCount: number;
canDirectDelete: boolean;
confirmMessage: string;
}
/** 绑定定额请求 */
export interface BindQuotaRequest {
boqCatalogItemId: any;
quotaCatalogItemId: any;
}
/** 交换排序请求 */
export interface SwapSortRequest {
nodeId1: number;
nodeId2: number;
}
/** 子项交换排序请求 */
export interface SubItemSwapSortRequest {
itemId1: number;
itemId2: number;
}
}
// ==================== 【第一层】清单目录树 API ====================
/** 获取清单目录树 */
export function getCatalogItemTree() {
return requestClient.get<BoqApi.CatalogItem[]>('/core/boq/catalog-item/tree');
}
/** 获取单个目录节点 */
export function getCatalogItem(id: number) {
return requestClient.get<BoqApi.CatalogItem>(`/core/boq/catalog-item/get?id=${id}`);
}
/** 创建目录节点(省市/清单专业) */
export function createCatalogItem(data: BoqApi.CatalogItem) {
return requestClient.post('/core/boq/catalog-item/create', data);
}
/** 更新目录节点 */
export function updateCatalogItem(data: BoqApi.CatalogItem) {
return requestClient.put('/core/boq/catalog-item/update', data);
}
/** 删除目录节点 */
export function deleteCatalogItem(id: number) {
return requestClient.delete(`/core/boq/catalog-item/delete?id=${id}`);
}
/** 绑定定额基价节点 */
export function bindQuotaToCatalog(data: BoqApi.BindQuotaRequest) {
return requestClient.post('/core/boq/catalog-item/bind-quota', data);
}
/** 交换目录节点排序 */
export function swapCatalogItemSort(data: BoqApi.SwapSortRequest) {
return requestClient.post('/core/boq/catalog-item/swap-sort', data);
}
// ==================== 【第二层】清单项树 API ====================
/** 获取清单项树形结构 */
export function getItemTree(boqCatalogItemId: number) {
return requestClient.get<BoqApi.ItemTree[]>(`/core/boq/item-tree/tree?boqCatalogItemId=${boqCatalogItemId}`);
}
/** 获取清单项列表(平铺) */
export function getItemList(boqCatalogItemId: number) {
return requestClient.get<BoqApi.ItemTree[]>(`/core/boq/item-tree/list?boqCatalogItemId=${boqCatalogItemId}`);
}
/** 获取单个清单项节点 */
export function getItemTreeNode(id: number) {
return requestClient.get<BoqApi.ItemTree>(`/core/boq/item-tree/get?id=${id}`);
}
/** 创建清单项节点 */
export function createItemTree(data: BoqApi.ItemTree) {
return requestClient.post('/core/boq/item-tree/create', data);
}
/** 更新清单项节点 */
export function updateItemTree(data: BoqApi.ItemTree) {
return requestClient.put('/core/boq/item-tree/update', data);
}
/** 删除清单项节点 */
export function deleteItemTree(id: number) {
return requestClient.delete(`/core/boq/item-tree/delete?id=${id}`);
}
/** 检查删除清单项节点前的关联数据 */
export function checkDeleteItemTree(id: number) {
return requestClient.get<BoqApi.ItemTreeDeleteCheck>(`/core/boq/item-tree/check-delete?id=${id}`);
}
/** 强制删除清单项节点(级联删除所有关联数据) */
export function forceDeleteItemTree(id: number) {
return requestClient.delete(`/core/boq/item-tree/force-delete?id=${id}`);
}
/** 交换清单项节点排序 */
export function swapItemTreeSort(data: BoqApi.SwapSortRequest) {
return requestClient.post('/core/boq/item-tree/swap-sort', data);
}
// ==================== 【第三层】清单子项 API ====================
/** 获取清单子项列表 */
export function getSubItemList(boqItemTreeId: any) {
return requestClient.get<BoqApi.SubItem[]>(`/core/boq/sub-item/list?boqItemTreeId=${boqItemTreeId}`);
}
/** 获取单个清单子项 */
export function getSubItem(id: number) {
return requestClient.get<BoqApi.SubItem>(`/core/boq/sub-item/get?id=${id}`);
}
/** 创建清单子项 */
export function createSubItem(data: BoqApi.SubItem) {
return requestClient.post('/core/boq/sub-item/create', data);
}
/** 更新清单子项 */
export function updateSubItem(data: BoqApi.SubItem) {
return requestClient.put('/core/boq/sub-item/update', data);
}
/** 删除清单子项 */
export function deleteSubItem(id: number) {
return requestClient.delete(`/core/boq/sub-item/delete?id=${id}`);
}
/** 交换清单子项排序 */
export function swapSubItemSort(data: BoqApi.SubItemSwapSortRequest) {
return requestClient.post('/core/boq/sub-item/swap-sort', data);
}
// ==================== 【第四层】清单指引树 API ====================
/** 获取清单指引树形结构 */
export function getGuideTree(boqSubItemId: number) {
return requestClient.get<BoqApi.GuideTree[]>(`/core/boq/guide-tree/tree?boqSubItemId=${boqSubItemId}`);
}
/** 获取清单指引列表(平铺) */
export function getGuideList(boqSubItemId: number) {
return requestClient.get<BoqApi.GuideTree[]>(`/core/boq/guide-tree/list?boqSubItemId=${boqSubItemId}`);
}
/** 获取单个清单指引节点 */
export function getGuideTreeNode(id: number) {
return requestClient.get<BoqApi.GuideTree>(`/core/boq/guide-tree/get?id=${id}`);
}
/** 创建清单指引节点(目录/定额) */
export function createGuideTree(data: BoqApi.GuideTree) {
return requestClient.post('/core/boq/guide-tree/create', data);
}
/** 更新清单指引节点 */
export function updateGuideTree(data: BoqApi.GuideTree) {
return requestClient.put('/core/boq/guide-tree/update', data);
}
/** 删除清单指引节点 */
export function deleteGuideTree(id: number) {
return requestClient.delete(`/core/boq/guide-tree/delete?id=${id}`);
}
/** 交换清单指引节点排序 */
export function swapGuideTreeSort(data: BoqApi.SwapSortRequest) {
return requestClient.post('/core/boq/guide-tree/swap-sort', data);
}
/** 验证定额编码是否存在于绑定范围内 */
export function validateQuotaCode(boqSubItemId: any, code: string) {
return requestClient.get<number | null>(`/core/boq/guide-tree/validate-quota-code?boqSubItemId=${boqSubItemId}&code=${encodeURIComponent(code)}`);
}
/** 定额编码验证结果(包含名称) */
export interface QuotaCodeValidateResult {
id: number;
code: string;
name: string;
unit?: string;
}
/** 验证定额编码并返回定额信息(包含名称) */
export function validateQuotaCodeWithInfo(boqSubItemId: any, code: string) {
return requestClient.get<QuotaCodeValidateResult | null>(`/core/boq/guide-tree/validate-quota-code-with-info?boqSubItemId=${boqSubItemId}&code=${encodeURIComponent(code)}`);
}
/** 获取清单子项关联的目录信息包含绑定的定额专业ID */
export function getBoqSubItemCatalogInfo(id: any) {
return requestClient.get<BoqApi.SubItem & { quotaCatalogItemId?: any }>(`/core/boq/sub-item/catalog-info?id=${id}`);
}

View File

@@ -38,11 +38,11 @@ export namespace MaterialsApi {
// }
export interface AddCategories {
id?: number,
parentId: number,
parentId?: number,
code?: string,
name: string,
nodeType: string, // "specialty"
sortOrder: number,
sortOrder?: number,
}
/** 工料机项属性 */
@@ -145,6 +145,14 @@ export function createCategoryTreeMapping(data: any) {
export function deleteCategoryTreeMapping(categoryTreeId: any, categoryId: any) {
return requestClient.delete(`/core/resource/category-tree/mapping/${categoryTreeId}/${categoryId}`);
}
// 6.7 拖动节点到指定位置
export function dragCategoryTree(dragNodeId: number | string, targetNodeId: number | string, position: any) {
return requestClient.put('/core/resource/category-tree/drag', {
dragNodeId,
targetNodeId,
position
});
}
//【第三层】工料机目录树
@@ -196,6 +204,18 @@ export function swapCatalogItemSort(nodeId1: number | string, nodeId2: number |
nodeId2
});
}
// 2.7 获取指定层级的分类/条目列表
export function getCatalogItemsListLevel(path: any) {
return requestClient.get<any[]>(`/core/resource/catalog-items/list-level?path=${path}`);
}
// 2.8 拖动节点到指定位置
export function dragCategoryItem(dragNodeId: number | string, targetNodeId: number | string, position: any) {
return requestClient.put('/core/resource/catalog-items/drag', {
dragNodeId,
targetNodeId,
position
});
}
// 【第四层】工料机项
export function getCatalogItemsPage(params: PageParam) {
@@ -217,6 +237,11 @@ export function deleteResourceItems(id: number) {
return requestClient.delete(`/core/resource/items/${id}`);
}
//3.8 根据编码查询工料机项
export function getResourceItemByCode(code: string) {
return requestClient.get<MaterialsApi.CatalogItems | null>(`/core/resource/items/by-code/${code}`);
}
//【第五层】复合工料机价格
export function getResourceItemsPage(params: any) {
return requestClient.get<PageResult<MaterialsApi.ResourceItems>>(

View File

@@ -1,4 +1,4 @@

import { requestClient } from '#/api/request';
export namespace QuotaApi {
@@ -15,6 +15,7 @@ export namespace QuotaApi {
id: number;
code: string;
name: string;
nodeType?: string;
parentId?: number | null; // 支持树形结构
sortOrder: number;
categoryTreeId?: number | null; // 绑定的工料机专业ID
@@ -22,14 +23,14 @@ export namespace QuotaApi {
children?: CatalogItem[]; // 子节点(树形结构)
}
/** 定额子目树节点属性 */
/** 定额基价树节点属性 */
export interface CatalogTreeAttributes {
content_type?: string; // 'directory' | 'content'
remark?: string;
[key: string]: any;
}
/** 定额子目树节点(第二层) */
/** 定额基价树节点(第二层) */
export interface CatalogTreeNode {
id: number | string; // 支持大整数,使用字符串
catalogItemId: number; // 关联第一层定额专业
@@ -42,7 +43,7 @@ export namespace QuotaApi {
children?: CatalogTreeNode[];
}
/** 定额子目 */
/** 定额基价 */
export interface QuotaItem {
id: number;
catalogItemId: number;
@@ -93,6 +94,16 @@ export namespace QuotaApi {
catalogItemId: number | string; // 支持雪花ID字符串
categoryTreeId: number | string; // 支持雪花ID字符串
}
export interface CreateCatalogTreeNodeParams {
catalogItemId: number | string | null; // 支持雪花ID字符串
parentId?: number | string | null; // 支持大整数,使用字符串
code: string;
name: string;
unit?: string;
contentType?: string;//'directory' | 'content';
sortOrder?: number;
attributes?: CatalogTreeAttributes;
}
}
// ==================== 定额专业管理(第一层) ====================
@@ -105,7 +116,11 @@ export function getQuotaCatalogItem(id: number | string) {
}
/** 1.2 获取定额专业树结构 */
export function getQuotaCatalogItemTree(params?: { exclude?: string }) {
export function getQuotaCatalogItemTree(params?: { exclude?: string | string[] }) {
// 如果 exclude 是逗号分隔的字符串,转换为数组
if (params?.exclude && typeof params.exclude === 'string') {
params = { ...params, exclude: params.exclude.split(',').map(s => s.trim()) };
}
return requestClient.get<QuotaApi.CatalogItem[]>(
'/core/quota/catalog-item/tree',
{ params }
@@ -139,16 +154,21 @@ export function bindQuotaSpecialty(data: QuotaApi.BindSpecialtyParams) {
return requestClient.post('/core/quota/catalog-item/bind-specialty', data);
}
// ==================== 定额子目树管理(第二层) ====================
/** 获取指定节点的子节点列表 */
export function getQuotaCatalogItemChildren(parentId: string | number) {
return requestClient.get<QuotaApi.CatalogItem[]>(`/core/quota/catalog-item/children?parentId=${parentId}`);
}
/** 2.4 获取定额子目树节点详情 */
// ==================== 定额基价树管理(第二层) ====================
/** 2.4 获取定额基价树节点详情 */
export function getQuotaCatalogTreeNode(id: number | string) {
return requestClient.get<QuotaApi.CatalogTreeNode>(
`/core/quota/catalog-tree/get?id=${id}`
);
}
/** 2.5 获取定额子目树节点列表 */
/** 2.5 获取定额基价树节点列表 */
export function getQuotaCatalogTreeList(catalogItemId: number | string) {
return requestClient.get<QuotaApi.CatalogTreeNode[]>(
'/core/quota/catalog-tree/list',
@@ -156,7 +176,7 @@ export function getQuotaCatalogTreeList(catalogItemId: number | string) {
);
}
/** 2.6 获取定额子目树结构 */
/** 2.6 获取定额基价树结构 */
export function getQuotaCatalogTree(catalogItemId: number | string) {
return requestClient.get<QuotaApi.CatalogTreeNode[]>(
'/core/quota/catalog-tree/tree',
@@ -164,21 +184,12 @@ export function getQuotaCatalogTree(catalogItemId: number | string) {
);
}
/** 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;
}) {
/** 2.1 创建定额基价树节点 */
export function createQuotaCatalogTreeNode(data: QuotaApi.CreateCatalogTreeNodeParams) {
return requestClient.post<number | string>('/core/quota/catalog-tree/create', data);
}
/** 2.2 更新定额子目树节点 */
/** 2.2 更新定额基价树节点 */
export function updateQuotaCatalogTreeNode(data: {
id: number;
catalogItemId?: number;
@@ -193,7 +204,7 @@ export function updateQuotaCatalogTreeNode(data: {
return requestClient.put('/core/quota/catalog-tree/update', data);
}
/** 2.3 删除定额子目树节点 */
/** 2.3 删除定额基价树节点 */
export function deleteQuotaCatalogTreeNode(id: number | string) {
return requestClient.delete(`/core/quota/catalog-tree/delete?id=${id}`);
}
@@ -214,16 +225,16 @@ export function getQuotaCatalogTreeByRateModeNode(rateModeNodeId: number | strin
);
}
// ==================== 定额子目管理(第四层) ====================
// ==================== 定额基价管理(第四层) ====================
/** 2.4 获取定额子目详情 */
/** 2.4 获取定额基价详情 */
export function getQuotaItem(id: number) {
return requestClient.get<QuotaApi.QuotaItem>(
`/core/quota/item/get?id=${id}`
);
}
/** 2.5 获取定额子目列表 */
/** 2.5 获取定额基价列表 */
export function getQuotaItemList(catalogItemId: number | string) {
return requestClient.get<QuotaApi.QuotaItem[]>(
'/core/quota/item/list',
@@ -231,17 +242,17 @@ export function getQuotaItemList(catalogItemId: number | string) {
);
}
/** 2.1 创建定额子目 */
/** 2.1 创建定额基价 */
export function createQuotaItem(data: any) {
return requestClient.post('/core/quota/item/create', data);
}
/** 2.2 更新定额子目 */
/** 2.2 更新定额基价 */
export function updateQuotaItem(data: any) {
return requestClient.put('/core/quota/item/update', data);
}
/** 2.3 删除定额子目 */
/** 2.3 删除定额基价 */
export function deleteQuotaItem(id: number) {
return requestClient.delete(`/core/quota/item/delete?id=${id}`);
}
@@ -251,6 +262,13 @@ export function calculateQuotaPrice(id: number) {
return requestClient.post(`/core/quota/item/calculate-price?id=${id}`);
}
/** 2.7 根据编码查询定额基价 */
export function getQuotaItemByCode(code: string) {
return requestClient.get<QuotaApi.QuotaItem[]>(
`/core/quota/item/get-by-code?code=${code}`
);
}
// ==================== 定额工料机组成管理 ====================
/** 3.4 获取定额工料机组成列表 */
@@ -284,6 +302,47 @@ export function deleteQuotaResource(id: number) {
return requestClient.delete(`/core/quota/resource/delete?id=${id}`);
}
/** 3.9 根据编码查询可用工料机(精确匹配) */
export function getResourceItemByCode(quotaItemId: number, code: string) {
return requestClient.get<any>(
'/core/quota/resource/get-by-code',
{ params: { quotaItemId, code } }
);
}
/** 3.6 应用调整设置到定额工料机 */
export function applyAdjustmentSetting(quotaItemId: number, adjustmentSettingId: number | string, enabled: boolean) {
return requestClient.post<boolean>(
'/core/quota/resource/apply-adjustment',
null,
{ params: { quotaItemId, adjustmentSettingId, enabled } }
);
}
/** 3.7 应用动态调整到定额工料机 */
export function applyDynamicAdjustment(quotaItemId: number, adjustmentSettingId: number | string, inputValues: Record<string, number>) {
return requestClient.post<boolean>(
'/core/quota/resource/apply-dynamic-adjustment',
{
quotaItemId,
adjustmentSettingId,
inputValues
}
);
}
/** 3.8 应用动态合并定额到定额工料机 */
export function applyDynamicMerge(quotaItemId: number, adjustmentSettingId: number | string, inputValues: Record<string, number>) {
return requestClient.post<boolean>(
'/core/quota/resource/apply-dynamic-merge',
{
quotaItemId,
adjustmentSettingId,
inputValues
}
);
}
// ==================== 定额调整设置管理(第五层) ====================
/** 5.1 创建调整设置 */
@@ -455,7 +514,7 @@ export function moveRateItemNode(nodeId: number | string, targetNodeId: number |
/** 8.1 保存费率字段 */
export function saveRateField(data: {
id?: number | string;
catalogItemId: number | string;
catalogItemId: number | string | null;
rateItemId?: number | string;
fieldIndex: number;
fieldValue?: number;
@@ -535,3 +594,162 @@ export function swapFieldLabelSort(labelId1: number | string, labelId2: number |
params: { labelId1, labelId2 }
});
}
// ==================== 统一取费设置管理 ====================
/** 10.1 创建统一取费设置 */
export function createUnifiedFeeSetting(data: any) {
return requestClient.post('/core/quota/unified-fee-setting/create', data);
}
/** 10.2 更新统一取费设置 */
export function updateUnifiedFeeSetting(data: any) {
return requestClient.put('/core/quota/unified-fee-setting/update', data);
}
/** 10.3 删除统一取费设置 */
export function deleteUnifiedFeeSetting(id: number | string) {
return requestClient.delete(`/core/quota/unified-fee-setting/delete?id=${id}`);
}
/** 10.4 获取统一取费设置详情 */
export function getUnifiedFeeSetting(id: number | string) {
return requestClient.get<any>(`/core/quota/unified-fee-setting/get?id=${id}`);
}
/** 10.5 获取统一取费设置列表 */
export function getUnifiedFeeSettingList(catalogItemId: number | string) {
return requestClient.get<any[]>(
'/core/quota/unified-fee-setting/list',
{ params: { catalogItemId } }
);
}
/** 10.6 获取统一取费设置树 */
export function getUnifiedFeeSettingTree(catalogItemId: number | string) {
return requestClient.get<any[]>(
'/core/quota/unified-fee-setting/tree',
{ params: { catalogItemId } }
);
}
/** 10.7 获取父定额列表 */
export function getUnifiedFeeSettingParentList(catalogItemId: number | string) {
return requestClient.get<any[]>(
'/core/quota/unified-fee-setting/parent-list',
{ params: { catalogItemId } }
);
}
/** 10.8 获取子定额列表 */
export function getUnifiedFeeSettingChildList(parentId: number | string) {
return requestClient.get<any[]>(
'/core/quota/unified-fee-setting/child-list',
{ params: { parentId } }
);
}
/** 10.9 交换统一取费设置排序 */
export function swapUnifiedFeeSettingSort(nodeId1: number | string, nodeId2: number | string) {
return requestClient.post('/core/quota/unified-fee-setting/swap-sort', null, {
params: { nodeId1, nodeId2 }
});
}
// ==================== 统一取费子目工料机管理 ====================
/** 11.1 创建子目工料机 */
export function createUnifiedFeeResource(data: any) {
return requestClient.post('/core/quota/unified-fee-setting/resource/create', data);
}
/** 11.2 更新子目工料机 */
export function updateUnifiedFeeResource(data: any) {
return requestClient.put('/core/quota/unified-fee-setting/resource/update', data);
}
/** 11.3 删除子目工料机 */
export function deleteUnifiedFeeResource(id: number | string) {
return requestClient.delete(`/core/quota/unified-fee-setting/resource/delete?id=${id}`);
}
/** 11.4 获取子目工料机列表 */
export function getUnifiedFeeResourceList(unifiedFeeSettingId: number | string) {
return requestClient.get<any[]>(
'/core/quota/unified-fee-setting/resource/list',
{ params: { unifiedFeeSettingId } }
);
}
/** 11.5 交换子目工料机排序 */
export function swapUnifiedFeeResourceSort(nodeId1: number | string, nodeId2: number | string) {
return requestClient.post('/core/quota/unified-fee-setting/resource/swap-sort', null, {
params: { nodeId1, nodeId2 }
});
}
/** 11.6 根据编码查询工料机 */
export function getUnifiedFeeResourceByCode(unifiedFeeSettingId: number | string, code: string) {
return requestClient.get<any>(
'/core/quota/unified-fee-setting/resource/get-by-code',
{ params: { unifiedFeeSettingId, code } }
);
}
/** 11.7 获取可选工料机列表(支持模糊查询) */
export function getAvailableUnifiedFeeResources(params: {
unifiedFeeSettingId: number | string;
code?: string;
name?: string;
spec?: string;
}) {
return requestClient.get<any[]>(
'/core/quota/unified-fee-setting/resource/available-list',
{ params }
);
}
// ==================== 统一取费单价管理 ====================
/** 12.1 创建统一取费单价 */
export function createUnifiedFee(data: any) {
return requestClient.post('/core/quota/unified-fee/create', data);
}
/** 12.2 更新统一取费单价 */
export function updateUnifiedFee(data: any) {
return requestClient.put('/core/quota/unified-fee/update', data);
}
/** 12.3 删除统一取费单价 */
export function deleteUnifiedFee(id: number | string) {
return requestClient.delete(`/core/quota/unified-fee/delete?id=${id}`);
}
/** 12.4 获取统一取费单价详情 */
export function getUnifiedFee(id: number | string) {
return requestClient.get<any>(`/core/quota/unified-fee/get?id=${id}`);
}
/** 12.5 获取统一取费单价列表 */
export function getUnifiedFeeList(catalogItemId: number | string) {
return requestClient.get<any[]>(
'/core/quota/unified-fee/list',
{ params: { catalogItemId } }
);
}
/** 12.6 获取统一取费单价树 */
export function getUnifiedFeeTree(catalogItemId: number | string) {
return requestClient.get<any[]>(
'/core/quota/unified-fee/tree',
{ params: { catalogItemId } }
);
}
/** 12.7 交换统一取费单价排序 */
export function swapUnifiedFeeSort(nodeId1: number | string, nodeId2: number | string) {
return requestClient.post('/core/quota/unified-fee/swap-sort', null, {
params: { nodeId1, nodeId2 }
});
}

View File

@@ -0,0 +1,72 @@
import { requestClient } from '#/api/request';
export namespace MarketMaterialsApi {
/** 市场主材设备 */
export interface MarketMaterial {
id?: number;
code?: string;
name: string;
spec?: string;
unit?: string;
category?: string;
taxRate?: number;
basePriceExTax?: number;
basePriceInTax?: number;
compilePriceExTax?: number;
compilePriceInTax?: number;
quotaQuantity?: number;
adjustQuantity?: number;
usageQty?: number;
totalPrice?: number;
isEstimated?: boolean;
createTime?: Date;
}
}
/** 查询市场主材设备列表 */
export async function getMarketMaterialsList(quotaItemId: string | number) {
return requestClient.get('/core/quota/market-material/list', { params: { quotaItemId } });
}
/** 查询市场主材设备详情 */
export async function getMarketMaterial(id: number) {
return requestClient.get<MarketMaterialsApi.MarketMaterial>(`/core/quota/market-material/get?id=${id}`);
}
/** 新增市场主材设备 */
export async function createMarketMaterial(data: MarketMaterialsApi.MarketMaterial) {
return requestClient.post('/core/quota/market-material/create', data);
}
/** 修改市场主材设备 */
export async function updateMarketMaterial(data: MarketMaterialsApi.MarketMaterial) {
return requestClient.put('/core/quota/market-material/update', data);
}
/** 删除市场主材设备 */
export async function deleteMarketMaterial(id: number) {
return requestClient.delete(`/core/quota/market-material/delete?id=${id}`);
}
/** 获取可选的工料机列表 */
export async function getAvailableMarketMaterials(params: {
quotaItemId: string | number;
code?: string;
name?: string;
spec?: string;
}) {
return requestClient.get('/core/quota/market-material/available-list', { params });
}
/**
* 根据编码查询可用工料机
* @param params 查询参数
* @param params.quotaItemId 报价项ID
* @param params.code 编码
*/
export async function getMarketMaterialByCode(params: {
quotaItemId: string | number;
code: string;
}) {
return requestClient.get('/core/quota/market-material/get-by-code', { params });
}

View File

@@ -0,0 +1,88 @@
import { requestClient } from '#/api/request';
export interface VariableSettingVO {
id?: string;
tenantId?: string;
catalogItemId: string;
category: 'division' | 'measure' | 'other' | 'unit_summary';
name?: string;
code?: string;
calcBase?: Record<string, any>;
sortOrder?: number;
createTime?: string;
updateTime?: string;
/** 数据来源manual-手动配置fee_item-定额取费引用 */
source?: 'manual' | 'fee_item';
/** 汇总值(后端计算) */
summaryValue?: number | string;
}
export interface VariableSettingSaveReqVO {
id?: string;
catalogItemId: string;
category: string;
name?: string;
code?: string;
calcBase?: Record<string, any>;
sortOrder?: number;
referenceNodeId?: string;
insertPosition?: 'above' | 'below';
}
/** 获取变量设置列表 */
export function getVariableSettingList(catalogItemId: string, category: string) {
return requestClient.get<VariableSettingVO[]>(`/core/quota/variable-setting/list`, {
params: { catalogItemId, category }
});
}
/** 获取所有类别的变量设置列表 */
export function getVariableSettingListAll(catalogItemId: string) {
return requestClient.get<VariableSettingVO[]>(`/core/quota/variable-setting/list-all`, {
params: { catalogItemId }
});
}
/** 获取变量设置详情 */
export function getVariableSetting(id: string) {
return requestClient.get<VariableSettingVO>(`/core/quota/variable-setting/get`, {
params: { id }
});
}
/** 创建变量设置 */
export function createVariableSetting(data: VariableSettingSaveReqVO) {
return requestClient.post<string>('/core/quota/variable-setting/create', data);
}
/** 更新变量设置 */
export function updateVariableSetting(data: VariableSettingSaveReqVO) {
return requestClient.put<boolean>('/core/quota/variable-setting/update', data);
}
/** 删除变量设置 */
export function deleteVariableSetting(id: string) {
return requestClient.delete<boolean>('/core/quota/variable-setting/delete', {
params: { id }
});
}
/** 根据编制树ID获取所有变量设置自动合并所有定额专业 */
export function getVariableSettingListByCompileTree(
compileTreeId: string,
baseNumberRangeIds?: (string | number)[]
) {
return requestClient.get<VariableSettingVO[]>(`/core/quota/variable-setting/list-by-compile-tree`, {
params: {
compileTreeId,
baseNumberRangeIds: baseNumberRangeIds?.join(',')
}
});
}
/** 交换排序 */
export function swapVariableSettingSort(nodeId1: string, nodeId2: string) {
return requestClient.post<boolean>('/core/quota/variable-setting/swap-sort', null, {
params: { nodeId1, nodeId2 }
});
}

View File

@@ -1,127 +1,137 @@
<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'
import { DbHst } from '#/components/db-hst';
import { ElButton, ElDialog, ElInput, ElSegmented } from 'element-plus';
import { computed, nextTick, ref, watch } from 'vue';
type TableType = '代号' | '类别'
interface TableColumn {
prop: string
label: string
}
interface TableItem {
type: TableType
data: any[]
columns: TableColumn[]
prop: 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 }>
tables?: TableItem[]
mode?: 'resource' | 'fee'
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', result: ConfirmResult): void
(e: 'confirm', result: any): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
currentValue: '',
tableData: () => [],
tableColumn: () => [],
tableProp: 'code', // 默认使用 code 字段
mode: 'resource' // 默认为工料机模式
tables: () => [],
mode: 'resource'
})
const emit = defineEmits<Emits>()
// Handsontable 相关
const tableContainer = ref<HTMLElement | null>(null)
let hotInstance: Handsontable | null = null
const hstRef = ref<InstanceType<typeof DbHst>>()
// 计算基数弹窗数据 - 使用 computed 从 props 获取
const calcTableData = computed(() => props.tableData || [])
// 运算符号按钮
const operators = ['+', '-', '*', '/', '(', ')']
// 当前编辑的值
const editValue = ref<string>('')
// 监听 props.currentValue 变化
const activeType = ref<TableType>('类别')
const typeOptions = computed(() => {
const types = props.tables.map(t => t.type)
return [...new Set(types)].map(t => ({ label: t, value: t }))
})
const currentTable = computed(() => props.tables.find(t => t.type === activeType.value))
watch(() => props.currentValue, (newVal) => {
editValue.value = newVal || ''
}, { immediate: true })
// 监听弹窗关闭,清空编辑值
watch(() => props.modelValue, (newVal) => {
console.log('DbCalc: modelValue changed =', newVal)
if (!newVal) {
editValue.value = ''
if (hotInstance) {
hotInstance.destroy()
hotInstance = null
}
} else {
editValue.value = props.currentValue || ''
// 弹窗打开时初始化表格
if (props.tables.length > 0) {
activeType.value = props.tables[0]?.type || '类别'
}
nextTick(() => {
initHandsontable()
updateTable()
})
}
})
// 初始化 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]
watch(activeType, () => {
nextTick(() => {
updateTable()
})
})
const updateTable = () => {
const table = currentTable.value
if (table && hstRef.value?.hotInstance) {
hstRef.value.hotInstance.updateSettings({
columns: createColumns(table.columns),
afterOnCellMouseDown: createCellClickHandler(table)
})
hstRef.value.hotInstance.loadData(table.data)
hstRef.value.hotInstance.render()
}
}
const createColumns = (columns: TableColumn[]) => columns.map(col => ({
data: col.prop,
title: col.label,
// readOnly: true,
className: 'htCenter'
}))
// 检查所有可能的价格代码字段
const priceCodeFields = ['taxExclBaseCode', 'taxInclBaseCode', 'taxExclCompileCode', 'taxInclCompileCode', 'code']
const createCellClickHandler = (table: TableItem) => {
return (_event: MouseEvent, coords: { row: number; col: number }) => {
if (coords.row >= 0 && coords.col >= 0) {
const rowData = hstRef.value?.hotInstance?.getSourceDataAtRow(coords.row) as any
const colProp = table.columns[coords.col]?.prop
//console.log('DbCalc: cell click:', { rowData, colProp })
if (colProp && priceCodeFields.includes(colProp)) {
const value = rowData?.[colProp]
if (value !== undefined && value !== null) {
const cellValue = Array.isArray(value) ? value.join('') : String(value)
editValue.value += cellValue
}
}
}
})
}
}
// 添加运算符
const settings = computed(() => {
const table = currentTable.value
if (!table) {
return {}
}
return {
data: table.data,
columns: createColumns(table.columns),
colHeaders: true,
rowHeaders: false,
editor: false,
afterOnCellMouseDown: createCellClickHandler(table)
}
})
const addOperator = (op: string) => {
editValue.value += op
}
@@ -135,17 +145,15 @@ const handleClose = () => {
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) {
const allTableData = props.tables.flatMap(t => t.data)
if (!formula || allTableData.length === 0) {
return variables
}
// 创建正则表达式,将运算符和数字替换为空格,用于分割
const operatorPattern = operators.map(op => {
// 转义特殊字符
return op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}).join('|')
// 移除运算符、数字、小数点和空格,剩下的就是变量代码
const operatorPattern = operators.map(op => op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
const cleanFormula = formula.replace(new RegExp(`(${operatorPattern}|\\d+\\.?\\d*|\\s+)`, 'g'), ' ')
// 分割出所有可能的变量代码
@@ -159,10 +167,9 @@ const extractVariables = (formula: string): Record<string, number | { categoryId
'taxInclCompileCode': 'tax_incl_compile_price'
}
// 遍历 tableData检查公式中是否包含该项的所有价格代码
props.tableData.forEach((item: any) => {
// 检查所有可能的价格代码字段
const priceCodeFields = ['taxExclBaseCode', 'taxInclBaseCode', 'taxExclCompileCode', 'taxInclCompileCode', 'code']
// 遍历合并后的 tableData检查公式中是否包含该项的所有价格代码
allTableData.forEach((item: any) => {
priceCodeFields.forEach(field => {
const codeValue = item[field]
@@ -188,20 +195,12 @@ const extractVariables = (formula: string): Record<string, number | { categoryId
return variables
}
// 保存计算数据
const handleConfirm = () => {
const variables = extractVariables(editValue.value)
const result: ConfirmResult = {
emit('confirm', {
formula: editValue.value,
variables: variables
}
// 只触发 confirm 事件,不自动关闭弹窗
// 让父组件在保存成功后再关闭弹窗
emit('confirm', result)
// 移除自动关闭逻辑
// emit('update:modelValue', false)
})
}
</script>
@@ -212,34 +211,51 @@ const handleConfirm = () => {
title="计算基数设置"
width="800px"
:close-on-click-modal="false"
body-class="calc-body-height"
>
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 10px; color: #909399; font-size: 13px;">
限数字:0-9及小数点运算符号:+-*/()英文括号代码
<div class="h-full w-full flex flex-col">
<div>
<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>
<span style="margin-right: 10px;">运算符号:</span>
<ElButton
v-for="op in operators"
:key="op"
@click="addOperator(op)"
circle
>
{{ op }}
</ElButton>
</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>
<div class="flex items-start">
<ElSegmented
v-if="typeOptions.length > 0"
class="hst-segmented"
v-model="activeType"
:options="typeOptions"
direction="vertical"
/>
<div class="flex-1 h-[400px] overflow-auto">
<DbHst
ref="hstRef"
:settings="settings"
/>
</div>
</div>
</div>
<template #footer>
<ElButton @click="handleClose">取消</ElButton>
@@ -248,5 +264,15 @@ const handleConfirm = () => {
</ElDialog>
</template>
<style scoped>
<style lang="scss">
.calc-body-height{
height: 500px;
}
.hst-segmented{
--el-border-radius-base: 0;
padding: 0;
}
.hst-segmented .el-segmented__item {
writing-mode: vertical-rl !important;
}
</style>

View File

@@ -1,75 +0,0 @@
# 右键菜单组件使用说明
## 功能特性
### 全局菜单管理
- 当打开一个右键菜单时,会自动关闭其他已打开的菜单
- 支持多个菜单组件实例共存,互不干扰
- 自动清理资源,防止内存泄漏
- **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

@@ -38,4 +38,16 @@ export const isVisualRowSelected = (visualRowIndex: number) => {
const range = selectedVisualRowRange.value
if (!range) return false
return visualRowIndex >= range.from && visualRowIndex <= range.to
}
}
export const formatNumeric = (value: any): string => {
if (value === null || value === undefined || value === '') return ''
const num = parseFloat(value)
if (isNaN(num)) return ''
return num.toFixed(2)
}
// const iconSvg = '<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>'
// // 替代方案(注释状态)
// const utf8Bytes = new TextEncoder().encode(iconSvg);
// const base64 = 'data:image/svg+xml;base64,' + btoa(String.fromCharCode(...utf8Bytes));
// console.log('base64', base64)

View File

@@ -0,0 +1,69 @@
import type { Ref } from 'vue';
/**
* 递归设置所有子节点的选中状态
*/
const setChildrenSelected = (node: any, selected: boolean): void => {
if (!node.__children?.length) return;
node.__children.forEach((child: any) => {
child.selected = selected;
setChildrenSelected(child, selected);
});
};
/**
* 递归检查并更新父节点的选中状态
* 只要有子节点选中,父节点就选中
*/
const updateParentSelected = (node: any, dataManager: any): void => {
const parent = dataManager.getRowParent(node);
if (!parent) return;
// 检查是否有子节点被选中
parent.selected = parent.__children?.some((child: any) => child.selected) ?? false;
// 递归向上更新父节点
updateParentSelected(parent, dataManager);
};
/**
* 处理 checkbox 变化事件
*/
export const hstCheckboxChange = (hstRef: Ref<any>) => {
return (changes: any[] | null, source: string): void => {
if (!changes || source === 'loadData') return;
const hotInstance = hstRef.value.hotInstance;
if (!hotInstance) return;
const nestedRowsPlugin = hotInstance.getPlugin('nestedRows');
if (!nestedRowsPlugin?.enabled){
console.error('需开启db.nestedRows')
return;
}
const dataManager = nestedRowsPlugin.dataManager;
for (const [row, prop, , newValue] of changes) {
if (prop !== 'selected') continue;
const dataObject = dataManager.getDataObject(row);
if (!dataObject) continue;
// 如果是父节点,递归设置所有子节点的选中状态
if (dataManager.isParent(row)) {
setChildrenSelected(dataObject, newValue);
}
// 向上更新父节点的选中状态
updateParentSelected(dataObject, dataManager);
}
hotInstance.render();
if (hotInstance.__onCheckboxChange) {
const data = hotInstance?.getSourceData() ?? []
const res = data.filter((f: any) => f.selected).map((item: any) => item.id)
hotInstance.__onCheckboxChange(res)
}
};
}

View File

@@ -0,0 +1,32 @@
import Handsontable from 'handsontable'
// Handsontable physical/visual row helper functions for hiddenColumns support
// Problem: Custom renderers cause dirty rendering after hiding columns due to DOM reuse
// Solution: Use physical row index for data lookup
export const getPhysicalRowIndex = (instance: Handsontable.Core, visualRow: number): number => {
const physicalRow = instance.toPhysicalRow(visualRow)
return physicalRow >= 0 ? physicalRow : visualRow
}
export const getRowData = (instance: Handsontable.Core, visualRow: number): any | null => {
const physicalRow = getPhysicalRowIndex(instance, visualRow)
return (instance.getSourceDataAtRow(physicalRow) as any | null) ?? null
}
export const getCellValueByProp = (
instance: Handsontable.Core,
visualRow: number,
visualColumn: number,
): { cellProp: string | number; cellValue: any; rowData: any | null } => {
const cellProp = instance.colToProp(visualColumn)
const rowData = getRowData(instance, visualRow)
if (typeof cellProp === 'string' && rowData) {
return { cellProp, cellValue: rowData[cellProp], rowData }
}
return {
cellProp,
cellValue: instance.getDataAtCell(visualRow, visualColumn),
rowData,
}
}

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { getFieldConfigList } from '#/api/database/interface/config';
import { DbHst } from '#/components/db-hst';
import { ElButton, ElDialog } from 'element-plus';
import { ref } from 'vue'
const props = defineProps<{
success?: (data: any[]) => void
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const modelValue = ref(false)
const hstRef = ref()
const savedHiddenFields = ref<string[]>([])
const hasConfirmed = ref(false)
const columns = [
{ type: 'text', data: 'fieldName', title: '字段名称', readOnly: true, width: 170 },
{ type: 'checkbox', data: 'hidden', title: '隐藏', className: 'htCenter' },
];
const settings = {
data: [],
columns,
// rowHeaders: true,
}
const show = async (fields: string[], catalogItemId: number | string) => {
const res = await getFieldConfigList(catalogItemId);
// Use saved hidden fields if confirmed before, otherwise use passed fields
const hiddenFields = hasConfirmed.value ? savedHiddenFields.value : fields;
const data = res.map((item: any) => ({
id: item.id,
fieldName: item.fieldName,
fieldCode: item.fieldCode,
hidden: hiddenFields.includes(item.fieldCode),
}));
// console.log('show res', data);
modelValue.value = true;
setTimeout(() => {
hstRef.value?.hotInstance?.loadData(data);
}, 200);
}
const close = () => {
modelValue.value = false
}
const handleConfirm = () => {
const hotInstance = hstRef.value?.hotInstance
const editedData = hotInstance?.getSourceData() ?? []
// collect hidden fieldCodes as string[]
const hiddenFieldCodes = editedData
.filter((item: any) => item.hidden)
.map((item: any) => item.fieldCode)
savedHiddenFields.value = hiddenFieldCodes
hasConfirmed.value = true
props.success?.(hiddenFieldCodes)
modelValue.value = false
}
defineExpose({ show, close })
</script>
<template>
<ElDialog :model-value="modelValue" @update:model-value="close" title="列头筛选"
width="400px" :close-on-click-modal="false">
<div class="h-[400px] w-full flex flex-col">
<DbHst ref="hstRef" :settings="settings" />
</div>
<template #footer>
<ElButton @click="close">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确定</ElButton>
</template>
</ElDialog>
</template>
<style lang="scss"></style>

View File

@@ -0,0 +1,440 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
const props = withDefaults(
defineProps<{
container?: HTMLElement | null
holderSelector?: string,
}>(),
{
container: null,
holderSelector: '.handsontable .wtHolder',
}
)
const BAR_PADDING = 2
const BAR_GAP = BAR_PADDING * 2
const BAR_SIZE = 0 // hot-scrollbar-corner 显示
const BAR_INSET = 2
const MIN_THUMB_SIZE = 28
type Axis = 'vertical' | 'horizontal'
const AXIS_MAP = {
vertical: {
clientKey: 'clientY',
scrollKey: 'scrollTop',
scrollSizeKey: 'scrollHeight',
clientSizeKey: 'clientHeight',
rectKey: 'top',
},
horizontal: {
clientKey: 'clientX',
scrollKey: 'scrollLeft',
scrollSizeKey: 'scrollWidth',
clientSizeKey: 'clientWidth',
rectKey: 'left',
},
} as const
const holderElement = ref<HTMLElement | null>(null)
const isVerticalScrollable = ref(false)
const isHorizontalScrollable = ref(false)
const verticalTrackRef = ref<HTMLDivElement | null>(null)
const horizontalTrackRef = ref<HTMLDivElement | null>(null)
const verticalThumbSize = ref(0)
const verticalThumbOffset = ref(0)
const horizontalThumbSize = ref(0)
const horizontalThumbOffset = ref(0)
const isDraggingVertical = ref(false)
const isDraggingHorizontal = ref(false)
let rafId = 0
const scheduleScrollbarUpdate = () => {
cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(() => {
updateScrollbars()
})
}
let holderScrollListener: (() => void) | null = null
let resizeObserver: ResizeObserver | null = null
let originalOnSelectStart: ((this: GlobalEventHandlers, ev: Event) => any) | null = null
const setContainerActive = (isActive: boolean) => {
const container = props.container
if (!container) return
if (isActive) container.classList.add('is-scrollbar-active')
else container.classList.remove('is-scrollbar-active')
}
const getTrackEl = (axis: Axis) => (axis === 'vertical' ? verticalTrackRef.value : horizontalTrackRef.value)
const getThumbSizeRef = (axis: Axis) => (axis === 'vertical' ? verticalThumbSize : horizontalThumbSize)
const getThumbOffsetRef = (axis: Axis) => (axis === 'vertical' ? verticalThumbOffset : horizontalThumbOffset)
const getDraggingRef = (axis: Axis) => (axis === 'vertical' ? isDraggingVertical : isDraggingHorizontal)
const setScrollable = (axis: Axis, next: boolean) => {
if (axis === 'vertical') isVerticalScrollable.value = next
else isHorizontalScrollable.value = next
}
const getScrollable = (axis: Axis) => (axis === 'vertical' ? isVerticalScrollable.value : isHorizontalScrollable.value)
const bindHolderElement = (holder: HTMLElement) => {
if (holderElement.value === holder) return
if (holderElement.value && holderScrollListener) holderElement.value.removeEventListener('scroll', holderScrollListener)
resizeObserver?.disconnect()
holderElement.value = holder
holderScrollListener = () => scheduleScrollbarUpdate()
holder.addEventListener('scroll', holderScrollListener, { passive: true })
resizeObserver = new ResizeObserver(() => scheduleScrollbarUpdate())
resizeObserver.observe(holder)
const container = props.container
if (container) resizeObserver.observe(container)
}
const ensureHolderBound = () => {
const container = props.container
if (!container) return
const holder = container.querySelector<HTMLElement>(props.holderSelector)
if (holder) bindHolderElement(holder)
}
const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const updateAxis = (axis: Axis, holder: HTMLElement) => {
const map = AXIS_MAP[axis]
const track = getTrackEl(axis)
if (!track) return
const scrollSize = (holder as any)[map.scrollSizeKey] as number
const clientSize = (holder as any)[map.clientSizeKey] as number
const nextScrollable = scrollSize > clientSize + 1
const prevScrollable = getScrollable(axis)
setScrollable(axis, nextScrollable)
const trackSize = (axis === 'vertical' ? track.clientHeight : track.clientWidth) - BAR_GAP
const thumbSizeRef = getThumbSizeRef(axis)
const thumbOffsetRef = getThumbOffsetRef(axis)
if (!nextScrollable || trackSize <= 0) {
thumbSizeRef.value = 0
thumbOffsetRef.value = 0
if (prevScrollable !== nextScrollable) scheduleScrollbarUpdate()
return
}
const nextThumbSize = Math.min(trackSize, Math.max((clientSize / scrollSize) * trackSize, MIN_THUMB_SIZE))
const maxScroll = scrollSize - clientSize
const maxOffset = Math.max(0, trackSize - nextThumbSize)
const scroll = (holder as any)[map.scrollKey] as number
const nextOffset = clampNumber(maxScroll > 0 ? (scroll / maxScroll) * maxOffset : 0, 0, maxOffset)
thumbSizeRef.value = nextThumbSize
thumbOffsetRef.value = nextOffset
if (prevScrollable !== nextScrollable) scheduleScrollbarUpdate()
}
const updateScrollbars = () => {
ensureHolderBound()
const holder = holderElement.value
if (!holder) return
updateAxis('vertical', holder)
updateAxis('horizontal', holder)
}
const scrollToOffset = (axis: Axis, nextOffset: number) => {
const holder = holderElement.value
const track = getTrackEl(axis)
if (!holder || !track) return
const map = AXIS_MAP[axis]
const scrollSize = (holder as any)[map.scrollSizeKey] as number
const clientSize = (holder as any)[map.clientSizeKey] as number
const trackSize = (axis === 'vertical' ? track.clientHeight : track.clientWidth) - BAR_GAP
const thumbSize = getThumbSizeRef(axis).value
const maxScroll = Math.max(0, scrollSize - clientSize)
const maxOffset = Math.max(0, trackSize - thumbSize)
const clampedOffset = clampNumber(nextOffset, 0, maxOffset)
const nextScroll = maxOffset > 0 ? (clampedOffset / maxOffset) * maxScroll : 0
;(holder as any)[map.scrollKey] = nextScroll
}
const handleTrackMouseDown = (axis: Axis, e: MouseEvent) => {
if (getDraggingRef(axis).value) return
if (!getScrollable(axis)) return
const track = getTrackEl(axis)
if (!track) return
const map = AXIS_MAP[axis]
const rect = track.getBoundingClientRect() as any
const clickClient = (e as any)[map.clientKey] as number
const clickOffset = clickClient - (rect[map.rectKey] as number) - BAR_PADDING
const thumbSize = getThumbSizeRef(axis).value
const nextOffset = clickOffset - thumbSize / 2
scrollToOffset(axis, nextOffset)
}
type DragState = {
axis: Axis
startClient: number
startScroll: number
trackSize: number
thumbSize: number
scrollSize: number
clientSize: number
}
const dragState = ref<DragState | null>(null)
const restoreSelectStart = () => {
if (originalOnSelectStart !== null && document.onselectstart !== originalOnSelectStart) document.onselectstart = originalOnSelectStart
originalOnSelectStart = null
}
const handleDragMouseMove = (e: MouseEvent) => {
const holder = holderElement.value
const state = dragState.value
if (!holder || !state) return
const map = AXIS_MAP[state.axis]
const currentClient = (e as any)[map.clientKey] as number
const delta = currentClient - state.startClient
const maxScroll = Math.max(0, state.scrollSize - state.clientSize)
const maxOffset = Math.max(0, state.trackSize - state.thumbSize)
if (maxOffset <= 0) return
const nextScroll = state.startScroll + (delta * maxScroll) / maxOffset
;(holder as any)[map.scrollKey] = clampNumber(nextScroll, 0, maxScroll)
}
const handleDragMouseUp = () => {
const state = dragState.value
if (state) {
getDraggingRef(state.axis).value = false
setContainerActive(false)
}
dragState.value = null
document.removeEventListener('mousemove', handleDragMouseMove)
document.removeEventListener('mouseup', handleDragMouseUp)
restoreSelectStart()
}
const startDrag = (axis: Axis, e: MouseEvent) => {
const holder = holderElement.value
const track = getTrackEl(axis)
if (!holder || !track) return
if (!getScrollable(axis)) return
const map = AXIS_MAP[axis]
const trackSize = (axis === 'vertical' ? track.clientHeight : track.clientWidth) - BAR_GAP
const scrollSize = (holder as any)[map.scrollSizeKey] as number
const clientSize = (holder as any)[map.clientSizeKey] as number
const startScroll = (holder as any)[map.scrollKey] as number
const startClient = (e as any)[map.clientKey] as number
const thumbSize = getThumbSizeRef(axis).value
dragState.value = { axis, startClient, startScroll, trackSize, thumbSize, scrollSize, clientSize }
getDraggingRef(axis).value = true
setContainerActive(true)
window.getSelection()?.removeAllRanges()
originalOnSelectStart = document.onselectstart
document.onselectstart = () => false
document.addEventListener('mousemove', handleDragMouseMove)
document.addEventListener('mouseup', handleDragMouseUp)
}
onMounted(() => {
// ensureHolderBound()
// scheduleScrollbarUpdate()
})
// watch(
// () => props.container,
// () => {
// ensureHolderBound()
// scheduleScrollbarUpdate()
// }
// )
onBeforeUnmount(() => {
cancelAnimationFrame(rafId)
handleDragMouseUp()
if (holderElement.value && holderScrollListener) holderElement.value.removeEventListener('scroll', holderScrollListener)
holderScrollListener = null
resizeObserver?.disconnect()
resizeObserver = null
restoreSelectStart()
setContainerActive(false)
})
defineExpose({scheduleScrollbarUpdate})
</script>
<template>
<div
class="hot-scrollbar hot-scrollbar--vertical"
:class="{ 'is-hidden': !isVerticalScrollable }"
:style="{ bottom: isHorizontalScrollable ? `${BAR_INSET + BAR_SIZE + BAR_GAP}px` : `${BAR_INSET}px` }"
>
<div
ref="verticalTrackRef"
class="hot-scrollbar__track"
@mousedown.prevent.stop="handleTrackMouseDown('vertical', $event)"
@click.stop
>
<div
class="hot-scrollbar__thumb"
:style="{ height: `${verticalThumbSize}px`, transform: `translate3d(0, ${verticalThumbOffset}px, 0)` }"
@mousedown.prevent.stop="startDrag('vertical', $event)"
/>
</div>
</div>
<div
class="hot-scrollbar hot-scrollbar--horizontal"
:class="{ 'is-hidden': !isHorizontalScrollable }"
:style="{ right: isVerticalScrollable ? `${BAR_INSET + BAR_SIZE + BAR_GAP}px` : `${BAR_INSET}px` }"
>
<div
ref="horizontalTrackRef"
class="hot-scrollbar__track"
@mousedown.prevent.stop="handleTrackMouseDown('horizontal', $event)"
@click.stop
>
<div
class="hot-scrollbar__thumb"
:style="{ width: `${horizontalThumbSize}px`, transform: `translate3d(${horizontalThumbOffset}px, 0, 0)` }"
@mousedown.prevent.stop="startDrag('horizontal', $event)"
/>
</div>
</div>
<!-- <div v-if="isVerticalScrollable && isHorizontalScrollable" class="hot-scrollbar-corner" /> -->
</template>
<style lang="scss">
.hot-scrollbar {
position: absolute;
z-index: 200;
opacity: 0;
transition: opacity 160ms ease, transform 160ms ease;
cursor: pointer;
}
.hot-table-wrapper:hover .hot-scrollbar:not(.is-hidden),
.hot-table-wrapper.is-scrollbar-active .hot-scrollbar:not(.is-hidden) {
opacity: 1;
}
.hot-scrollbar.is-hidden {
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.hot-scrollbar--vertical {
top: 2px;
right: 2px;
width: 7px;
}
.hot-scrollbar--horizontal {
left: 2px;
bottom: 2px;
height: 7px;
}
.hot-scrollbar__track {
position: relative;
width: 100%;
height: 100%;
padding: 2px;
border-radius: 999px;
background:
linear-gradient(rgba(233, 233, 233, 0.22), rgba(233, 233, 233, 0.1)),
rgba(0, 0, 0, 0.06);
// box-shadow:
// 0 0 0 1px rgba(0, 0, 0, 0.08),
// 0 6px 14px rgba(0, 0, 0, 0.12);
backdrop-filter: blur(10px);
}
.hot-scrollbar__thumb {
position: absolute;
top: 0;
left: 0;
background-color: #b4b7bd;
// background-color: var(--el-scrollbar-bg-color, var(--el-text-color-secondary));
border-radius: 999px;
// box-shadow:
// 0 1px 2px rgba(0, 0, 0, 0.2),
// inset 0 0 0 1px rgba(255, 255, 255, 0.32);
transition: transform 80ms ease, background-color 120ms ease;
}
.hot-scrollbar__thumb:hover {
background-color: rgb(168, 171, 178);
}
.hot-table-wrapper.is-scrollbar-active .hot-scrollbar__thumb {
background-color: rgb(168, 171, 178);
}
.hot-scrollbar--vertical .hot-scrollbar__thumb {
width: 100%;
}
.hot-scrollbar--horizontal .hot-scrollbar__thumb {
height: 100%;
}
.hot-scrollbar-corner {
position: absolute;
right: 2px;
bottom: 2px;
width: 5px;
height: 5px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.06);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
z-index: 39;
pointer-events: none;
}
.handsontable .wtHolder::-webkit-scrollbar {
display: none;
}
.handsontable .wtHolder {
scrollbar-width: none;
-ms-overflow-style: none;
}
/** 为了解决 滚动条bottom 10px 被遮挡问题 */
.hot-table-wrapper .ht_master.handsontable .wtHolder .htCore {
padding: 0px 20px 20px 0px;
}
/** 为了解决 有些表头没有被控制到 */
.hot-table-wrapper .ht_clone_top.handsontable .wtHolder .htCore {
padding: 0px 20px 0 0px;
}
</style>

View File

@@ -1,56 +0,0 @@
/**
* 全局右键菜单管理器
* 用于管理多个右键菜单实例,确保同一时间只有一个菜单处于打开状态
*/
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

@@ -1,228 +0,0 @@
<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

@@ -1,170 +1,148 @@
import { ref } from 'vue'
import { ref, type Ref, watch } from 'vue'
import Handsontable from 'handsontable'
import type { DropdownInstance } from 'element-plus'
import { getCellValueByProp } from './command'
let currentDropdownEl: HTMLElement | null = null
let currentOnDocClick: ((e: MouseEvent) => void) | null = null
const isDisabled = ref<any>(false)
const closeDropdown = () => {
if (currentDropdownEl && currentDropdownEl.parentNode) currentDropdownEl.parentNode.removeChild(currentDropdownEl)
currentDropdownEl = null
if (currentOnDocClick) document.removeEventListener('click', currentOnDocClick, true)
currentOnDocClick = null
type DropdownOption = {
value: string
label: string
icon?: string
disabled?: boolean
divided?: boolean
}
const openDropdown = (
td: HTMLTableCellElement,
value: unknown,
source: any[],
onSelect: (opt: string) => void,
isOptionDisabled?: (opt: string) => boolean,
onAfterSelect?: (oldValue: unknown, newValue: string, optData?: any) => void,
) => {
closeDropdown()
const menu = document.createElement('div')
menu.className = 'ht-dropdown-menu'
const frag = document.createDocumentFragment()
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')
item.className = 'ht-dropdown-item'
item.textContent = optLabel ?? ''
if (String(value) === String(optValue)) item.classList.add('is-selected')
const disabled = isDisabled.value && isOptionDisabled?.(String(optValue)) === true
if (disabled) { item.classList.add('is-disabled'); item.setAttribute('aria-disabled', 'true') }
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)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const instanceStateMap = new WeakMap<any, any>()
const { EventManager } = Handsontable;
const { addClass, hasClass } = Handsontable.dom;
Handsontable.renderers.registerRenderer("db-dropdown", (instance: any, TD: any, row: number, column: number, prop: string | number, value: any, cellProperties: any) => {
// console.log('value',value, row, column)
const { rootDocument } = instance;
const ARROW = rootDocument.createElement('DIV');
ARROW.className = 'htAutocompleteArrow';
ARROW.appendChild(rootDocument.createTextNode(String.fromCharCode(9660)));
// 先重绘 TD防止 DOM 复用时残留旧内容
Handsontable.renderers.TextRenderer.apply(this, [instance, TD, row, column, prop, value, cellProperties])
if (!TD.firstChild) { TD.appendChild(rootDocument.createTextNode(String.fromCharCode(160))); }
const disabledProp = (cellProperties as { disabled?: boolean | Function }).disabled
const disabled = typeof disabledProp === 'function'
? disabledProp(instance, row, prop, value, cellProperties.source)
: disabledProp
// console.log('TD.firstChild',TD.firstChild)
//TODO 优化到{key:'',value:''}
TD.firstChild.nodeValue = cellProperties.source.find((item: any) => item.value === value)?.label || ''
// TD.firstChild.nodeValue = value?.label || ''
if (disabled) return
TD.insertBefore(ARROW, TD.firstChild);
// addClass(TD, 'htAutocomplete');
const target = 'tdDropdown'
addClass(TD, [target, '!cursor-pointer']);
addClass(ARROW, [target, '!cursor-pointer']);
if (!instance[target + 'Listener']) {
const eventManager = new EventManager(instance);
// not very elegant but easy and fast
instance[target + 'Listener'] = function (event: MouseEvent) {
if (hasClass(event.target as HTMLElement, target)) {
const targetCell = (event.target as HTMLElement).closest('td');
const state = instanceStateMap.get(instance)
if (!state) return
state.rendererDropdownRef.value?.handleClose()
if (targetCell) {
// 使用公共 API 获取坐标visual index
const coords = instance.getCoords(targetCell);
const visualRow = coords.row;
const visualCol = coords.col;
const cellMeta = instance.getCellMeta(visualRow, visualCol);
if (cellMeta.readOnly != undefined && cellMeta.readOnly) return;
// 使用 getCellValueByProp 正确处理 hiddenColumns
const { cellProp, cellValue, rowData } = getCellValueByProp(instance, visualRow, visualCol)
let cellSource = (cellMeta as any).source || []
if (typeof cellMeta.onOptions === 'function') {
cellSource = cellMeta.onOptions(instance, coords.row, cellProp, cellValue, cellSource)
}
state.activeContext.value = { instance, TD, row: coords.row, column: coords.col, prop: cellProp, value: cellValue, cellProperties: cellMeta, rowData }
state.dropdownOptions.value = cellSource
setTimeout(() => {
const rect = targetCell.getBoundingClientRect()
state.popperWidth.value = Math.ceil(rect.width)
state.position.value = DOMRect.fromRect({
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
})
state.rendererDropdownRef.value?.handleOpen()
}, 200)
}
event.preventDefault();
event.stopPropagation(); //阻止冒泡
}
closeDropdown()
}
frag.appendChild(item)
}
menu.appendChild(frag)
const rect = td.getBoundingClientRect()
menu.style.visibility = 'hidden'
document.body.appendChild(menu)
const menuWidth = menu.offsetWidth
const menuHeight = menu.offsetHeight
const viewportRight = window.scrollX + window.innerWidth
const viewportBottom = window.scrollY + window.innerHeight
const left = Math.min(rect.left + window.scrollX, viewportRight - menuWidth - 8)
let top = rect.bottom + window.scrollY
let placement = 'bottom'
if (top + menuHeight > viewportBottom - 8) { top = rect.top + window.scrollY - menuHeight; placement = 'top' }
menu.style.left = `${Math.max(left, 8)}px`
menu.style.top = `${top}px`
menu.classList.add(placement === 'top' ? 'is-top' : 'is-bottom')
menu.style.visibility = ''
currentDropdownEl = menu
currentOnDocClick = (ev: MouseEvent) => { const target = ev.target as Node; if (currentDropdownEl && !currentDropdownEl.contains(target)) closeDropdown() }
document.addEventListener('click', currentOnDocClick, true)
}
};
//true 是 阻止冒泡
eventManager.addEventListener(instance.rootElement, 'mousedown', instance[target + 'Listener'], true);
export function handlerDropdownRenderer(instance: any, td: HTMLTableCellElement, row: number, column: number, prop: string | number, value: any, cellProperties: any) {
td.innerHTML = ''
// 检查是否需要设置验证背景色
const cellMeta = instance.getCellMeta(row, column)
const isValid = cellMeta?.valid !== false
// 如果单元格被标记为无效,设置红色背景
if (!isValid) {
td.style.backgroundColor = '#ffbeba' // 淡红色背景
} else {
td.style.backgroundColor = '' // 清除背景色
// We need to unbind the listener after the table has been destroyed
instance.addHookOnce('afterDestroy', () => {
eventManager.destroy();
instanceStateMap.delete(instance);
});
}
const wrapper = document.createElement('div')
wrapper.className = 'ht-cell-dropdown'
const valueEl = document.createElement('span')
valueEl.className = 'ht-cell-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
});
export function useDropdown(instanceRef: Ref<any>) {
const rendererDropdownRef = ref<DropdownInstance>()
const position = ref({
top: 0,
left: 0,
bottom: 0,
right: 0,
} as DOMRect)
const triggerRef = ref({
getBoundingClientRect: () => position.value,
})
const popperWidth = ref(100)
const activeContext = ref<any>()
const dropdownOptions = ref<DropdownOption[]>([])
watch(instanceRef, (instance) => {
if (instance) {
instanceStateMap.set(instance, {
rendererDropdownRef,
position,
popperWidth,
activeContext,
dropdownOptions,
})
}
}
valueEl.textContent = displayText
const caretEl = document.createElement('span')
caretEl.className = 'ht-cell-caret'
wrapper.appendChild(valueEl)
wrapper.appendChild(caretEl)
td.appendChild(wrapper)
let disabledSet = new Set<string>()
if (isDisabled.value) {
const colValues = instance.getSourceDataAtCol(column) as unknown[]
const currentStr = String(value ?? '')
disabledSet = new Set((Array.isArray(colValues) ? colValues : []).map(v => String(v)))
disabledSet.delete(currentStr)
}
//在 dropdown 的点击事件中阻止冒泡(推荐)
wrapper.onclick = (e) => {
e.stopPropagation()
e.preventDefault()
//TODO 暂时性,后面要删除
// 如果是只读状态,不打开下拉框
if (isReadOnly) {
return
}, { immediate: true })
const handleCommandDropdownRenderer = (key: string | number): void => {
const ctx = activeContext.value
if (!ctx?.instance) return
const selectedValue = String(key)
if (typeof ctx.cellProperties.onSelect === 'function') {
ctx.cellProperties.onSelect({ ...ctx, value: selectedValue })
}
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
)
ctx.instance.setDataAtRowProp(ctx.row, ctx.prop as string, selectedValue, 'category-dropdown')
rendererDropdownRef.value?.handleClose()
}
// 阻止 mousedown 事件冒泡,防止触发 beforeOnCellMouseDown
wrapper.onmousedown = (e) => {
e.stopPropagation()
return {
rendererDropdownRef,
triggerRef,
popperWidth,
activeContext,
dropdownOptions,
handleCommandDropdownRenderer,
}
return td
}

View File

@@ -1,33 +1,45 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch, computed } from 'vue'
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
import Handsontable from "handsontable";
import { HotTable } from '@handsontable/vue3'
import { registerLanguageDictionary, zhCN } from 'handsontable/i18n'
import { registerAllModules } from 'handsontable/registry'
import 'handsontable/styles/handsontable.css'
import { HotTable } from '@handsontable/vue3';
// import Handsontable from "handsontable";
import { useDebounceFn, useResizeObserver } from '@vueuse/core';
import type { DropdownInstance } from 'element-plus';
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus';
import { registerLanguageDictionary, zhCN } from 'handsontable/i18n';
import { registerAllModules } from 'handsontable/registry';
import 'handsontable/styles/handsontable.css';
import { computed, onMounted, ref, watch } from 'vue';
// import 'handsontable/styles/ht-theme-main.css'
import 'handsontable/styles/ht-theme-classic.css';
import HotTableScrollbars from './component/HotTableScrollbars.vue';
import { useDropdown } from './dropdown';
import { initAddChildAtIndex } from './nestedRows';
registerAllModules()
registerLanguageDictionary(zhCN)
import { handlerDropdownRenderer } from './dropdown'
import { handlerTableRenderer } from './table'
import { handlerDuplicateCodeRenderer } from './text'
import { computeCodeColWidth,codeRenderer } from './tree'
import ContextMenu from './contextmenu.vue'
import { handleRowOperation } from '#/components/db-hst/tree'
// import { sourceDataObject } from './mockData'
// const language = ref('zh-CN')
defineOptions({ name: 'DbHst' });
const componentProps = defineProps<{
const componentProps = defineProps<{
settings?: any
contextMenuItems?: Array<{
key: string
name: string
callback?: (hotInstance: any) => void
icon?: any
disabled?: boolean | (() => boolean)
divided?: boolean
callback?: (hotInstance: any) => void | Promise<void>
separator?: boolean
}>
}>()
const emit = defineEmits<{
(
e: 'root-menu-command',
command: string,
): void
}>()
// 导入和注册插件和单元格类型
// import { registerCellType, NumericCellType } from 'handsontable/cellTypes';
// import { registerPlugin, UndoRedo } from 'handsontable/plugins';
@@ -35,373 +47,382 @@ const componentProps = defineProps<{
// registerPlugin(UndoRedo);
// const tableHeight = computed(() => componentProps.height ?? 0)
// const defaultData = ref<any[][]>(Array.from({ length: 30 }, () => Array(componentProps.columns?.length ?? 0).fill('')))
const scrollbarContainer = ref<HTMLElement | null>(null)
const hotTableComponent = ref<any>(null)
const selectedRow = ref<number | null>(null) // 记录当前选中的行
const codeColWidth = ref<number>(120)
const hotInstance = ref<any>(null)
const {
rendererDropdownRef,
triggerRef: rendererTriggerRef,
popperWidth: popperWidthDropdownRenderer,
activeContext: activeContextDropdownRenderer,
dropdownOptions: dropdownOptionsDropdownRenderer,
handleCommandDropdownRenderer,
} = useDropdown(hotInstance)
// const colHeaders = ref<string[]>([])
let defaultSettings = {
const tableContainer = ref<any>(null)
const tableHeight = ref<number>(600)
const tableData = ref<any[]>([])
// ElDropdown 相关变量
const dropdownRef = ref<DropdownInstance>()
const position = ref({
top: 0,
left: 0,
bottom: 0,
right: 0,
} as DOMRect)
const triggerRef = ref({
getBoundingClientRect: () => position.value,
})
const defaultSettings = computed(() => ({
// themeName: 'ht-theme-main',
themeName: 'ht-theme-classic',
language: 'zh-CN',
// data: sourceDataObject,
// colWidths: [100, 120, 100, 100, 100, 100],
// rowHeights: [30, 30, 30, 30, 30, 30],
colWidths: 120, // 固定列宽
// colWidths(index) {
// return (index + 1) * 40;
// },
// colWidths: undefined,
rowHeights: 23, // 固定行高
wordWrap: false,// 禁止单元格内容自动换行
//manualColumnMove: true,
height: 600,//默认高度,为防止滚动条加载前后顺序变化导致的布局抖动
columns: [],
manualColumnMove: true,
manualColumnResize: true,
autoRowSize: false,
autoColumnSize: false,
fillHandle: false,//禁用 右下角拖拽
selectionMode: 'single', // 'single', 'range' or 'multiple',
renderAllRows: false,
viewportColumnRenderingOffset: 12,//渲染列数
viewportRowRenderingOffset: 12,//渲染行数
// colHeaders: componentProps.colHeaders ?? [],
rowHeaders: false,
// columns: componentProps.columns ?? [],
autoWrapRow: true,
autoWrapCol: true,
width: '100%',
// height: 'auto',
// height: tableHeight.value,
// height: 200,
// stretchH: 'all',
// loading: true,
//contextMenu: true,
// dialog: true,
// dialog: {
// content: 'This dialog can be controlled programmatically.',
// closable: true,
// contentBackground: true,
// background: 'semi-transparent',
// },
licenseKey: '424fc-f3b67-5905b-a191b-9b809',
// 如果使用第一行作为列头colHeaders: false添加以下配置
// cells: function(row: number, col: number) {
// const cellProperties: any = {};
// // 如果 colHeaders 为 false将第一行设置为列头样式
// if (row === 0) {
// cellProperties.readOnly = true; // 不可编辑
// cellProperties.className = 'custom-header-row'; // 自定义样式类
// cellProperties.renderer = function(instance: any, td: HTMLTableCellElement, row: number, col: number, prop: any, value: any, cellProperties: any) {
// Handsontable.renderers.TextRenderer.apply(this, arguments as any);
// td.style.fontWeight = 'bold';
// td.style.backgroundColor = '#f5f5f5';
// td.style.textAlign = 'center';
// td.style.borderBottom = '2px solid #ddd';
// };
// }
// return cellProperties;
// },
// afterSelection(row1: number, _col1: number, _row2: number, _col2: number) {
// const hot = this as any
// if (selectedRow.value !== null && selectedRow.value !== row1) {
// 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 = row1
// const colCount = hot.countCols()
// for (let c = 0; c < colCount; c++) {
// const meta = hot.getCellMeta(row1, c)
// 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?.()
dropdownRef.value?.handleClose()
},
}
// beforeKeyDown(event: KeyboardEvent) {
// const hot = this as any
// // 当按下 Backspace 或 Delete 键时,如果单元格处于选中状态(非编辑状态),
// // 则进入编辑模式而不是清空单元格内容
// if (event.key === 'Backspace' || event.key === 'Delete') {
// const activeEditor = hot.getActiveEditor()
// // 如果编辑器未打开(单元格处于选中状态而非编辑状态)
// if (activeEditor && !activeEditor.isOpened()) {
// const selected = hot.getSelected()
// if (selected && selected.length > 0) {
// const [row, col] = selected[0]
// const cellMeta = hot.getCellMeta(row, col)
// // 只对非只读单元格生效
// if (!cellMeta.readOnly) {
// // 阻止默认的清空行为
// event.stopImmediatePropagation()
// event.preventDefault()
// // 打开编辑器进入编辑模式,并将光标放在末尾
// activeEditor.beginEditing()
// // 延迟执行,确保编辑器已打开
// setTimeout(() => {
// const textareaElement = activeEditor.TEXTAREA
// if (textareaElement) {
// // 将光标移到末尾,不全选
// const len = textareaElement.value.length
// textareaElement.setSelectionRange(len, len)
// }
// }, 0)
// }
// }
// }
// }
// }
}))
// 合并外部 settings 和默认配置
let hotSettings = {}
// 保留必要的回调函数
// let hotSettings = {}
// 保留必要的回调函数
const hotInstance = ref<any>(null)
const contextMenuRef = ref<any>(null)
const isContextMenu = ref(true)
// 处理右键菜单事件
const handleContextMenu = (event: MouseEvent) => {
contextMenuRef.value?.handleContextMenu(event)
const startContextMenu = () => {
isContextMenu.value = true
}
const stopContextMenu = () => {
isContextMenu.value = false
}
// ElDropdown 相关函数
const handleClick = () => {
dropdownRef.value?.handleClose()
}
onMounted(() => {
const handleRootContextmenu = (event: MouseEvent) => {
if (!isContextMenu.value) return
// 如果没有自定义菜单项,不拦截右键事件,让 Handsontable 原生 contextMenu 生效
if (!componentProps.contextMenuItems || componentProps.contextMenuItems.length === 0) return
const { clientX, clientY } = event
position.value = DOMRect.fromRect({
x: clientX,
y: clientY,
})
event.preventDefault()
dropdownRef.value?.handleOpen()
}
const handleRootMenuCommand = (command: string | number) => {
componentProps.contextMenuItems
?.find((item) => item.key === command)
?.callback?.(hotInstance.value)
emit('root-menu-command', String(command))
dropdownRef.value?.handleClose()
}
// 处理鼠标按下事件,确保点击空白区域时触发输入框的 blur
const handleMouseDown = (event: MouseEvent) => {
const target = event.target as HTMLElement
// 如果点击的不是 input 元素,手动触发当前聚焦元素的 blur
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
const activeElement = document.activeElement as HTMLElement
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
activeElement.blur()
}
}
}
const handleMouseEnter = useDebounceFn(() => {
if (scrollbarContainer?.value) (scrollbarContainer.value as any).scheduleScrollbarUpdate?.()
}, 100)
const updateTableHeight = useDebounceFn(() => {
if (!tableContainer.value) return
const newHeight = tableContainer.value.clientHeight
// console.log('updateTableHeight',newHeight, tableHeight.value)
if (newHeight > 0 && newHeight !== tableHeight.value) {
const enabled = hotInstance.value?.getPlugin('nestedRows').enabled;
// console.log('nestedRowsLoadData', enabled)
let trimmedIndexes = []
if(enabled){
const map = hotInstance.value?.getPlugin('nestedRows')?.collapsedRowsMap
if(map){
trimmedIndexes = map.getTrimmedIndexes()
}
hotInstance.value?.getPlugin('nestedRows').disablePlugin();//先关闭
hotInstance.value.loadData([]);
}
tableHeight.value = newHeight
hotInstance.value?.updateSettings({ height: newHeight })
// 通知 Handsontable 更新尺寸
hotInstance.value?.refreshDimensions()
if(enabled){
hotInstance.value?.getPlugin('nestedRows').enablePlugin();//再打开
if(tableData.value.length > 0) hotInstance.value.loadData(tableData.value);
if(trimmedIndexes.length > 0){
// console.log('nestedRowsLoadData', trimmedIndexes)
hotInstance.value?.getPlugin('nestedRows')?.collapsingUI.collapseRows(trimmedIndexes, true, true)
}
hotInstance.value?.render()
}
}
}, 100)
useResizeObserver(tableContainer, updateTableHeight);
onMounted(async () => {
hotInstance.value = hotTableComponent.value?.hotInstance
})
onUnmounted(() => {
})
watch(
() => componentProps.settings,
(newSettings) => {
if (!newSettings) return
const merged = {
...defaultSettings,
...newSettings,
}
Object.assign(hotSettings, merged)
hotSettings = merged
// console.log(merged)
Object.assign(defaultSettings.value, newSettings)
// console.log(defaultSettings.value)
},
{ immediate: true,deep:true }
{ immediate: true, deep: true }
)
const loadData = (rows: any[][]) => {
const nestedRowsLoadData = (rows: any[][]) => {
if (!hotInstance.value) return
// hotInstance.value.loadData(rows.length === 0?defaultData.value:rows)
hotInstance.value.loadData(rows)
//console.log('Source Data:', hotInstance.value.getSourceData());
}
const updateCodeColWidth = () => {
if (!hotInstance.value) return
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('nestedRowsLoadData',rows)
// nestedRows 解决数据 空数组
if(rows.length == 0){
if(hotInstance.value?.getPlugin('nestedRows').enabled){
hotInstance.value?.getPlugin('nestedRows').disablePlugin();
}
}else{
if(!hotInstance.value?.getPlugin('nestedRows').enabled){
if (hotInstance.value?.getSettings().columns.find((item: any) => item.type === 'db.nestedRows')){
hotInstance.value?.getPlugin('nestedRows').enablePlugin();
initAddChildAtIndex(hotInstance.value?.getPlugin('nestedRows')?.dataManager);
}
}
console.log(newColWidths)
// 更新列宽
hotInstance.value.updateSettings({
colWidths: newColWidths
})
}
hotInstance.value.render()
tableData.value = rows
// console.log('nestedRowsLoadData', hotInstance.value?.getPlugin('nestedRows').enabled, rows)
hotInstance.value.loadData(rows);
}
defineExpose({ loadData, hotTableComponent, hotInstance, updateCodeColWidth, codeColWidth })
const collapseAll = () => {
if (hotInstance.value?.getPlugin('nestedRows').enabled) {
hotInstance.value?.getPlugin('nestedRows').collapsingUI.collapseAll();
}
}
const expandAll = () => {
if (hotInstance.value?.getPlugin('nestedRows').enabled) {
hotInstance.value?.getPlugin('nestedRows').collapsingUI.expandAll();
}
}
const addChild = () => {
return new Promise((resolve) => {
if (!hotInstance.value?.getPlugin('nestedRows').enabled) {
resolve(null);
return;
}
const plugin = hotInstance.value.getPlugin('nestedRows');
const parent = plugin.dataManager.getDataObject(hotInstance.value.getSelectedActive()[0]);
// console.log('parent',plugin, parent)
if (!parent) {
resolve(null);
return;
}
// addHookOnce 确保回调只执行一次
hotInstance.value.addHookOnce('afterAddChild', (_parent: any, childElement: any) => {
const newRowIndex = plugin.dataManager.getRowIndex(childElement);
resolve(newRowIndex);
});
// batch() 合并 addChild 内部的多次 render 为一次
hotInstance.value.batch(() => {
plugin.dataManager.addChild(parent);
});
});
}
defineExpose({
nestedRowsLoadData, hotTableComponent, hotInstance,
stopContextMenu, startContextMenu, collapseAll, expandAll, addChild,
})
Handsontable.renderers.registerRenderer("db-table", handlerTableRenderer);
Handsontable.renderers.registerRenderer("db-dropdown", handlerDropdownRenderer);
Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRenderer);
</script>
<template>
<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 class="ht-dialog-content">
<h3>执行操作</h3>
<el-table :data="gridData">
<el-table-column property="date" label="Date" width="150" />
<el-table-column property="name" label="Name" width="200" />
<el-table-column property="address" label="Address" />
</el-table>
<div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end">
<button class="el-button el-button--default el-button--small btn-cancel">取消</button>
<button class="el-button el-button--primary el-button--small btn-ok">确定</button>
</div>
</div>
</div>
</div> -->
<!-- <el-popover
ref="popoverRef"
:virtual-ref="popoverButtonRef"
v-model:visible="isPopoverOpen"
trigger="manual"
virtual-triggering
width="auto"
>
<el-table :data="gridData" size="small" @row-click="onClickOutside" border>
<el-table-column width="150" property="date" label="date" />
<el-table-column width="100" property="name" label="name" />
<el-table-column width="300" property="address" label="address" />
</el-table>
</el-popover> -->
</template>
<div ref="tableContainer" class="hot-table-wrapper" @mouseenter="handleMouseEnter"
@contextmenu="handleRootContextmenu" @mousedown="handleMouseDown" @click="handleClick">
<hot-table ref="hotTableComponent" :settings="defaultSettings"></hot-table>
<HotTableScrollbars ref="scrollbarContainer" :container="tableContainer" />
<ElDropdown ref="dropdownRef" :virtual-ref="triggerRef" :show-arrow="false" :popper-options="{
modifiers: [{ name: 'offset', options: { offset: [0, 0] } }],
}" virtual-triggering trigger="contextmenu" placement="bottom-start" @command="handleRootMenuCommand">
<template #dropdown>
<ElDropdownMenu v-if="componentProps.contextMenuItems?.length">
<ElDropdownItem v-for="item in componentProps.contextMenuItems" :key="item.key" :icon="item.icon"
:disabled="typeof item.disabled === 'function' ? item.disabled() : item.disabled" :divided="item.divided"
:command="item.key">
{{ item.name }}
</ElDropdownItem>
</ElDropdownMenu>
<slot v-else name="dropdown-menu" />
</template>
</ElDropdown>
<ElDropdown
ref="rendererDropdownRef"
:virtual-ref="rendererTriggerRef"
:show-arrow="false"
:popper-options="{
modifiers: [{ name: 'offset', options: { offset: [0, 0] } }],
}"
:popper-style="{ width: `${popperWidthDropdownRenderer}px` }"
virtual-triggering
trigger="contextmenu"
placement="bottom-end"
size="small"
@command="handleCommandDropdownRenderer"
:max-height="200"
>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem class="hst-dropdown-menu"
v-for="item in dropdownOptionsDropdownRenderer"
:key="item.value"
:class="{
'hot-category-dropdown__item--active': item.value === activeContextDropdownRenderer?.value
}"
:icon="item.icon"
:disabled="item.disabled"
:divided="item.divided"
:command="item.value"
>
{{ item.label }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
<style lang="css" scoped>
/** 类型 行颜色 */
.hot-table-wrapper :deep(td.node-type-root) {
background-color: #F2F2F2;
}
.hot-table-wrapper :deep(td.node-type-division) {
background-color: #FAF5E2;
}
.hot-table-wrapper :deep(td.node-type-boq) {
background-color: #D3E2F3;
}
.hot-table-wrapper :deep(td.node-type-quota) {
background-color: #ffffff;
}
.hot-table-wrapper :deep(td.node-type-work_content) {
background-color: #E7EAEE;
}
.hot-table-wrapper :deep(td.node-type-sync_source) {
background-color: #F0FFF4;
}
</style>
<style lang="css">
/* 禁止单元格内容换行 */
.hot-table-wrapper {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* 禁止单元格内容换行 */
.handsontable td {
white-space: nowrap !important;
overflow: hidden;
text-overflow: ellipsis;
}
/* 自定义滚动条样式 */
/* .ht_master .wtHolder::-webkit-scrollbar {
width: 10px;
background-color: #f1f1f1;
}
.ht_master .wtHolder::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 6px;
border: 3px solid #ffffff;
}
.ht_master .wtHolder::-webkit-scrollbar-thumb:hover {
background-color: #555;
} */
/* 滚动条width */
.ht_master .wtHolder{
/* overflow: hidden !important; */
scrollbar-width: thin;
scrollbar-color: #a6a8ac #ecf0f1;
}
/* dropdown */
.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: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-dropdown-menu { position: absolute; background: #fff; border: 1px solid var(--el-border-color-light); border-radius: 8px; box-shadow: 0 10px 20px rgba(0,0,0,0.1); min-width: 160px; max-height:300px; overflow: auto; z-index: 10000; }
.ht-dropdown-menu.is-bottom { margin-top: 4px; }
.ht-dropdown-menu.is-top { margin-bottom: 4px; }
.ht-dropdown-item { padding: 8px 12px; cursor: pointer; font-size: 13px; }
.ht-dropdown-item:hover { background-color: #f5f7fa; }
.ht-dropdown-item.is-selected { background-color: #eef3ff; }
.ht-dropdown-item.is-disabled { color: #c0c4cc; cursor: not-allowed; }
/* tree */
.handsontable .text-relative span.ht_nestingLevel_empty{
.handsontable .wtHolder {
/**为了解决 滚动条表头 */
/* padding-right: 10px !important; */
position: relative;
display: inline-block;
width: 5px;
height: 1px;
order: -2;
}
/* tree */
.handsontable .text-relative span.ht_nestingLevel_empty {
position: relative;
display: inline-block;
width: 5px;
height: 1px;
order: -2;
}
.handsontable .text-relative span:last-child {
padding-left: calc(var(--ht-icon-size) + 5px);
padding-left: calc(var(--ht-icon-size) + 5px);
}
/* table */
/* 自定义下拉渲染样式 */
.hot-cell-dropdown { display: flex; align-items: center; gap: 6px; padding: 0 6px; }
.hot-dropdown-display { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hot-dropdown-text { font-size: 12px; color: #1f2328; }
.hot-dropdown-placeholder { font-size: 12px; color: #a0a0a0; }
.hot-dropdown-trigger {
margin-left: auto;
border: none;
background: transparent;
position: relative;
cursor: pointer;
width: 16px;
height: 16px;
font-size: 0;
}
.hot-dropdown-trigger::after {
width: 16px;
height: 16px;
-webkit-mask-size: contain;
-webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg 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'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cpath d='m21 21-4.35-4.35'%3E%3C/path%3E%3C/svg%3E");
background-color: currentColor;
}
.hot-dropdown-trigger::after {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
opacity: 0.6;
}
.hot-dropdown { position: fixed; z-index: 10000; background: #fff; border: 1px solid #e5e7eb; box-shadow: 0 8px 24px rgba(0,0,0,0.12); border-radius: 6px; max-height: 260px; overflow: hidden; will-change: top, left; display: flex; flex-direction: column; }
.hot-dropdown--up { border-top-left-radius: 6px; border-top-right-radius: 6px; }
.hot-dropdown--down { border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; }
.hot-dropdown-search { padding: 0px; border-bottom: 1px solid #e5e7eb; background: #fff; position: sticky; top: 0; z-index: 1; }
.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-table-wrapper { overflow: auto; flex: 1; }
.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: center; }
.hot-dropdown-table tbody td { padding: 8px; border-bottom: 1px solid #f3f4f6; color: #374151; }
.hot-dropdown-row { cursor: pointer; }
.hot-dropdown-row:hover { background: #f3f4f6; }
/* 整行高亮样式 */
.row-highlight {
background-color: #e9ecfc !important; /* 浅蓝色背景 */
background-color: #e9ecfc !important;
/* 浅蓝色背景 */
}
/* 确保 Handsontable 右键菜单在 ElDialog 之上 - 必须是全局样式 */
@@ -410,10 +431,12 @@ Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRend
.handsontable.htFiltersConditionsMenu:not(.htGhostTable) {
z-index: 9999 !important;
}
.hot-category-dropdown__item--active {
background-color: #ecf5ff;
}
.ht-id-cell {
position: relative !important;
z-index: 3 !important;
/* z-index: 3 !important; */
overflow: visible !important;
}
@@ -425,7 +448,7 @@ Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRend
height: 14px;
display: none;
cursor: pointer;
z-index: 4;
/* z-index: 4; */
}
.ht-id-cell.current .ht-id-icon,
@@ -439,6 +462,7 @@ Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRend
--ht-tree-line-width: 1px;
--ht-tree-indent: 14px;
}
/** 新树形连接线 */
.handsontable .ht_treeCell {
display: flex;
@@ -473,5 +497,164 @@ Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRend
.handsontable td.ht_rowHighlight:not(.current):not(.area) {
background-color: #e9ecfc;
}
/* nestedRows 样式 */
.handsontable td.ht_nestingLevels {
padding: 0;
}
.handsontable td.ht_nestingLevels > .relative {
display: flex;
align-items: center;
gap: 5px;
height: 100%;
box-sizing: border-box;
padding: var(--ht-cell-vertical-padding) var(--ht-cell-horizontal-padding);
}
/* 树形连线基础样式 */
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty {
position: relative;
display: inline-block;
width: 12px;
height: 100%;
flex-shrink: 0;
}
/* 垂直延续线 - 祖先节点还有后续兄弟节点 */
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_line::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--ht-border-color);
transform: translateX(-50%);
}
/* 水平连接线 - 当前节点的层级 */
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_connector::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 10px;
height: 1px;
background-color: var(--ht-border-color);
transform: translateY(-50%);
}
/* 拐角线 - 最后一个子节点,同时有垂直和水平线 */
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_corner::before {
content: '';
position: absolute;
left: 50%;
top: 0;
width: 1px;
height: 50%;
background-color: var(--ht-border-color);
transform: translateX(-50%);
}
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_corner::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 10px;
height: 1px;
background-color: var(--ht-border-color);
transform: translateY(-50%);
}
/* 中间子节点 - 有完整垂直线和水平连接 */
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_middle::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--ht-border-color);
transform: translateX(-50%);
}
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_middle::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 10px;
height: 1px;
background-color: var(--ht-border-color);
transform: translateY(-50%);
}
.handsontable td.ht_nestingLevels .hot-nesting-button--spacer {
visibility: hidden;
pointer-events: none;
}
/* codeRenderer 高亮样式:只在文字区域显示背景色 */
.handsontable td.ht_codeHighlight:not(.row-highlight):not(.current):not(.area) {
background-image: linear-gradient(
to right,
transparent var(--ht-highlight-offset, 0px),
var(--ht-highlight-color, #ffeb3b) var(--ht-highlight-offset, 0px)
);
background-repeat: no-repeat;
}
/** 计算基数图标 */
.handsontable .htCalculate {
position: relative;
cursor: default;
width: 16px;
height: 16px;
font-size: 0;
float: right;
top: calc((var(--ht-line-height) - var(--ht-icon-size)) / 3);
margin-left: calc(var(--ht-gap-size)* 2);
margin-right: 1px;
}
.handsontable .htCalculate::after {
width: 16px;
height: 16px;
-webkit-mask-size: contain;
-webkit-mask-image: url('data:image/svg+xml;base64,PHN2ZyB0PSIxNzY2MjAwNTkzNTAwIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjgxMzAiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTI1MS4yIDM4N0gzMjB2NjguOGMwIDEuOCAxLjggMy4yIDQgMy4yaDQ4YzIuMiAwIDQtMS40IDQtMy4zVjM4N2g2OC44YzEuOCAwIDMuMi0xLjggMy4yLTR2LTQ4YzAtMi4yLTEuNC00LTMuMy00SDM3NnYtNjguOGMwLTEuOC0xLjgtMy4yLTQtMy4yaC00OGMtMi4yIDAtNCAxLjQtNCAzLjJWMzMxaC02OC44Yy0xLjggMC0zLjIgMS44LTMuMiA0djQ4YzAgMi4yIDEuNCA0IDMuMiA0eiBtMzI4IDBoMTkzLjZjMS44IDAgMy4yLTEuOCAzLjItNHYtNDhjMC0yLjItMS40LTQtMy4zLTRINTc5LjJjLTEuOCAwLTMuMiAxLjgtMy4yIDR2NDhjMCAyLjIgMS40IDQgMy4yIDR6IG0wIDI2NWgxOTMuNmMxLjggMCAzLjItMS44IDMuMi00di00OGMwLTIuMi0xLjQtNC0zLjMtNEg1NzkuMmMtMS44IDAtMy4yIDEuOC0zLjIgNHY0OGMwIDIuMiAxLjQgNCAzLjIgNHogbTAgMTA0aDE5My42YzEuOCAwIDMuMi0xLjggMy4yLTR2LTQ4YzAtMi4yLTEuNC00LTMuMy00SDU3OS4yYy0xLjggMC0zLjIgMS44LTMuMiA0djQ4YzAgMi4yIDEuNCA0IDMuMiA0eiBtLTE5NS43LTgxbDYxLjItNzQuOWM0LjMtNS4yIDAuNy0xMy4xLTUuOS0xMy4xSDM4OGMtMi4zIDAtNC41IDEtNS45IDIuOWwtMzQgNDEuNi0zNC00MS42Yy0xLjUtMS44LTMuNy0yLjktNS45LTIuOWgtNTAuOWMtNi42IDAtMTAuMiA3LjktNS45IDEzLjFsNjEuMiA3NC45LTYyLjcgNzYuOGMtNC40IDUuMi0wLjggMTMuMSA1LjggMTMuMWg1MC44YzIuMyAwIDQuNS0xIDUuOS0yLjlsMzUuNS00My41IDM1LjUgNDMuNWMxLjUgMS44IDMuNyAyLjkgNS45IDIuOWg1MC44YzYuNiAwIDEwLjItNy45IDUuOS0xMy4xTDM4My41IDY3NXoiIHAtaWQ9IjgxMzEiPjwvcGF0aD48cGF0aCBkPSJNODgwIDExMkgxNDRjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjczNmMwIDE3LjcgMTQuMyAzMiAzMiAzMmg3MzZjMTcuNyAwIDMyLTE0LjMgMzItMzJWMTQ0YzAtMTcuNy0xNC4zLTMyLTMyLTMyeiBtLTM2IDczMkgxODBWMTgwaDY2NHY2NjR6IiBwLWlkPSI4MTMyIj48L3BhdGg+PC9zdmc+');
background-color: black;
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
opacity: 0.6;
}
/** 三条线图标 */
.handsontable .htMenuLine {
position: relative;
cursor: default;
width: 16px;
height: 16px;
font-size: 0;
float: right;
top: calc((var(--ht-line-height) - var(--ht-icon-size)) / 3);
margin-left: calc(var(--ht-gap-size)* 2);
margin-right: 1px;
}
.handsontable .htMenuLine::after {
width: 16px;
height: 16px;
-webkit-mask-size: contain;
-webkit-mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNCIgaGVpZ2h0PSIxNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxsaW5lIHgxPSI0IiB5MT0iNiIgeDI9IjIwIiB5Mj0iNiI+PC9saW5lPjxsaW5lIHgxPSI0IiB5MT0iMTIiIHgyPSIyMCIgeTI9IjEyIj48L2xpbmU+PGxpbmUgeDE9IjQiIHkxPSIxOCIgeDI9IjIwIiB5Mj0iMTgiPjwvbGluZT48L3N2Zz4=');
background-color: black;
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
opacity: 0.6;
}
</style>

View File

@@ -1,95 +0,0 @@
interface Album {
code?: string;
title: string;
artist: string;
label: string;
level?: string;
__children?: Album[];
}
interface MusicAward {
code?: string;
category: string;
artist?: string | null;
title?: string | null;
label?: string | null;
level?: string;
__children: Album[];
}
// 音乐类型
const categories = [
'Best Rock Performance',
'Best Metal Performance',
'Best Pop Performance',
'Best Jazz Performance',
'Best Classical Performance',
'Best Electronic Performance',
'Best Hip Hop Performance',
'Best Country Performance',
'Best R&B Performance',
'Best Alternative Performance'
]
// 艺术家名字
const artists = [
'Alabama Shakes', 'Florence & The Machine', 'Foo Fighters', 'Elle King', 'Wolf Alice',
'Ghost', 'August Burns Red', 'Lamb Of God', 'Sevendust', 'Slipknot',
'Muse', 'James Bay', 'Death Cab For Cutie', 'Highly Suspect', 'Arctic Monkeys',
'The Killers', 'Imagine Dragons', 'Twenty One Pilots', 'Panic! At The Disco', 'Fall Out Boy',
'Paramore', 'Green Day', 'Blink-182', 'My Chemical Romance', 'Linkin Park',
'Coldplay', 'Radiohead', 'The Strokes', 'Kings of Leon', 'The Black Keys'
]
// 歌曲标题
const titles = [
"Don't Wanna Fight", 'What Kind Of Man', 'Something From Nothing', "Ex's & Oh's", 'Moaning Lisa Smile',
'Cirice', 'Identity', '512', 'Thank You', 'Custer',
'Drones', 'Chaos And The Calm', 'Kintsugi', 'Mister Asylum', 'The Gray Chapter',
'Believer', 'Thunder', 'Radioactive', 'Demons', 'Warriors',
'High Hopes', 'Hey Look Ma I Made It', 'Victorious', 'King of the Clouds', 'Roaring 20s',
'Misery Business', 'Still Into You', 'Ain\'t It Fun', 'Hard Times', 'Rose-Colored Boy'
]
// 唱片公司
const labels = [
'ATO Records', 'Republic', 'RCA Records', 'Warner Bros. Records', 'Atlantic',
'Loma Vista Recordings', 'Fearless Records', 'Epic Records', '7Bros Records', 'Roadrunner Records',
'300 Entertainment', 'Columbia Records', 'Interscope Records', 'Capitol Records', 'Universal Music',
'Sony Music', 'EMI Records', 'Def Jam', 'Island Records', 'Elektra Records'
]
// 生成随机数据
const generateMockData = (count: number): MusicAward[] => {
const data: MusicAward[] = []
for (let i = 0; i < count; i++) {
const categoryIndex = i % categories.length
const childrenCount = Math.floor(Math.random() * 8) + 3 // 3-10个子项
const children: Album[] = []
for (let j = 0; j < childrenCount; j++) {
children.push({
code: `${String.fromCharCode(65 + categoryIndex)}${String(i + 1).padStart(3, '0')}-${j + 1}`,
title: titles[Math.floor(Math.random() * titles.length)],
artist: artists[Math.floor(Math.random() * artists.length)],
label: labels[Math.floor(Math.random() * labels.length)],
level: `${i}-${j + 1}`,
})
}
data.push({
code: `${String.fromCharCode(65 + categoryIndex)}${String(i + 1).padStart(3, '0')}`,
category: categories[categoryIndex],
artist: null,
title: null,
label: null,
level: String(i),
__children: children,
})
}
return data
}
export function sourceData(row: number){return generateMockData(row ?? 10)}
export const sourceDataObject = generateMockData(10)

View File

@@ -1,490 +1,269 @@
import { useDebounceFn } from '@vueuse/core'
import { ref } from 'vue'
export type TreeNode = Record<string, unknown> & {
__id?: string
__children?: TreeNode[]
level?: string | null
// console.log('NestedRows')
import Handsontable from 'handsontable'
import { NestedRows } from 'handsontable/plugins'
// 编辑后刷新列宽
const refreshColumnSize = (instance: Handsontable.Core, col: number) => {
const manualColumnResize = instance.getPlugin?.('manualColumnResize')
if (!manualColumnResize?.setManualSize) return
const baseWidth = instance.getColWidth?.(col) ?? 0
const hookedWidth = instance.runHooks?.('beforeColumnResize', baseWidth, col, true)
const newWidth = typeof hookedWidth === 'number' ? hookedWidth : baseWidth
manualColumnResize.setManualSize(col, newWidth)
manualColumnResize.saveManualColumnWidths?.()
instance.runHooks?.('afterColumnResize', newWidth, col, true)
instance.view?.adjustElementsSize?.()
instance.render?.()
}
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
class CodeEditor extends Handsontable.editors.TextEditor {
override close(): void {
super.close()
// console.log(this.state)
if (this.state === 'STATE_FINISHED') {
// refreshColumnSize(this.hot, this.col)
}
const id = String(nextNodeId++)
node.__id = id
return id
}
}
const codeRenderer: Handsontable.renderers.TextRenderer = (
instance: Handsontable.Core,
td: HTMLTableCellElement,
row: number,
col: number,
prop: string,
value: string,
cellProperties: Handsontable.CellProperties
) => {
Handsontable.renderers.TextRenderer(instance, td, row, col, prop, value, cellProperties)
const nestedRowsPlugin: NestedRows = instance.getPlugin('nestedRows')
const physicalRow = instance.toPhysicalRow(row)
const dataManager = nestedRowsPlugin?.dataManager
if (!dataManager || physicalRow === null) {
return td
}
const normalizeNode = (node: TreeNode): TreeNode => {
getNodeId(node)
if (!Array.isArray(node.__children)) node.__children = []
return node
const rowLevel = dataManager.getRowLevel(physicalRow) ?? 0
const isParentRow = dataManager.isParent(physicalRow)
const collapsingUI = nestedRowsPlugin?.collapsingUI
const isCollapsed = isParentRow && collapsingUI ? collapsingUI.areChildrenCollapsed(physicalRow) : false
const dataObject = dataManager.getDataObject(physicalRow)
td.innerHTML = ''
td.classList.add('ht_nestingLevels')
const container = document.createElement('div')
container.className = 'relative'
// 一次性构建层级信息并创建元素
const levelIsLast: boolean[] = new Array(rowLevel)
let node = dataObject
// 从内向外遍历,填充 isLast 信息
for (let i = rowLevel - 1; i >= 0; i--) {
const parent = dataManager.getRowParent(node)
levelIsLast[i] = !parent?.__children ||
dataManager.getRowIndexWithinParent(node) === parent.__children.length - 1
node = parent
}
for (let i = 0; i < rowLevel; i++) {
const isLast = levelIsLast[i]
const isCurrent = i === rowLevel - 1
// 直接构建完整类名,避免多次 DOM 操作
const cssClass = isCurrent
? isLast ? 'ht_nestingLevel_empty ht_nestingLevel_corner' // └
: 'ht_nestingLevel_empty ht_nestingLevel_middle' // ├
: !isLast ? 'ht_nestingLevel_empty ht_nestingLevel_line' // │
: 'ht_nestingLevel_empty' // 空白
const levelIndicator = document.createElement('span')
levelIndicator.className = cssClass
container.appendChild(levelIndicator)
}
// console.log(value,rowLevel, isParentRow, collapsingUI)
const buildFlatTreeIndex = (root: TreeNode[], collapsedIds: Set<string>): FlatTreeIndex => {
const rows: TreeNode[] = []
const metaByRow: FlatRowMeta[] = []
if (isParentRow && collapsingUI) {
const toggleButton = document.createElement('div')
toggleButton.className = `ht_nestingButton ${isCollapsed ? 'ht_nestingExpand' : 'ht_nestingCollapse'}`
toggleButton.tabIndex = -1
toggleButton.setAttribute('role', 'button')
toggleButton.setAttribute('aria-label', isCollapsed ? 'Expand row' : 'Collapse row')
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)
toggleButton.addEventListener(
'mousedown',
(event) => {
event.preventDefault()
event.stopPropagation()
},
{ capture: true }
)
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}`
toggleButton.addEventListener(
'click',
(event) => {
event.preventDefault()
event.stopPropagation()
;(event as MouseEvent).stopImmediatePropagation?.()
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)
if (collapsingUI.areChildrenCollapsed(physicalRow)) {
collapsingUI.expandChildren(physicalRow)
} else {
collapsingUI.collapseChildren(physicalRow)
}
}
}
// refreshColumnSize(instance, col)
},
{ capture: true }
)
visit(root, 0, '', '')
return { root, rows, metaByRow }
container.appendChild(toggleButton)
} else {
const spacer = document.createElement('div')
spacer.className = 'ht_nestingButton hot-nesting-button--spacer'
spacer.setAttribute('aria-hidden', 'true')
container.appendChild(spacer)
}
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
}
const text = document.createElement('span')
text.className = 'htTextEllipsis'
text.textContent = value ?? ''
container.appendChild(text)
// 被冒泡了要单独设置contextmenu
container.addEventListener('contextmenu', (ev) => {
const newEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: ev.clientX,
clientY: ev.clientY,
})
return rowSchema
}
ev.preventDefault()
ev.stopPropagation()
td.dispatchEvent(newEvent)
})
td.appendChild(container)
//把最后一次选中的行重新选回来,保持高亮
const highlightDeselect = (_this: any, coords: any) => {
if (coords !== null) queueMicrotask(() => _this.selectCell(coords?.row, coords?.col))
}
const showColor = typeof cellProperties.colorClass === 'function'
if (showColor) {
// 基于 CSS 结构计算偏移量,避免 getBoundingClientRect() 触发强制布局回流
// CSS 常量: levelWidth=12px, gap=5px, buttonSize=16px, cellPadding≈4px
// 公式: padding + rowLevel * (levelWidth + gap) + buttonSize + gap - 5
const relativeLeft = 20 + rowLevel * 17
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
const highlightColor = cellProperties.colorClass(dataObject)
const hasHighlight = Boolean(highlightColor)
td.classList.toggle('ht_codeHighlight', hasHighlight)
if (hasHighlight) {
td.style.setProperty(
'--ht-highlight-offset',
`${relativeLeft}px`
)
td.style.setProperty('--ht-highlight-color', highlightColor)
} else {
td.style.removeProperty('--ht-highlight-offset')
td.style.removeProperty('--ht-highlight-color')
}
}
return td
}
Handsontable.cellTypes.registerCellType('db.nestedRows', {
editor: CodeEditor,
// editor: Handsontable.editors.TextEditor,
renderer: codeRenderer,
});
/**
* 修复 nestedRows 插件的 bug
* @param parent 父行数据对象
* @param index 子行插入的索引位置
* @param element 子行数据对象(可选)
*/
export function initAddChildAtIndex(_dataManager : any){
_dataManager.addChildAtIndex = function(parent: any, index: number, element: any) {
let childElement = element;
let flattenedIndex;
if (!childElement) {
childElement = this.mockNode();
}
this.hot.runHooks('beforeAddChild', parent, childElement, index);
if (parent) {
if (!parent.__children) {
parent.__children = [];
}
const safeIndex = Math.max(0, Math.min(index, parent.__children.length));
const parentIndex = this.getRowIndex(parent);
let insertRowIndex;
if (safeIndex === parent.__children.length) {
if (parent.__children.length === 0) {
insertRowIndex = parentIndex + 1;
} else {
const prevSibling = parent.__children[parent.__children.length - 1];
insertRowIndex = this.getRowIndex(prevSibling) + this.countChildren(prevSibling) + 1;
}
} else {
const nextSibling = parent.__children[safeIndex];
insertRowIndex = this.getRowIndex(nextSibling);
}
this.hot.runHooks('beforeCreateRow', insertRowIndex, 1);
parent.__children.splice(safeIndex, 0, childElement);
this.rewriteCache();
let upmostParent = parent;
let tempParent = upmostParent;
do {
tempParent = this.getRowParent(tempParent);
if (tempParent !== null) {
upmostParent = tempParent;
}
} while (tempParent !== null);
// 挂起渲染,合并 setSourceDataAtCell + insertIndexes + afterCreateRow 为一次渲染
this.hot.suspendRender();
this.plugin.disableCoreAPIModifiers();
this.hot.setSourceDataAtCell(
this.getRowIndexWithinParent(upmostParent),
'__children',
upmostParent.__children,
'NestedRows.addChildAtIndex'
);
this.hot.rowIndexMapper.insertIndexes(insertRowIndex, 1);
this.plugin.enableCoreAPIModifiers();
this.hot.runHooks('afterCreateRow', insertRowIndex, 1);
this.hot.resumeRender();
flattenedIndex = insertRowIndex;
} else {
this.plugin.disableCoreAPIModifiers();
this.hot.alter('insert_row_above', index, 1, 'NestedRows.addChildAtIndex');
this.plugin.enableCoreAPIModifiers();
flattenedIndex = this.getRowIndex(this.data[index]);
}
// Workaround for refreshing cache losing the reference to the mocked row.
childElement = this.getDataObject(flattenedIndex);
this.hot.runHooks('afterAddChild', parent, childElement, index);
};
}

View File

@@ -37,25 +37,13 @@ const debounce = <T extends (...args: any[]) => void>(func: T, wait: number) =>
}, wait)
}
}
export const createPopoverCellRenderer = ({ visible, buttonRef }: SelectRenderDeps) => {
const openPopover = (container: HTMLElement, virtualEl: HTMLElement) => {
buttonRef.value = virtualEl
visible.value = true
}
return (
instance: Handsontable,
function createPopover(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')){
cellProperties: any, openPopover: (container: HTMLElement, virtualEl: HTMLElement) => void){
td.innerHTML = ''
const container = document.createElement('div')
@@ -158,7 +146,73 @@ export const createPopoverCellRenderer = ({ visible, buttonRef }: SelectRenderDe
container.appendChild(input)
container.appendChild(searchIcon)
td.appendChild(container)
}
//** for /database/materials/machine */
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
) => {
createPopover(instance,
td,
row,
col,
prop,
value,
cellProperties, openPopover)
return td
}
}
/** for database/quota/price */
export const createPopoverCellPriceRenderer = ({ 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')){
//直接改某个单元格的 meta可动态切换
//例如 key='readOnly' 或 key='editor' (配合 hot.render() /生命周期自动重绘)
// instance.setCellMeta(row, col, 'editor', true)
// instance.render()
createPopover(instance,
td,
row,
col,
prop,
value,
cellProperties, openPopover)
}else{
// 子行:设置 quotaQuantity 和 adjustQuantity 为只读
// 使用 propToCol 方法将 data 属性转换为列索引
const quotaQuantityCol = instance.propToCol('quotaQuantity')
const adjustQuantityCol = instance.propToCol('adjustQuantity')
if (quotaQuantityCol !== null && quotaQuantityCol !== undefined) {
instance.setCellMeta(row, Number(quotaQuantityCol), 'readOnly', true)
}
if (adjustQuantityCol !== null && adjustQuantityCol !== undefined) {
instance.setCellMeta(row, Number(adjustQuantityCol), 'readOnly', true)
}
td.innerHTML = value || ''
}

View File

@@ -1,267 +0,0 @@
let currentDropdownEl: HTMLElement | null = null
let currentOnDocClick: ((e: MouseEvent) => void) | null = null
let currentAnchor: { instance: any; row: number; col: number; td: HTMLTableCellElement | null } | null = null
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 fieldKeys 字段键数组,按顺序对应表格列
* @param getDisplayText 获取显示文本的函数
* @returns 格式化后的表格行HTML和数据属性
*/
export function createTableDataStructure(
dataSource: any[],
fieldKeys: string[],
getDisplayText?: (item: any) => string
) {
const getLabel = getDisplayText ?? ((x: any) => x?.name ?? '')
return dataSource.map(item => {
// 动态生成单元格 - 支持嵌套属性
const cells = fieldKeys.map(key => {
const value = getNestedValue(item, key)
return `<td>${String(value)}</td>`
}).join('')
// 动态生成 data 属性 - 支持嵌套属性
const dataAttrs = fieldKeys
.map(key => {
const value = getNestedValue(item, key)
// 将嵌套路径转换为有效的 data 属性名,如 'calcBase.formula' -> 'calcbase-formula'
const attrName = key.toLowerCase().replace(/\./g, '-')
return `data-${attrName}="${String(value)}"`
})
.join(' ')
// 将完整的 item 数据序列化存储
const itemDataJson = JSON.stringify(item).replace(/"/g, '&quot;')
return {
html: `<tr class="hot-dropdown-row" data-label="${String(getLabel(item) ?? '')}" data-item="${itemDataJson}" ${dataAttrs}>${cells}</tr>`,
data: item
}
})
}
const getSelectedCoords = (inst: any): { row: number; col: number } | null => {
const r = inst?.getSelectedRangeLast?.()
if (!r) return null
return { row: r.to.row, col: r.to.col }
}
const getTargetCellRect = (inst: any, fallbackTD: HTMLTableCellElement | null): DOMRect | null => {
const sel = getSelectedCoords(inst)
if (sel) {
const td = inst.getCell(sel.row, sel.col, true) as HTMLTableCellElement | null
if (td) return td.getBoundingClientRect()
}
return fallbackTD ? fallbackTD.getBoundingClientRect() : null
}
const positionDropdown = (inst: any) => {
if (!currentDropdownEl || !inst) return
const containerRect = inst.rootElement?.getBoundingClientRect?.()
const cellRect = getTargetCellRect(inst, currentAnchor?.td ?? null)
if (!containerRect || !cellRect) return
const minWidth = Math.max(cellRect.width, 220)
currentDropdownEl.style.minWidth = `${minWidth}px`
const ddRect = currentDropdownEl.getBoundingClientRect()
const spaceBelow = containerRect.bottom - cellRect.bottom
const spaceAbove = cellRect.top - containerRect.top
const openUp = spaceBelow < ddRect.height && spaceAbove > spaceBelow
currentDropdownEl.classList.remove('hot-dropdown--up', 'hot-dropdown--down')
currentDropdownEl.classList.add(openUp ? 'hot-dropdown--up' : 'hot-dropdown--down')
const maxH = Math.max(0, Math.min(260, (openUp ? spaceAbove : spaceBelow)))
currentDropdownEl.style.maxHeight = `${maxH}px`
const dropdownW = Math.max(ddRect.width || minWidth, minWidth)
const left = clamp(
Math.round(cellRect.left),
Math.round(containerRect.left ),
Math.round(containerRect.right - dropdownW )
)
const ddh = Math.min(ddRect.height, maxH)
const topCandidate = openUp ? Math.round(cellRect.top - ddh) : Math.round(cellRect.bottom)
const top = clamp(
topCandidate,
Math.round(containerRect.top),
Math.round(containerRect.bottom - ddh)
)
currentDropdownEl.style.left = `${left}px`
currentDropdownEl.style.top = `${top}px`
}
export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, row: number, column: number, prop: string | number, value: any, cellProperties: any) {
td.innerHTML = ''
td.classList.add('hot-cell-dropdown')
const createEl = (tag: string, className?: string, text?: string) => {
const el = document.createElement(tag)
if (className) el.className = className
if (text !== undefined) el.textContent = text
return el
}
const display = createEl('div', 'hot-dropdown-display')
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))
else display.appendChild(createEl('span', 'hot-dropdown-placeholder', '选择'))
const trigger = createEl('button', 'hot-dropdown-trigger', '▼') as HTMLButtonElement
trigger.type = 'button'
const buildDropdown = async () => {
const headers: string[] | undefined = cellProperties.customTableHeaders
const dropdown = createEl('div', 'hot-dropdown')
const getDisplayText = (x: any) => x?.name ?? ''
// 创建搜索栏
const searchContainer = createEl('div', 'hot-dropdown-search')
const searchInput = createEl('input', 'hot-dropdown-search-input') as HTMLInputElement
searchInput.type = 'text'
searchInput.placeholder = '搜索...'
searchContainer.appendChild(searchInput)
const theadHtml = headers && headers.length ? `<thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead>` : ''
// 使用自定义字段键或默认字段键
const fieldKeys = cellProperties.customFieldKeys || []
// 创建加载提示
const tableEl = createEl('div', 'hot-dropdown-table-wrapper')
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(tableEl)
// 异步加载数据
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']
searchInput.addEventListener('input', (ev) => {
ev.stopPropagation()
const keyword = searchInput.value.toLowerCase().trim()
allRows.forEach(tr => {
const matches = searchFieldKeys.some((key: string) => {
// 将嵌套路径转换为 data 属性名,如 'calcBase.formula' -> 'calcbase-formula'
const attrName = key.toLowerCase().replace(/\./g, '-')
const value = (tr.dataset[attrName] ?? '').toLowerCase()
return value.includes(keyword)
})
tr.style.display = matches ? '' : 'none'
})
})
// 阻止搜索框点击事件冒泡
searchInput.addEventListener('click', (ev) => ev.stopPropagation())
tbody.addEventListener('click', (ev) => {
ev.stopPropagation()
const tr = (ev.target as HTMLElement).closest('tr') as HTMLTableRowElement | null
if (!tr || !tr.classList.contains('hot-dropdown-row')) return
// 从 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)
currentDropdownEl = null
if (currentOnDocClick) {
document.removeEventListener('click', currentOnDocClick)
currentOnDocClick = null
}
})
// 自动聚焦搜索框
setTimeout(() => searchInput.focus(), 50)
return dropdown
}
const openDropdown = async () => {
if (currentDropdownEl && currentDropdownEl.parentNode) currentDropdownEl.parentNode.removeChild(currentDropdownEl)
if (currentOnDocClick) document.removeEventListener('click', currentOnDocClick)
const dropdown = await buildDropdown()
document.body.appendChild(dropdown)
currentDropdownEl = dropdown
currentAnchor = { instance, row, col: column, td }
positionDropdown(instance)
currentOnDocClick = (e: MouseEvent) => {
if (!dropdown.contains(e.target as Node)) {
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown)
currentDropdownEl = null
currentAnchor = null
document.removeEventListener('click', currentOnDocClick!)
currentOnDocClick = null
}
}
document.addEventListener('click', currentOnDocClick)
}
trigger.addEventListener('click', (e) => { e.stopPropagation(); openDropdown() })
display.addEventListener('click', (e) => { e.stopPropagation(); openDropdown() })
td.appendChild(display)
td.appendChild(trigger)
return td
}

View File

@@ -1,28 +0,0 @@
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,250 +0,0 @@
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 = (
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 = '#fa4d3233' // 淡红色背景
} else {
TD.style.backgroundColor = cellProperties.className == "row-highlight"?'#e9ecfc':'' // 清除背景色
}
const nestedRowsPlugin = hot.getPlugin('nestedRows')
const physicalRow = nestedRowsPlugin ? nestedRowsPlugin.collapsingUI.translateTrimmedRow(row) : hot.toPhysicalRow(row)
const dataManager = nestedRowsPlugin?.dataManager
const dataNode = dataManager?.getDataObject?.(physicalRow)
const root = (dataManager?.getData?.() as any[] | undefined) ?? []
const line = nestedRowsPlugin && dataManager && dataNode && Array.isArray(root)
? getTreeLine(dataNode, dataManager, root)
: ({ key: '0', hasChildren: false } as const)
const { indentLayer, content, toggleEl, textEl } = getTreeCellDom(TD)
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 (line.hasChildren && nestedRowsPlugin) {
const isCollapsed = nestedRowsPlugin.collapsingUI.areChildrenCollapsed(physicalRow)
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.row = String(physicalRow)
if (!(btn as any).__htTreeToggleBound) {
;(btn as any).__htTreeToggleBound = true
btn.addEventListener('mousedown', (ev) => {
ev.stopPropagation()
ev.preventDefault()
const rowStr = (ev.currentTarget as HTMLElement | null)?.dataset.row
const targetRow = rowStr ? Number(rowStr) : NaN
if (!nestedRowsPlugin || Number.isNaN(targetRow)) return
const nowCollapsed = nestedRowsPlugin.collapsingUI.areChildrenCollapsed(targetRow)
if (nowCollapsed) nestedRowsPlugin.collapsingUI.expandChildren(targetRow)
else nestedRowsPlugin.collapsingUI.collapseChildren(targetRow)
})
}
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)
//解决右键行头触发上下文菜单事件
textEl.addEventListener('contextmenu', (ev) => {
ev.preventDefault()
ev.stopPropagation()
const e = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
view: window,
button: 2,
clientX: (ev as MouseEvent).clientX,
clientY: (ev as MouseEvent).clientY
})
TD.dispatchEvent(e)
})
}
// 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 => {
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 codeDataKey = codeColumn.data
// 计算该列的最大长度
const maxLen = data.reduce((m: number, r: any) => {
const v = r && r[codeDataKey] != null ? String(r[codeDataKey]) : ''
return Math.max(m, v.length)
}, 0)
const charWidth = 9
const basePadding = 24
const nested = hot.getPlugin('nestedRows')
const levelCount = nested?.dataManager?.cache?.levelCount ?? 0
const rootEl = hot.rootElement as HTMLElement
const styles = rootEl ? getComputedStyle(rootEl) : null
const iconSizeStr = styles ? styles.getPropertyValue('--ht-icon-size') : ''
const iconSize = Number.parseFloat(iconSizeStr) || 16
const indicatorWidth = 5
const gap = 6
const nestedPadding = levelCount * indicatorWidth + iconSize + gap
const width = Math.ceil(maxLen * charWidth) + basePadding + nestedPadding
return Math.min(Math.max(80, width), 480)
}
const createNode = (dataSchema: any,level: string): any => ({
...dataSchema,
level,
__children: []
})
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 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
}
if (type === 'delete') {
const range = getSelectedVisualRowRange(hot)
if (!range) return
hot.alter('remove_row', range.startRow, range.amount, 'remove_row_alter')
return
}
const sel = hot.getSelected()
if (!sel?.length) return
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

@@ -1,140 +0,0 @@
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

@@ -1,89 +1,53 @@
// 验证行
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);
export const validatorRow = (_this: any, changes: any, columns: any[] = []) => {
// const hasRealChange = changes.some((change: any) => {
// const [, , oldValue, newValue] = change
// return oldValue !== newValue
// })
const hasRealChange = changes.some((change: any) => {
const [, , oldValue, newValue] = change;
const isEmpty = (v: any) => v === null || v === undefined || v === '';
return oldValue !== newValue && !(isEmpty(oldValue) && isEmpty(newValue));
})
if (!hasRealChange) {
console.log('值未改变,跳过更新', hasRealChange)
return false
}
let hasEmptyRequired = false
if (columns.length > 0) {
const row = changes[0][0]
const rowData = _this.getSourceDataAtRow(row)
for (const col of columns) {
const colIndex = columns.findIndex((c: any) => c.data === col.data)
if (colIndex === -1) continue
const cellMeta = _this.getCellMeta?.(row, colIndex)
const metaRequired = cellMeta?.required
const isRequired =
metaRequired === true || (metaRequired !== false && col.required === true)
if (!isRequired) continue
const value = rowData[col.data]
const isEmpty = value === null || value === undefined || value === ''
if (isEmpty) {
hasEmptyRequired = true
_this.setCellMeta(row, colIndex, 'valid', false)
} else {
_this.setCellMeta(row, colIndex, 'valid', true)
}
// console.log('cellMeta', _this.getCellMeta?.(row, colIndex))
}
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('存在空单元格,验证失败,不执行后续操作')
_this.render()
if (hasEmptyRequired) {
console.error('必填字段不能为空', hasEmptyRequired)
return false
}
return true
}
}
return true
}

View File

@@ -0,0 +1,9 @@
// 生成随机6位数字+英文字符串
export 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
}

View File

@@ -1,418 +1,418 @@
import type { Ref } from 'vue'
import { ref } from 'vue'
import type { DropdownInstance, TreeV2Instance } from 'element-plus'
import { contextMenuManager } from '../db-hst/contextMenuManager'
// import type { Ref } from 'vue'
// import { ref } from 'vue'
// import type { DropdownInstance, TreeV2Instance } from 'element-plus'
// import { contextMenuManager } from '../db-hst/contextMenuManager'
interface NodeBase<T> { id: string; label: string; children?: T[] }
type NodeType<T> = T & NodeBase<T>
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
// interface NodeBase<T> { id: string; label: string; children?: T[] }
// type NodeType<T> = T & NodeBase<T>
// type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
interface MenuItem { key: string; text: string; disabled?: boolean }
// interface MenuItem { key: string; text: string; disabled?: boolean }
interface ContextMenuConfig<T> {
dataRef: Ref<NodeType<T>[]>;
treeRef: Ref<TreeV2Instance | undefined>;
expandedKeysRef: Ref<string[]>;
locate: (id: string) => LocateResult<T>;
startEdit: (node: NodeType<T>) => void;
}
// interface ContextMenuConfig<T> {
// dataRef: Ref<NodeType<T>[]>;
// treeRef: Ref<TreeV2Instance | undefined>;
// expandedKeysRef: Ref<string[]>;
// locate: (id: string) => LocateResult<T>;
// startEdit: (node: NodeType<T>) => void;
// }
interface ContextMenuContext<T> {
createNode: (prefix: string) => NodeType<T>;
setData: (next: NodeType<T>[]) => void;
expandNode: (id: string) => void;
setCurrentKey: (id: string) => void;
locate: (id: string) => LocateResult<T>;
startEdit: (node: NodeType<T>) => void;
dataRef: Ref<NodeType<T>[]>;
}
// interface ContextMenuContext<T> {
// createNode: (prefix: string) => NodeType<T>;
// setData: (next: NodeType<T>[]) => void;
// expandNode: (id: string) => void;
// setCurrentKey: (id: string) => void;
// locate: (id: string) => LocateResult<T>;
// startEdit: (node: NodeType<T>) => void;
// dataRef: Ref<NodeType<T>[]>;
// }
interface LevelConfig {
depth: number
addKey?: string
addText?: string
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 LevelConfig {
// depth: number
// addKey?: string
// addText?: string
// 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 {
rootKey: string
rootText: string
onRootAdd?: (newNode: any, allRootNodes: any[]) => void | Promise<void>
onRootDelete?: (node: any) => void | Promise<void>
levels: LevelConfig[]
}
// interface HierarchyConfig {
// rootKey: string
// rootText: string
// onRootAdd?: (newNode: any, allRootNodes: any[]) => void | Promise<void>
// onRootDelete?: (node: any) => void | Promise<void>
// levels: LevelConfig[]
// }
interface ContextMenuHandler<T> {
getMenuItems: (node: NodeType<T> | null, ctx: ContextMenuContext<T>) => MenuItem[];
execute: (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => Promise<void> | void;
}
// interface ContextMenuHandler<T> {
// getMenuItems: (node: NodeType<T> | null, ctx: ContextMenuContext<T>) => MenuItem[];
// execute: (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => Promise<void> | void;
// }
/**
* 获取节点深度
*/
const getDepth = <T>(node: NodeType<T>, ctx: ContextMenuContext<T>): number => {
const target = ctx.locate(node.id)
if (!target) return -1
let depth = 0
let p = target.parent
while (p) {
depth += 1
const pt = ctx.locate(p.id)
p = pt?.parent ?? null
}
return depth
}
// /**
// * 获取节点深度
// */
// const getDepth = <T>(node: NodeType<T>, ctx: ContextMenuContext<T>): number => {
// const target = ctx.locate(node.id)
// if (!target) return -1
// let depth = 0
// let p = target.parent
// while (p) {
// depth += 1
// const pt = ctx.locate(p.id)
// p = pt?.parent ?? null
// }
// return depth
// }
/**
* 层级化上下文菜单处理器
*/
class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
constructor(private config: HierarchyConfig) {}
// /**
// * 层级化上下文菜单处理器
// */
// class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
// constructor(private config: HierarchyConfig) {}
getMenuItems = (node: NodeType<T> | null, ctx: ContextMenuContext<T>): MenuItem[] => {
// 空白区域右键 - 添加根节点
if (!node) {
return [{ key: this.config.rootKey, text: this.config.rootText }]
}
// getMenuItems = (node: NodeType<T> | null, ctx: ContextMenuContext<T>): MenuItem[] => {
// // 空白区域右键 - 添加根节点
// if (!node) {
// return [{ key: this.config.rootKey, text: this.config.rootText }]
// }
const depth = getDepth(node, ctx)
console.log('getMenuItems - 节点:', node, '深度:', depth)
const levelConfig = this.config.levels.find(l => l.depth === depth)
console.log('找到的 levelConfig:', levelConfig)
// const depth = getDepth(node, ctx)
// //console.log('getMenuItems - 节点:', node, '深度:', depth)
// const levelConfig = this.config.levels.find(l => l.depth === depth)
// //console.log('找到的 levelConfig:', levelConfig)
if (!levelConfig) {
// 未配置的层级,只显示删除
return [{ key: 'delete', text: '删除' }]
}
// if (!levelConfig) {
// // 未配置的层级,只显示删除
// return [{ key: 'delete', text: '删除' }]
// }
const items: MenuItem[] = []
// const items: MenuItem[] = []
// 添加子级菜单项(检查条件)
if (levelConfig.addKey && 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.addKey && 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 })
}
}
}
// // 添加自定义菜单项
// if (levelConfig.customMenuItems) {
// for (const customItem of levelConfig.customMenuItems) {
// // 如果有条件函数,检查是否满足条件
// if (!customItem.condition || customItem.condition(node)) {
// items.push({ key: customItem.key, text: customItem.text })
// }
// }
// }
// 删除菜单项
if (levelConfig.allowDelete !== false) {
items.push({ key: 'delete', text: '删除' })
}
// // 删除菜单项
// if (levelConfig.allowDelete !== false) {
// items.push({ key: 'delete', text: '删除' })
// }
return items
}
// return items
// }
execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
// 添加根节点
if (!node && cmd === this.config.rootKey) {
const next = ctx.createNode('root')
next.label = this.config.rootText.replace('添加', '')
// execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
// // 添加根节点
// if (!node && cmd === this.config.rootKey) {
// const next = ctx.createNode('root')
// 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
}
// // 调用根节点添加回调,传入所有根节点数据
// 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
// }
if (!node) return
// if (!node) return
// 删除节点
if (cmd === 'delete') {
const target = ctx.locate(node.id)
if (!target) return
// // 删除节点
// if (cmd === 'delete') {
// const target = ctx.locate(node.id)
// if (!target) return
// 查找当前节点的层级配置
const depth = getDepth(node, ctx)
const levelConfig = this.config.levels.find(l => l.depth === depth)
// // 查找当前节点的层级配置
// 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)
}
// // 如果是根节点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)
ctx.setData([...ctx.dataRef.value])
return
}
// target.container.splice(target.index, 1)
// ctx.setData([...ctx.dataRef.value])
// return
// }
// 查找匹配的层级配置
const depth = getDepth(node, ctx)
const levelConfig = this.config.levels.find(l => l.depth === depth && l.addKey === cmd)
// // 查找匹配的层级配置
// const depth = getDepth(node, ctx)
// const levelConfig = this.config.levels.find(l => l.depth === depth && l.addKey === cmd)
if (levelConfig) {
const target = ctx.locate(node.id)
if (!target) return
// if (levelConfig) {
// const target = ctx.locate(node.id)
// if (!target) return
target.node.children = target.node.children || []
// 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
})
// // 如果配置了排序字段,先对现有子节点排序
// 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)
// // 计算新节点的排序号(取最大值 + 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
// 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)
// 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('添加', '') || '新目录'
// // 调用添加回调,传递父节点的所有子节点
// 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)
// 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
}
// // 调用添加回调,传递父节点的所有子节点
// 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
// }
// 处理自定义命令
const customLevelConfig = this.config.levels.find(l => l.depth === getDepth(node, ctx))
if (customLevelConfig?.onCustomCommand) {
await customLevelConfig.onCustomCommand(cmd, node)
}
}
}
// // 处理自定义命令
// const customLevelConfig = this.config.levels.find(l => l.depth === getDepth(node, ctx))
// if (customLevelConfig?.onCustomCommand) {
// await customLevelConfig.onCustomCommand(cmd, node)
// }
// }
// }
/**
* 默认上下文菜单处理器
*/
class DefaultContextMenuHandler<T> implements ContextMenuHandler<T> {
getMenuItems = (node: NodeType<T> | null, _ctx: ContextMenuContext<T>): MenuItem[] => {
if (!node) return [{ key: 'add-root', text: '添加根目录' }]
return [
{ key: 'add-sibling', text: '添加目录' },
{ key: 'add-child', text: '添加子目录' },
{ key: 'rename', text: '重命名' },
{ key: 'delete', text: '删除' },
]
}
// /**
// * 默认上下文菜单处理器
// */
// class DefaultContextMenuHandler<T> implements ContextMenuHandler<T> {
// getMenuItems = (node: NodeType<T> | null, _ctx: ContextMenuContext<T>): MenuItem[] => {
// if (!node) return [{ key: 'add-root', text: '添加根目录' }]
// return [
// { key: 'add-sibling', text: '添加目录' },
// { key: 'add-child', text: '添加子目录' },
// { key: 'rename', text: '重命名' },
// { key: 'delete', text: '删除' },
// ]
// }
execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
if (!node && cmd === 'add-root') {
ctx.setData([...ctx.dataRef.value, ctx.createNode('node')])
return
}
if (!node) return
if (cmd === 'add-sibling') {
const target = ctx.locate(node.id)
if (!target) return
const prefix = target.parent ? target.parent.id : 'node'
target.container.push(ctx.createNode(prefix))
ctx.setData([...ctx.dataRef.value])
return
}
if (cmd === 'add-child') {
const target = ctx.locate(node.id)
if (!target) return
target.node.children = target.node.children || []
target.node.children.push(ctx.createNode(target.node.id))
ctx.setData([...ctx.dataRef.value])
ctx.expandNode(target.node.id)
// ctx.setCurrentKey(target.node.id)
return
}
if (cmd === 'rename') { ctx.startEdit(node); return }
if (cmd === 'delete') {
const target = ctx.locate(node.id)
if (!target) return
target.container.splice(target.index, 1)
ctx.setData([...ctx.dataRef.value])
}
}
}
// execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
// if (!node && cmd === 'add-root') {
// ctx.setData([...ctx.dataRef.value, ctx.createNode('node')])
// return
// }
// if (!node) return
// if (cmd === 'add-sibling') {
// const target = ctx.locate(node.id)
// if (!target) return
// const prefix = target.parent ? target.parent.id : 'node'
// target.container.push(ctx.createNode(prefix))
// ctx.setData([...ctx.dataRef.value])
// return
// }
// if (cmd === 'add-child') {
// const target = ctx.locate(node.id)
// if (!target) return
// target.node.children = target.node.children || []
// target.node.children.push(ctx.createNode(target.node.id))
// ctx.setData([...ctx.dataRef.value])
// ctx.expandNode(target.node.id)
// // ctx.setCurrentKey(target.node.id)
// return
// }
// if (cmd === 'rename') { ctx.startEdit(node); return }
// if (cmd === 'delete') {
// const target = ctx.locate(node.id)
// if (!target) return
// target.container.splice(target.index, 1)
// ctx.setData([...ctx.dataRef.value])
// }
// }
// }
class DbTreeContextMenu<T> {
dropdownRef = ref<DropdownInstance>()
position = ref({ top: 0, left: 0, bottom: 0, right: 0 } as DOMRect)
triggerRef = ref({ getBoundingClientRect: () => this.position.value })
currentNode = ref<NodeType<T> | null>(null)
// class DbTreeContextMenu<T> {
// dropdownRef = ref<DropdownInstance>()
// position = ref({ top: 0, left: 0, bottom: 0, right: 0 } as DOMRect)
// triggerRef = ref({ getBoundingClientRect: () => this.position.value })
// currentNode = ref<NodeType<T> | null>(null)
private config: ContextMenuConfig<T>
private handler: ContextMenuHandler<T>
private unregister: (() => void) | null = null
// private config: ContextMenuConfig<T>
// private handler: ContextMenuHandler<T>
// private unregister: (() => void) | null = null
constructor(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T>) {
this.config = config
this.handler = handler ?? new DefaultContextMenuHandler<T>()
// constructor(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T>) {
// this.config = config
// this.handler = handler ?? new DefaultContextMenuHandler<T>()
// 注册到全局菜单管理器
this.unregister = contextMenuManager.register(this.closeContextMenu)
}
// // 注册到全局菜单管理器
// this.unregister = contextMenuManager.register(this.closeContextMenu)
// }
destroy() {
// 取消注册
if (this.unregister) {
this.unregister()
this.unregister = null
}
}
// destroy() {
// // 取消注册
// if (this.unregister) {
// this.unregister()
// this.unregister = null
// }
// }
private createNode = (prefix: string): NodeType<T> => {
const suffix = Math.random().toString(36).slice(2, 8)
const id = `${prefix}-${suffix}`
return { id, label: '新目录' } as NodeType<T>
}
// private createNode = (prefix: string): NodeType<T> => {
// const suffix = Math.random().toString(36).slice(2, 8)
// const id = `${prefix}-${suffix}`
// return { id, label: '新目录' } as NodeType<T>
// }
private setData = (next: NodeType<T>[]) => { this.config.dataRef.value = next }
private expandNode = (id: string) => {
const keys = Array.from(new Set([...this.config.expandedKeysRef.value, id]))
this.config.expandedKeysRef.value = keys
this.config.treeRef.value?.setExpandedKeys(keys)
}
private setCurrentKey = (id: string) => { this.config.treeRef.value?.setCurrentKey?.(id) }
private ctx = (): ContextMenuContext<T> => ({
createNode: this.createNode,
setData: this.setData,
expandNode: this.expandNode,
setCurrentKey: this.setCurrentKey,
locate: this.config.locate,
startEdit: this.config.startEdit,
dataRef: this.config.dataRef,
})
// private setData = (next: NodeType<T>[]) => { this.config.dataRef.value = next }
// private expandNode = (id: string) => {
// const keys = Array.from(new Set([...this.config.expandedKeysRef.value, id]))
// this.config.expandedKeysRef.value = keys
// this.config.treeRef.value?.setExpandedKeys(keys)
// }
// private setCurrentKey = (id: string) => { this.config.treeRef.value?.setCurrentKey?.(id) }
// private ctx = (): ContextMenuContext<T> => ({
// createNode: this.createNode,
// setData: this.setData,
// expandNode: this.expandNode,
// setCurrentKey: this.setCurrentKey,
// locate: this.config.locate,
// startEdit: this.config.startEdit,
// dataRef: this.config.dataRef,
// })
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>) => {
// console.log('openContextMenu',nodeData)
// 通知管理器即将打开新菜单,关闭其他菜单
contextMenuManager.notifyOpening(this.closeContextMenu)
// openContextMenu = (event: MouseEvent, nodeData: NodeType<T>) => {
// // console.log('openContextMenu',nodeData)
// // 通知管理器即将打开新菜单,关闭其他菜单
// contextMenuManager.notifyOpening(this.closeContextMenu)
event.preventDefault()
const { clientX, clientY } = event
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()
}
// event.preventDefault()
// const { clientX, clientY } = event
// 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()
// }
openBlankContextMenu = (event: MouseEvent) => {
// console.log('openBlankContextMenu')
// 通知管理器即将打开新菜单,关闭其他菜单
contextMenuManager.notifyOpening(this.closeContextMenu)
// openBlankContextMenu = (event: MouseEvent) => {
// // console.log('openBlankContextMenu')
// // 通知管理器即将打开新菜单,关闭其他菜单
// contextMenuManager.notifyOpening(this.closeContextMenu)
event.preventDefault()
const { clientX, clientY } = event
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()
}
// event.preventDefault()
// const { clientX, clientY } = event
// 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()
// }
closeContextMenu = () => { this.dropdownRef.value?.handleClose() }
// closeContextMenu = () => { this.dropdownRef.value?.handleClose() }
onCommand = async (cmd: string) => { await this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
// onCommand = async (cmd: string) => { await this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
onGlobalCommand = (cmd: string) => { void this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
}
// onGlobalCommand = (cmd: string) => { void this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
// }
/**
* 创建层级化的上下文菜单处理器
* @param config 层级配置
* @returns ContextMenuHandler
*/
const createHierarchyContextMenuHandler = <T>(config: HierarchyConfig): ContextMenuHandler<T> => {
return new HierarchyContextMenuHandler<T>(config)
}
// /**
// * 创建层级化的上下文菜单处理器
// * @param config 层级配置
// * @returns ContextMenuHandler
// */
// const createHierarchyContextMenuHandler = <T>(config: HierarchyConfig): ContextMenuHandler<T> => {
// return new HierarchyContextMenuHandler<T>(config)
// }
const createContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => {
// 如果传入的是 HierarchyConfig自动创建 HierarchyContextMenuHandler
if (handler && 'rootKey' in handler && 'rootText' in handler && 'levels' in handler) {
return new DbTreeContextMenu<T>(config, createHierarchyContextMenuHandler<T>(handler))
}
return new DbTreeContextMenu<T>(config, handler as ContextMenuHandler<T> | undefined)
}
// const createContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => {
// // 如果传入的是 HierarchyConfig自动创建 HierarchyContextMenuHandler
// if (handler && 'rootKey' in handler && 'rootText' in handler && 'levels' in handler) {
// return new DbTreeContextMenu<T>(config, createHierarchyContextMenuHandler<T>(handler))
// }
// return new DbTreeContextMenu<T>(config, handler as ContextMenuHandler<T> | undefined)
// }
export { DbTreeContextMenu, HierarchyContextMenuHandler, createHierarchyContextMenuHandler }
export type { ContextMenuConfig, ContextMenuHandler, MenuItem, NodeType, LocateResult, HierarchyConfig, LevelConfig }
// export { DbTreeContextMenu, HierarchyContextMenuHandler, createHierarchyContextMenuHandler }
// export type { ContextMenuConfig, ContextMenuHandler, MenuItem, NodeType, LocateResult, HierarchyConfig, LevelConfig }
export const useContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => createContextMenu<T>(config, handler)
// export const useContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => createContextMenu<T>(config, handler)

View File

@@ -1,102 +1,292 @@
import type { Ref } from 'vue'
import { ref } from 'vue'
import type { TreeV2Instance } from 'element-plus'
import { ref, shallowRef } from 'vue'
type NodeType<T> = T & { id: string; label: string; children?: NodeType<T>[] }
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
export type TreeKey = string | number
export type DropType = 'before' | 'after' | 'inner'
type DropType = 'before' | 'after' | 'inside'
type ParentMap<Key extends TreeKey> = Map<Key, Key | null>
const getDropType = (e: DragEvent): DropType => {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const offset = e.clientY - rect.top
if (offset < rect.height * 0.25) return 'before'
if (offset > rect.height * 0.75) return 'after'
return 'inside'
const isKey = (value: unknown): value is TreeKey => typeof value === 'string' || typeof value === 'number'
const getDropType = (event: DragEvent): DropType => {
const target = event.currentTarget as HTMLElement | null
if (!target) return 'inner'
const rect = target.getBoundingClientRect()
const y = event.clientY - rect.top
const threshold = Math.max(6, rect.height * 0.25)
if (y <= threshold) return 'before'
if (y >= rect.height - threshold) return 'after'
return 'inner'
}
export const useDragAndDrop = <T>(params: {
dataRef: Ref<NodeType<T>[]>;
treeRef: Ref<TreeV2Instance | undefined>;
expandedKeysRef: Ref<string[]>;
locate: (id: string) => LocateResult<T>;
const getSiblingDropType = (event: DragEvent): Exclude<DropType, 'inner'> => {
const target = event.currentTarget as HTMLElement | null
if (!target) return 'after'
const rect = target.getBoundingClientRect()
const y = event.clientY - rect.top
return y <= rect.height / 2 ? 'before' : 'after'
}
const buildParentMap = <DataT extends Record<string, any>, Key extends TreeKey>(params: {
treeData: DataT[]
keyField: string
childrenField: string
}): ParentMap<Key> => {
const parentByKey: ParentMap<Key> = new Map()
const { treeData, keyField, childrenField } = params
const walk = (nodes: DataT[], parentKey: Key | null) => {
for (const item of nodes) {
if (!item || typeof item !== 'object') continue
const key = item[keyField] as unknown
if (!isKey(key)) continue
parentByKey.set(key as Key, parentKey)
const children = item[childrenField] as unknown
if (Array.isArray(children) && children.length) walk(children as DataT[], key as Key)
}
}
walk(treeData, null)
return parentByKey
}
const locate = <DataT extends Record<string, any>, Key extends TreeKey>(params: {
treeData: DataT[]
keyField: string
childrenField: string
targetKey: Key
}): { container: DataT[]; index: number; item: DataT } | null => {
const { treeData, keyField, childrenField, targetKey } = params
const walk = (nodes: DataT[]): { container: DataT[]; index: number; item: DataT } | null => {
for (let i = 0; i < nodes.length; i++) {
const item = nodes[i]
if (!item || typeof item !== 'object') continue
const key = item[keyField] as unknown
if (key === targetKey) return { container: nodes, index: i, item }
const children = item[childrenField] as unknown
if (Array.isArray(children) && children.length) {
const found = walk(children as DataT[])
if (found) return found
}
}
return null
}
return walk(treeData)
}
const isAncestorOf = <Key extends TreeKey>(parentByKey: ParentMap<Key>, ancestorKey: Key, possibleDescendantKey: Key): boolean => {
let current: Key | null | undefined = possibleDescendantKey
while (current !== null && current !== undefined) {
if (current === ancestorKey) return true
current = parentByKey.get(current)
}
return false
}
export const useDbTreeDraggable = <
NodeT extends Record<string, any>,
DataT extends Record<string, any>,
Key extends TreeKey = TreeKey,
>(params: {
getTreeData: () => DataT[]
getKeyField: () => string
getChildrenField: () => string
getKeyFromNode?: (node: NodeT, data: DataT) => Key | null
isDraggable: () => boolean
allowCrossNodeDrag: () => boolean
confirmMove: () => Promise<boolean>
onMoved: (payload: {
dragData: DataT
dropData: DataT
dragNode: NodeT
dropNode: NodeT
dropType: DropType
event: DragEvent
}) => void
}) => {
const { dataRef, treeRef, expandedKeysRef, locate } = params
const draggingId = ref<string | null>(null)
const dropState = ref<{ id: string | null; type: DropType | null }>({ id: null, type: null })
const draggingKey = ref<Key | null>(null)
const draggingNode = shallowRef<NodeT | null>(null)
const draggingData = shallowRef<DataT | null>(null)
const dropHint = ref<{ key: Key; type: DropType } | null>(null)
const parentByKey = shallowRef<ParentMap<Key>>(new Map())
const contains = (sourceId: string, targetId: string) => {
const src = locate(sourceId)
if (!src) return false
const stack: NodeType<T>[] = src.node.children ? [...src.node.children] : []
while (stack.length) {
const n = stack.pop()!
if (n.id === targetId) return true
if (n.children && n.children.length) stack.push(...n.children)
const getKeyFromNode =
params.getKeyFromNode ??
((node: NodeT, data?: DataT) => {
const nodeKey = node?.['key'] as unknown
if (isKey(nodeKey)) return nodeKey as Key
const key = data?.[params.getKeyField()] as unknown
return isKey(key) ? (key as Key) : null
})
const clearDragState = () => {
draggingKey.value = null
draggingNode.value = null
draggingData.value = null
dropHint.value = null
parentByKey.value = new Map()
}
const isDropHint = (node: NodeT, data: DataT, type: DropType) => {
const key = getKeyFromNode(node, data)
return Boolean(dropHint.value && dropHint.value.key === key && dropHint.value.type === type)
}
const handleDragStart = (event: DragEvent, node: NodeT, data: DataT) => {
if (!params.isDraggable()) return
const dragKey = getKeyFromNode(node, data)
if (dragKey === null) return
draggingKey.value = dragKey
draggingNode.value = node
draggingData.value = data
parentByKey.value = buildParentMap<DataT, Key>({
treeData: params.getTreeData(),
keyField: params.getKeyField(),
childrenField: params.getChildrenField(),
})
const transfer = event.dataTransfer
if (transfer) {
transfer.effectAllowed = 'move'
transfer.setData('text/plain', String(dragKey))
}
return false
}
const getNodeStyles = (node: NodeType<T>) => {
const base: Record<string, string> = { display: 'block', width: '100%'}
if (dropState.value.id === node.id && dropState.value.type) {
if (dropState.value.type === 'before') base.borderTop = '2px solid #409eff'
else if (dropState.value.type === 'after') base.borderBottom = '2px solid #409eff'
else base.background = 'rgba(64,158,255,0.15)'
const handleDragOver = (event: DragEvent, node: NodeT, data: DataT) => {
if (!params.isDraggable()) return
const currentDraggingKey = draggingKey.value
if (currentDraggingKey === null) return
const dropKey = getKeyFromNode(node, data)
if (dropKey === null) {
dropHint.value = null
return
}
return base
}
const onDragStart = (node: NodeType<T>, e: DragEvent) => {
draggingId.value = node.id
e.dataTransfer?.setData('text/plain', node.id)
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
}
const onDragOver = (node: NodeType<T>, e: DragEvent) => {
if (!draggingId.value) return
if (draggingId.value === node.id) { dropState.value = { id: null, type: null }; return }
const type = getDropType(e)
dropState.value = { id: node.id, type }
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
}
const onDrop = (node: NodeType<T>, e: DragEvent) => {
if (!draggingId.value) return
const sourceId = draggingId.value
const type = getDropType(e)
if (sourceId === node.id) { onDragEnd(); return }
if (type === 'inside' && contains(sourceId, node.id)) { onDragEnd(); return }
const src = locate(sourceId)
const tgt = locate(node.id)
if (!src || !tgt) { onDragEnd(); return }
src.container.splice(src.index, 1)
if (type === 'inside') {
const nextChildren = tgt.node.children ? [...tgt.node.children] : []
nextChildren.push(src.node as NodeType<T>)
tgt.node.children = nextChildren
expandedKeysRef.value = Array.from(new Set([...expandedKeysRef.value, tgt.node.id]))
treeRef.value?.setExpandedKeys(expandedKeysRef.value)
treeRef.value?.setCurrentKey?.(src.node.id)
} else {
let insertIndex = tgt.index
if (type === 'after') insertIndex = tgt.index + 1
const same = src.container === tgt.container
if (same && src.index < tgt.index && type === 'before') insertIndex -= 1
if (same && src.index < tgt.index && type === 'after') insertIndex -= 1
tgt.container.splice(insertIndex, 0, src.node)
treeRef.value?.setCurrentKey?.(src.node.id)
if (dropKey === currentDraggingKey) {
dropHint.value = null
return
}
dataRef.value = [...dataRef.value]
onDragEnd()
if (params.allowCrossNodeDrag() && isAncestorOf(parentByKey.value, currentDraggingKey, dropKey)) {
dropHint.value = null
return
}
let dropType = getDropType(event)
if (!params.allowCrossNodeDrag()) {
dropType = getSiblingDropType(event)
const dragParentKey = parentByKey.value.get(currentDraggingKey)
const dropParentKey = parentByKey.value.get(dropKey)
if (dragParentKey !== dropParentKey) {
dropHint.value = null
return
}
}
event.preventDefault()
dropHint.value = { key: dropKey, type: dropType }
const transfer = event.dataTransfer
if (transfer) transfer.dropEffect = 'move'
}
const onDragEnd = () => {
draggingId.value = null
dropState.value = { id: null, type: null }
const moveNodeByKey = (dragKey: Key, dropKey: Key, dropType: DropType) => {
const treeData = params.getTreeData()
const keyField = params.getKeyField()
const childrenField = params.getChildrenField()
const dragLoc = locate<DataT, Key>({ treeData, keyField, childrenField, targetKey: dragKey })
if (!dragLoc) return false
const [dragItem] = dragLoc.container.splice(dragLoc.index, 1)
if (!dragItem) return false
if (dropType === 'inner') {
const dropLoc = locate<DataT, Key>({ treeData, keyField, childrenField, targetKey: dropKey })
if (!dropLoc) {
treeData.push(dragItem)
return true
}
const children = dropLoc.item[childrenField] as any
if (Array.isArray(children)) {
children.push(dragItem)
return true
}
;(dropLoc.item as any)[childrenField] = [dragItem]
return true
}
const dropLoc = locate<DataT, Key>({ treeData, keyField, childrenField, targetKey: dropKey })
if (!dropLoc) {
treeData.push(dragItem)
return true
}
const insertIndex = dropType === 'before' ? dropLoc.index : dropLoc.index + 1
dropLoc.container.splice(insertIndex, 0, dragItem)
return true
}
return { draggingId, dropState, getNodeStyles, onDragStart, onDragOver, onDrop, onDragEnd }
const handleDrop = async (event: DragEvent, node: NodeT, data: DataT) => {
if (!params.isDraggable()) return
const currentDraggingKey = draggingKey.value
const currentDraggingNode = draggingNode.value
const currentDraggingData = draggingData.value
if (currentDraggingKey === null || !currentDraggingNode || !currentDraggingData) return
const dropKey = getKeyFromNode(node, data)
if (dropKey === null) return
event.preventDefault()
event.stopPropagation()
if (dropKey === currentDraggingKey) {
clearDragState()
return
}
if (params.allowCrossNodeDrag() && isAncestorOf(parentByKey.value, currentDraggingKey, dropKey)) {
clearDragState()
return
}
let dropType = dropHint.value?.key === dropKey ? dropHint.value.type : getDropType(event)
if (!params.allowCrossNodeDrag()) {
dropType = getSiblingDropType(event)
const dragParentKey = parentByKey.value.get(currentDraggingKey)
const dropParentKey = parentByKey.value.get(dropKey)
if (dragParentKey !== dropParentKey) {
clearDragState()
return
}
}
clearDragState()
const ok = await params.confirmMove().catch(() => false)
if (!ok) return
const moved = moveNodeByKey(currentDraggingKey, dropKey, dropType)
if (!moved) return
params.onMoved({
dragData: currentDraggingData,
dropData: data,
dragNode: currentDraggingNode,
dropNode: node,
dropType,
event,
})
}
const handleDragEnd = () => clearDragState()
return {
clearDragState,
isDropHint,
handleDragStart,
handleDragOver,
handleDrop,
handleDragEnd,
}
}

View File

@@ -1 +1,2 @@
export { default as DbTree } from './index.vue';
export { default as DbTree } from './index.vue';
export type { TreeProps } from './index.vue';

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +1,56 @@
import type { Ref } from 'vue'
import { ref, nextTick } from 'vue'
// import type { Ref } from 'vue';
// import { nextTick, ref } from 'vue';
type NodeType<T> = T & { id: string; label: string; children?: T[] }
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
// type NodeType<T> = T & { id: string; label: string; children?: T[] }
// type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
export const useInlineEdit = <T>(params: {
dataRef: Ref<NodeType<T>[]>;
locate: (id: string) => LocateResult<T>;
onSave?: (node: NodeType<T>, oldLabel: string, newLabel: string) => void | Promise<void>;
}) => {
const { dataRef, locate, onSave } = params
const editingId = ref<string | null>(null)
const editingLabel = ref('')
const editingOriginalLabel = ref('')
// export const useInlineEdit = <T>(params: {
// dataRef: Ref<NodeType<T>[]>;
// locate: (id: string) => LocateResult<T>;
// onSave?: (node: NodeType<T>, oldLabel: string, newLabel: string) => void | Promise<void>;
// }) => {
// const { dataRef, locate, onSave } = params
// const editingId = ref<string | null>(null)
// const editingLabel = ref('')
// const editingOriginalLabel = ref('')
const startEdit = (nodeData: NodeType<T>) => {
editingId.value = nodeData.id
editingLabel.value = nodeData.label
editingOriginalLabel.value = nodeData.label
nextTick(() => {
const el = document.getElementById(`edit-${nodeData.id}`) as HTMLInputElement | null
el?.focus()
el?.select()
})
}
// const startEdit = (nodeData: NodeType<T>) => {
// editingId.value = nodeData.id
// editingLabel.value = nodeData.label
// editingOriginalLabel.value = nodeData.label
// nextTick(() => {
// const el = document.getElementById(`edit-${nodeData.id}`) as HTMLInputElement | null
// el?.focus()
// el?.select()
// })
// }
const saveEdit = async () => {
if (!editingId.value) return
const target = locate(editingId.value)
if (!target) { editingId.value = null; return }
const next = editingLabel.value.trim()
const oldLabel = editingOriginalLabel.value
if (next && next !== oldLabel) {
target.node.label = next
if (onSave) {
await onSave(target.node, oldLabel, next)
}
}
dataRef.value = [...dataRef.value]
editingId.value = null
}
// const saveEdit = async () => {
// if (!editingId.value) return
// const target = locate(editingId.value)
// if (!target) { editingId.value = null; return }
// const next = editingLabel.value.trim()
// const oldLabel = editingOriginalLabel.value
// if (next && next !== oldLabel) {
// target.node.label = next
// // 同步更新 name 属性(如果存在),保持数据一致性
// if ('name' in target.node) {
// (target.node as any).name = next
// }
// if (onSave) {
// await onSave(target.node, oldLabel, next)
// }
// }
// dataRef.value = [...dataRef.value]
// editingId.value = null
// }
const cancelEdit = () => {
if (!editingId.value) return
editingLabel.value = editingOriginalLabel.value
editingId.value = null
}
// const cancelEdit = () => {
// if (!editingId.value) return
// editingLabel.value = editingOriginalLabel.value
// editingId.value = null
// }
return { editingId, editingLabel, editingOriginalLabel, startEdit, saveEdit, cancelEdit }
}
// return { editingId, editingLabel, editingOriginalLabel, startEdit, saveEdit, cancelEdit }
// }

View File

@@ -0,0 +1,177 @@
<template>
<span class="element-tree-node-label-wrapper">
<slot v-if="$slots.default" :node="node" :data="data" />
<template v-else>
<slot name="node-label" :node="node" :data="data">
<span class="element-tree-node-label">{{ String(node.label ?? '') }}</span>
</slot>
<span v-if="showLabelLine" class="element-tree-node-label-line" />
<slot name="after-node-label" :node="node" :data="data" />
</template>
<template v-if="shouldRenderLines">
<span
v-for="line in verticalLines"
:key="line.index"
class="element-tree-node-line-ver"
:class="{ 'last-node-isLeaf-line': line.isLastLeafLine }"
:style="{ left: line.left }"
/>
<span class="element-tree-node-line-hor" :style="horizontalStyle" />
</template>
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
type TreeNodeLike = {
id?: unknown
key?: unknown
label?: unknown
level: number
parent?: {
children?: TreeNodeLike[]
childNodes?: TreeNodeLike[]
level?: number
key?: unknown
parent?: unknown
} | null
isLeaf?: boolean
children?: TreeNodeLike[]
childNodes?: TreeNodeLike[]
}
type Props = {
node: TreeNodeLike
data?: unknown
treeData?: unknown[]
rootKeyIndexMap?: Map<string | number, number>
rootLastIndex?: number
indent?: number
showLabelLine?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: undefined,
treeData: undefined,
rootKeyIndexMap: undefined,
rootLastIndex: undefined,
indent: 16,
showLabelLine: true,
})
const shouldRenderLines = computed(() => props.node.level > 1)
const lastNodeFlags = computed(() => {
if (!shouldRenderLines.value) return []
const flags: boolean[] = []
let currentNode: TreeNodeLike | null | undefined = props.node
while (currentNode) {
let parentNode = currentNode.parent ?? null
if (currentNode.level === 1 && !currentNode.parent) {
const currentKey = currentNode.key ?? currentNode.id
if (
(typeof currentKey === 'string' || typeof currentKey === 'number') &&
props.rootKeyIndexMap &&
typeof props.rootLastIndex === 'number'
) {
flags.unshift(props.rootKeyIndexMap.get(currentKey) === props.rootLastIndex)
break
}
if (!Array.isArray(props.treeData)) throw new Error('TreeNodeLine requires treeData when used with el-tree-v2')
const index = props.treeData.findIndex((item) => {
if (!item || typeof item !== 'object') return false
const record = item as Record<string, unknown>
return record.id === currentKey
})
flags.unshift(index === props.treeData.length - 1)
break
}
if (parentNode) {
const siblings = (parentNode.children || (parentNode as any).childNodes || []) as any[]
const currentKey = (currentNode.key ?? currentNode.id) as unknown
const index = siblings.findIndex((item) => (item?.key ?? item?.id) === currentKey)
flags.unshift(index === siblings.length - 1)
}
currentNode = parentNode as any
}
return flags
})
const verticalLines = computed(() => {
if (!shouldRenderLines.value) return []
const level = props.node.level
const lines: Array<{ index: number; left: string; isLastLeafLine: boolean }> = []
// 根节点不渲染垂直线
for (let i = 1; i < level; i++) {
if (lastNodeFlags.value[i] && level - 1 !== i) continue
lines.push({
index: i,
left: `${props.indent * i}px`,
isLastLeafLine: Boolean(lastNodeFlags.value[i] && level - 1 === i),
})
}
return lines
})
const horizontalStyle = computed(() => ({
width: `${props.node.isLeaf ? 24 : 8}px`,
left: `${(props.node.level - 1) * props.indent}px`,
}))
</script>
<style scoped lang="scss">
.element-tree-node-label-wrapper {
flex: 1;
width: 100%;
display: flex;
align-items: center;
}
.element-tree-node-label {
font-size: 12px;
}
.element-tree-node-line-ver {
display: block;
position: absolute;
top: 0;
left: 0;
height: 100%;
border-left: 1px solid #ababab;
}
.element-tree-node-line-ver.last-node-isLeaf-line {
height: 50%;
}
.element-tree-node-line-hor {
display: block;
position: absolute;
top: 50%;
left: 0;
height: 0;
border-bottom: 1px solid #ababab;
}
.element-tree-node-label-line {
flex: 1;
border-top: 1px solid #ababab;
align-self: center;
margin: 0 10px;
}
</style>

View File

@@ -0,0 +1 @@
export { default as LazyLoad } from './lazy-load.vue';

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
interface Props {
delay?: number;
}
const props = withDefaults(defineProps<Props>(), {
delay: 0,
});
const isVisible = ref(false);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
onMounted(() => {
//为了解决 Uncaught ResizeObserver loop completed with undelivered notifications.做了延时显示
if (props.delay > 0) {
timeoutId = setTimeout(() => {
isVisible.value = true;
}, props.delay);
} else {
isVisible.value = true;
}
});
onUnmounted(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
</script>
<template>
<div class="lazy-load-wrapper h-full w-full">
<slot v-if="isVisible" />
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -2,9 +2,9 @@ import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/database/entry/add',
component: () => import('#/views/database/entry/add.vue'),
name: 'EntryAdd',
path: '/database/info/add',
component: () => import('#/views/database/info/add/index.vue'),
name: 'InfoAdd',
meta: {
title: '信息价录入',
icon: 'ant-design:history-outlined',

View File

@@ -66,16 +66,16 @@ const chartTabs: TabOption[] = [
<template>
<div class="p-5">
<AnalysisOverview :items="overviewItems" />
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
<!-- <AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
<template #trends>
<AnalyticsTrends />
</template>
<template #visits>
<AnalyticsVisits />
</template>
</AnalysisChartsTabs>
</AnalysisChartsTabs> -->
<div class="mt-5 w-full md:flex">
<!-- <div class="mt-5 w-full md:flex">
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问数量">
<AnalyticsVisitsData />
</AnalysisChartCard>
@@ -85,6 +85,6 @@ const chartTabs: TabOption[] = [
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源">
<AnalyticsVisitsSales />
</AnalysisChartCard>
</div>
</div> -->
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { DbHst } from '#/components/db-hst';
import { DbTree } from '#/components/db-tree';
import { Page } from '@vben/common-ui';
import { ElCard } from 'element-plus';
import { onMounted, onUnmounted, ref } from 'vue';
import { useDbHst } from './use-hst';
import { useDbTree } from './use-tree';
const dbTreeRef = ref();
const hstRef = ref();
const { treeData, rootMenus, nodeMenus, handleTreeNodeEdit, handleTreeNodeSelect } = useDbTree(dbTreeRef);
const { hstSettings, hstContextMenuItems } = useDbHst(hstRef);
onMounted(() => {
console.log('组件已挂载');
});
onUnmounted(() => {
console.log('组件已卸载');
});
</script>
<template>
<Page auto-content-height>
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbTree
ref="dbTreeRef"
:tree-data="treeData"
:root-menus="rootMenus"
:node-menus="nodeMenus"
@node-edit="handleTreeNodeEdit"
@node-click="handleTreeNodeSelect"
/>
</ElCard>
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbHst ref="hstRef" :settings="hstSettings" :contextMenuItems="hstContextMenuItems"></DbHst>
</ElCard>
</Page>
</template>
<style lang="scss">
</style>

View File

@@ -0,0 +1,16 @@
import type { Ref } from 'vue';
export const useDbHst = (hstRef: Ref<any>) => {
const columns: any[] = [];
const hstSettings = {
columns: columns,
};
const hstContextMenuItems: any[] = [];
return {
hstSettings,
hstContextMenuItems,
};
};

View File

@@ -0,0 +1,26 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
export const rootMenus: any[] = [];
export const nodeMenus: any[] = [];
export const useDbTree = (dbTreeRef: Ref<any>) => {
const treeData = ref();
const handleTreeNodeEdit = (payload: any) => {
console.log('节点编辑保存:', payload);
};
const handleTreeNodeSelect = (payload: any) => {
console.log('节点点击:', payload);
};
return {
treeData,
rootMenus,
nodeMenus,
handleTreeNodeEdit,
handleTreeNodeSelect,
};
};

View File

@@ -1,182 +0,0 @@
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

@@ -1,638 +0,0 @@
<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

@@ -1,154 +0,0 @@
<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

@@ -1,85 +0,0 @@
<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

@@ -1,220 +0,0 @@
<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

@@ -1,322 +0,0 @@
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()
})
}
},
]

View File

@@ -1,704 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
import { Page } from '@vben/common-ui';
import { ElSplitter,ElSplitterPanel,ElCard,ElButton,ElDialog,ElUpload,ElImage,ElDatePicker,ElMessage,ElPopover, ElDatePickerPanel } 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 AttachmentDialog from './components/AttachmentDialog.vue'
import AddDialog from './components/AddDialog.vue'
import CopyDialog from './components/CopyDialog.vue'
import { useRouter } from 'vue-router';
const { push } = useRouter();
import { contextMenuHandler, createOperationRenderer, createDateRangeRenderer, createAttachmentRenderer, contextMenuItems } from './index'
import { validatorRow } from '#/components/db-hst/validator'
const containerRef = ref<HTMLElement | null>(null)
const { height: containerHeight } = useElementSize(containerRef)
type Tree = { id: string; label: string; children?: Tree[] }
// 专业选择
const selectedSpecialty = ref<string>('建筑工程')
// 不同专业的树形数据
const treeDataMap: Record<string, Tree[]> = {
'建筑工程': [
{
id: '1',
label: '建筑材料',
children: [
{
id: '1-1',
label: '广东省',
children: [
{ id: '1-1-1', label: '水泥' },
{ id: '1-1-2', label: '钢筋' },
{ id: '1-1-3', label: '混凝土' },
{ id: '1-1-4', label: '砖块' }
]
},
{
id: '1-2',
label: '广西省',
children: [
{ id: '1-2-1', label: '水泥' },
{ id: '1-2-2', label: '钢筋' },
{ id: '1-2-3', label: '混凝土' }
]
}
]
},
{
id: '2',
label: '建筑机械',
children: [
{
id: '2-1',
label: '广东省',
children: [
{ id: '2-1-1', label: '挖掘机' },
{ id: '2-1-2', label: '塔吊' },
{ id: '2-1-3', label: '混凝土泵车' }
]
}
]
}
],
'市政工程': [
{
id: '3',
label: '市政材料',
children: [
{
id: '3-1',
label: '广东省',
children: [
{ id: '3-1-1', label: '沥青' },
{ id: '3-1-2', label: '路缘石' },
{ id: '3-1-3', label: '井盖' },
{ id: '3-1-4', label: '管道' }
]
},
{
id: '3-2',
label: '湖南省',
children: [
{ id: '3-2-1', label: '沥青' },
{ id: '3-2-2', label: '路缘石' },
{ id: '3-2-3', label: '排水管' }
]
}
]
},
{
id: '4',
label: '市政设备',
children: [
{
id: '4-1',
label: '广东省',
children: [
{ id: '4-1-1', label: '摊铺机' },
{ id: '4-1-2', label: '压路机' },
{ id: '4-1-3', label: '铣刨机' }
]
}
]
}
],
'装饰装修': [
{
id: '5',
label: '装修材料',
children: [
{
id: '5-1',
label: '广东省',
children: [
{ id: '5-1-1', label: '瓷砖' },
{ id: '5-1-2', label: '地板' },
{ id: '5-1-3', label: '涂料' },
{ id: '5-1-4', label: '吊顶材料' }
]
},
{
id: '5-2',
label: '浙江省',
children: [
{ id: '5-2-1', label: '瓷砖' },
{ id: '5-2-2', label: '木地板' },
{ id: '5-2-3', label: '墙纸' }
]
}
]
},
{
id: '6',
label: '装修设备',
children: [
{
id: '6-1',
label: '广东省',
children: [
{ id: '6-1-1', label: '电钻' },
{ id: '6-1-2', label: '切割机' },
{ id: '6-1-3', label: '打磨机' }
]
}
]
}
],
'安装工程': [
{
id: '7',
label: '安装材料',
children: [
{
id: '7-1',
label: '广东省',
children: [
{ id: '7-1-1', label: '电线电缆' },
{ id: '7-1-2', label: '开关插座' },
{ id: '7-1-3', label: '配电箱' },
{ id: '7-1-4', label: '灯具' }
]
},
{
id: '7-2',
label: '江苏省',
children: [
{ id: '7-2-1', label: '电线电缆' },
{ id: '7-2-2', label: '配电设备' },
{ id: '7-2-3', label: '管道阀门' }
]
}
]
},
{
id: '8',
label: '安装设备',
children: [
{
id: '8-1',
label: '广东省',
children: [
{ id: '8-1-1', label: '电焊机' },
{ id: '8-1-2', label: '切管机' },
{ id: '8-1-3', label: '套丝机' }
]
}
]
}
]
}
const categoryTreeData = ref<Tree[]>(treeDataMap['建筑工程'])
// 专业选择变化处理
const handleSpecialtyChange = (value: string) => {
categoryTreeData.value = treeDataMap[value] || []
console.log('专业切换:', value)
}
// 附件管理相关
const attachmentDialogVisible = ref(false)
const attachmentTableData = ref<any[]>([
{
id: 1,
name: '广东省信息价',
priceDate: '2024-01',
attachment: '广东省信息价_2024-01.pdf',
attachmentUrl: ''
},
{
id: 2,
name: '广东省信息价',
priceDate: '2024-02',
attachment: '广东省信息价_2024-02.pdf',
attachmentUrl: ''
},
{
id: 3,
name: '广西省信息价',
priceDate: '2024-01',
attachment: '广西省信息价_2024-01.pdf',
attachmentUrl: ''
}
])
// 新增对话框相关
const addDialogVisible = ref(false)
// 复制(软件商)对话框相关
const copyDialogVisible = ref(false)
const copyTableData = ref<any[]>([
{
id: 1,
serialNumber: '001',
tenantName: '租户A',
specialty: '建筑工程',
region: '广东省',
priceDate: '2024-01',
priceName: '广东省信息价',
priceCount: 150,
attachment: '附件1.pdf',
published: true
},
{
id: 2,
serialNumber: '002',
tenantName: '租户B',
specialty: '市政工程',
region: '广西省',
priceDate: '2024-02',
priceName: '广西省信息价',
priceCount: 200,
attachment: '附件2.pdf',
published: false
},
{
id: 3,
serialNumber: '003',
tenantName: '租户C',
specialty: '装饰装修',
region: '广东省',
priceDate: '2024-03',
priceName: '广东省装修信息价',
priceCount: 180,
attachment: '',
published: true
}
])
// Popover 相关
const popoverDateRangeRef = ref()
const visibleDateRange = ref(false)
const popoverDate = ref<[Date, Date]>()
const currentEditingRow = ref()
// 点击外部关闭 popover
const handleClickOutside = (e: MouseEvent) => {
if (visibleDateRange.value) {
const target = e.target as HTMLElement
// 检查点击是否在 popover 或触发元素内
const popoverEl = document.querySelector('.el-popover')
const isClickInside = popoverEl?.contains(target) ||
target.closest('.el-popover') ||
target.closest('svg') // 日历图标
if (!isClickInside) {
visibleDateRange.value = false
}
}
}
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 中的数据
hstRef.value.hotInstance.setDataAtRowProp(currentEditingRow.value, 'code', dateRangeStr)
console.log(`已更新第 ${currentEditingRow.value} 行的价格时间段为: ${dateRangeStr}`)
}
}, 200);
}
})
}
// 价格时间段图标点击回调
const handleDateIconClick = (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 dateRangeRenderer = createDateRangeRenderer({
onDateIconClick: handleDateIconClick
})
// 附件下载回调函数
const handleDownload = (row: number, data: any) => {
console.log('下载附件:', data)
// 获取附件文件名
const fileName = data.attachment
if (!fileName) {
ElMessage.warning('没有可下载的附件')
return
}
// TODO: 实际项目中应该从后端获取文件 URL
// 这里模拟下载
ElMessage.success(`正在下载: ${fileName}`)
// 示例:如果有实际的文件 URL可以这样下载
// const link = document.createElement('a')
// link.href = data.attachmentUrl || `/api/download/${fileName}`
// link.download = fileName
// link.click()
}
// 创建附件下载渲染器
const attachmentRenderer = createAttachmentRenderer({
onDownload: handleDownload
})
// 操作列回调函数
const handleUpload = (row: number, data: any) => {
console.log('附件上传:', data)
ElMessage.info(`附件上传: ${data.name}`)
// TODO: 打开上传对话框
}
const handleComplete = (row: number, data: any) => {
console.log('完成:', data)
ElMessage.success(`完成: ${data.name}`)
// TODO: 调用完成接口
}
const handlePublish = (row: number, data: any) => {
console.log('发布:', data)
ElMessage.warning(`发布: ${data.name}`)
// TODO: 调用发布接口
}
const handleView = (row: number, data: any) => {
console.log('查看:', data)
ElMessage.info(`查看: ${data.name}`)
// TODO: 打开查看对话框
push({
name: 'EntryAdd',
query: data?.number ? { id: data.number } : {},
});
}
// 创建操作列渲染器
const operationRenderer = createOperationRenderer({
onUpload: handleUpload,
onComplete: handleComplete,
onPublish: handlePublish,
onView: handleView
})
const columns = ref<any[]>([
{type:'text',data:'number',title:'序号',width: 40},
//TODO 这里需要两个参数,一个显示一个赋值
{type:'text',data:'code',title:'价格时间段',width: 200, renderer: dateRangeRenderer, readOnly: true},
{type:'text',data:'name',title:'信息价名称',width: 150, allowInvalid: true},
{type:'text',data:'model',title:'地区', allowInvalid: true},
{type:'text',data:'time',title:'发布时间', allowInvalid: true},
{type:'text',data:'attachment',title:'附件下载',width: 100, renderer: attachmentRenderer, readOnly: true},
{type:'text',data:'operation',title:'操作',width: 200, renderer: operationRenderer, readOnly: true},
])
// const colHeaders = ref<string[]>(topColHeaders)
const hstRef = ref<any>(null)
const mockData = ()=>{
// 根据columns生成模拟数据
const regions = ['广东省', '广西省', '湖南省', '浙江省', '江苏省']
const priceNames = ['建筑工程信息价', '市政工程信息价', '装饰装修信息价', '安装工程信息价']
const mockData = Array.from({ length: 30 }, (_, index) => {
const month = (index % 12) + 1
const year = `20${String(month).padStart(2, '0')}`
const priceDate = `${year}-${String(month).padStart(2, '0')}`
const region = regions[index % regions.length]
const priceName = priceNames[index % priceNames.length]
return {
number: index + 1,
code: priceDate,
name: `${region}${priceName}`,
model: region,
time: `${priceDate}-${String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')}`,
attachment: `${region}${priceName}_${priceDate}.pdf`,
}
})
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('handleSaveResourceItem-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('handleSaveResourceItem-update', sendData)
// 实现更新接口调用
// updateResourceItems(sendData).then(res => {
// console.log('updateResourceItems', res)
// }).catch(err => {
// console.error('更新失败', err)
// // ElMessage.error('更新失败')
// })
}
}
}
const searchText = ref('')
const categoryHandleSelect = (node: Tree) => {
console.log('categoryhandleSelect',node)
}
const handleSearch = () => {
console.log('handleSearch')
hstRef.value.loadData(mockData())
hstRef.value.hotInstance.render()
}
const handleAttachment = () => {
attachmentDialogVisible.value = true
}
const handleAdd = () => {
addDialogVisible.value = true
}
const handleCopy = () => {
copyDialogVisible.value = true
}
function onHstHeight(height: number){
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.updateSettings({
height: height-35
})
hstRef.value.hotInstance.render()
console.log('onResizeEnd-onHstHeight',height);
}
}
onMounted(() => {
setTimeout(() => {
onHstHeight(containerHeight.value)
}, 200);
// 添加全局点击事件监听
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
// 移除全局点击事件监听
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<Page auto-content-height>
<ElSplitter >
<ElSplitterPanel collapsible size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef">
<el-form label-position="left" label-width="auto" style="padding: 0px 5px;">
<el-form-item label="专业" style="margin-bottom: 0px;">
<el-select
v-model="selectedSpecialty"
placeholder="请选择专业"
size="small"
@change="handleSpecialtyChange"
style="width: 100%;"
>
<el-option label="建筑工程" value="建筑工程" />
<el-option label="市政工程" value="市政工程" />
<el-option label="装饰装修" value="装饰装修" />
<el-option label="安装工程" value="安装工程" />
</el-select>
</el-form-item>
</el-form>
<DbTree :height="containerHeight" :data="categoryTreeData" @select="categoryHandleSelect"
:defaultExpandedKeys="2" :search="false" :context-menu-handler="contextMenuHandler"/>
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel collapsible :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full">
<template #header>
<div class="flex items-center justify-between">
<div class="flex gap-2">
<ElDatePicker
v-model="searchText"
type="daterange"
start-placeholder="开始时间"
end-placeholder="结束时间"
size="small"
style="width: 300px"
/>
<ElButton @click="handleSearch" type="primary" size="small">查询</ElButton>
</div>
<div class="flex gap-2">
<ElButton @click="handleAttachment" type="primary" size="small">附件</ElButton>
<ElButton @click="handleAdd" type="primary" size="small">新增</ElButton>
<ElButton @click="handleCopy" type="primary" size="small">复制软件商按钮</ElButton>
<!-- <ElButton @click="handleHistory" type="primary" size="small">调用历史信息</ElButton> -->
</div>
</div>
</template>
<DbHst ref="hstRef" :settings="dbSettings" :contextMenuItems="contextMenuItems"></DbHst>
</ElCard>
</ElSplitterPanel>
</ElSplitter>
<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>
<!-- 附件管理对话框 -->
<AttachmentDialog
v-model:visible="attachmentDialogVisible"
v-model:data="attachmentTableData"
/>
<!-- 新增对话框 -->
<AddDialog
v-model:visible="addDialogVisible"
v-model:data="attachmentTableData"
/>
<!-- 复制软件商对话框 -->
<CopyDialog
v-model:visible="copyDialogVisible"
v-model:data="copyTableData"
/>
</Page>
</template>
<style lang="css">
.el-card__header{
padding: 5px;
}
.upload-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.image-preview {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background-color: #f5f7fa;
}
.image-preview img {
max-width: 100%;
max-height: 300px;
object-fit: contain;
}
.upload-demo {
width: 100%;
}
.preview-image-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.more-price-container {
padding: 10px 0;
}
</style>

View File

@@ -0,0 +1,35 @@
import type { Ref } from 'vue';
const columns = [
{ type: 'text', data: 'code', title: '特征编码' },
{ type: 'text', data: 'name', title: '名称' },
{ type: 'text', data: 'spec', title: '代码' },
];
export const useBottomHst = (bottomHstRef: Ref<any>) => {
const bottomHstSettings = {
data: [],
colWidths: 120,
columns: columns,
rowHeaders: false,
nestedRows: true,
bindRowsWithHeaders: true,
contextMenu: {
items: {
row_above: {},
row_below: {},
separator1: '---------',
add_child: {},
separator2: '---------',
remove_row: {},
}
},
};
const bottomHstContextMenuItems: any[] = [];
return {
bottomHstSettings,
bottomHstContextMenuItems,
};
};

View File

@@ -0,0 +1,57 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
export const rootMenus: any[] = [];
export const nodeMenus: any[] = [];
export const useDbTree = (dbTreeRef: Ref<any>) => {
const treeData = 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 handleTreeNodeEdit = (payload: any) => {
console.log('节点编辑保存:', payload);
};
const handleTreeNodeSelect = (payload: any) => {
console.log('节点点击:', payload);
};
return {
treeData,
rootMenus,
nodeMenus,
handleTreeNodeEdit,
handleTreeNodeSelect,
};
};

View File

@@ -1,268 +1,59 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch,computed } from 'vue'
import { Page } from '@vben/common-ui';
import { ElSplitter,ElSplitterPanel,ElCard } from 'element-plus';
import { useElementSize } from '@vueuse/core'
import { DbTree } from '#/components/db-tree';
import { DbHst } from '#/components/db-hst';
import { DbTree } from '#/components/db-tree';
import { Page } from '@vben/common-ui';
import { ElCard, ElSplitter, ElSplitterPanel } from 'element-plus';
import { onMounted, onUnmounted, ref } from 'vue';
import { useBottomHst } from './bottomHst';
import { useDbTree } from './dbTree';
import { useTopHst } from './topHst';
import { handleRowOperation, codeRenderer } from '#/components/db-hst/tree'
// import { sourceDataObject } from '#/components/db-hst/mockData'
const containerRef = ref<HTMLElement | null>(null)
const { height: containerHeight } = useElementSize(containerRef)
const topContainerRef = ref<HTMLElement | null>(null)
const { height: topContainerHeight } = useElementSize(topContainerRef)
const bottomContainerRef = ref<HTMLElement | null>(null)
const { height: bottomContainerHeight } = useElementSize(bottomContainerRef)
const dbTreeRef = ref();
const topHstRef = ref();
const bottomHstRef = ref();
type Tree = { id: string; label: string; children?: Tree[] }
const categoryTreeData = 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 topColumns = ref<any[]>([
{type:'text',data:'code',title:'编码', renderer: codeRenderer, code:true},
{type:'text',data:'name',title:'名称'},
{type:'text',data:'unit',title:'单位'},
])
const { treeData, rootMenus, nodeMenus, handleTreeNodeEdit, handleTreeNodeSelect } = useDbTree(dbTreeRef);
const bottomColumns = ref<any[]>([
{type:'text',data:'code',title:'特征编码'},
{type:'text',data:'name',title:'名称'},
{type:'text',data:'spec',title:'代码'},
])
// const colHeaders = ref<string[]>(topColHeaders)
const topHstRef = ref<any>(null)
const bottomHstRef = ref<any>(null)
let rowSchema: any = {level: null,__children: []}
// 根据 columns 的 data 字段生成对象结构
topColumns.value.forEach((col: any) => {
if (col.data && col.data !== 'level' && col.data !== '__children') {
rowSchema[col.data] = null
}
})
// console.log(rowSchema)
const topMock = ()=>{
// 生成带层级结构的模拟数据
const categories = ['人', '人机', '材', '机']
const units = ['台', '个', 't', 'm³', 'm²', 'kg']
const mockData = []
let codeCounter = 1
// 生成5个父级项目
for (let i = 0; i < 5; i++) {
const parentCode = `MAC${String(codeCounter++).padStart(6, '0')}`
const parent = {
code: parentCode,
name: `机械设备分类${i + 1}`,
unit: units[i % units.length],
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: units[(i + j) % units.length],
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: units[(i + j + k) % units.length],
level: `${i}-${j + 1}-${k + 1}`,
__children: []
}
child.__children.push(grandChild)
}
}
parent.__children.push(child)
}
mockData.push(parent)
}
return mockData
}
let topDbSettings = {
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}`,
}))
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)
}
}
// watch(
// () => topContainerHeight.value,
// (val) => {
// console.log('topContainerHeight', val);
// },
// { immediate: true },
// )
function onTopHeight(height: number){
if (topHstRef.value?.hotInstance) {
topHstRef.value.hotInstance.updateSettings({
height: height-15,
})
// 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-15
})
bottomHstRef.value.loadData(bootomMock())
bottomHstRef.value.hotInstance.render()
console.log('onResizeEnd-bottomHstRef',height);
}
}
const { topHstSettings, topHstContextMenuItems } = useTopHst(topHstRef);
const { bottomHstSettings, bottomHstContextMenuItems } = useBottomHst(bottomHstRef);
onMounted(() => {
setTimeout(() => {
onTopHeight(topContainerHeight.value)
onBottomHeight(bottomContainerHeight.value)
}, 200);
})
onUnmounted(() => {
})
console.log('组件已挂载');
});
onUnmounted(() => {
console.log('组件已卸载');
});
</script>
<template>
<Page auto-content-height>
<ElSplitter >
<Page auto-content-height>
<ElSplitter>
<ElSplitterPanel collapsible size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full" ref="containerRef">
<DbTree :height="containerHeight" :data="categoryTreeData" @select="categoryHandleSelect" :defaultExpandedKeys="2" :search="false" />
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbTree
ref="dbTreeRef"
:tree-data="treeData"
:root-menus="rootMenus"
:node-menus="nodeMenus"
@node-edit="handleTreeNodeEdit"
@node-click="handleTreeNodeSelect"
/>
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel collapsible :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full">
<DbHst ref="topHstRef" :settings="topDbSettings"></DbHst>
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbHst ref="topHstRef" :settings="topHstSettings" :contextMenuItems="topHstContextMenuItems" />
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel collapsible :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full">
<DbHst ref="bottomHstRef" :settings="bottomDbSettings"></DbHst>
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbHst ref="bottomHstRef" :settings="bottomHstSettings" :contextMenuItems="bottomHstContextMenuItems" />
</ElCard>
</ElSplitterPanel>
</ElSplitter>
</Page>
</Page>
</template>
<style lang="css">
</style>
<style lang="css"></style>

View File

@@ -0,0 +1,35 @@
import type { Ref } from 'vue';
const columns = [
{ type: 'text', data: 'code', title: '编码' },
{ type: 'text', data: 'name', title: '名称' },
{ type: 'text', data: 'unit', title: '单位' },
];
export const useTopHst = (topHstRef: Ref<any>) => {
const topHstSettings = {
data: [],
colWidths: 120,
columns: columns,
rowHeaders: false,
nestedRows: true,
bindRowsWithHeaders: true,
contextMenu: {
items: {
row_above: {},
row_below: {},
separator1: '---------',
add_child: {},
separator2: '---------',
remove_row: {},
}
},
};
const topHstContextMenuItems: any[] = [];
return {
topHstSettings,
topHstContextMenuItems,
};
};

View File

@@ -0,0 +1,338 @@
import { nextTick } from 'vue'
import Handsontable from 'handsontable'
import { ElMessage } from 'element-plus'
import { createCategoryTreeNode, deleteCategoryTreeNode, type InfoPriceApi } from '#/api/database/info'
import { useAccessStore } from '@vben/stores';
const accessStore = useAccessStore();
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 t="1772077707702" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4821" width="20" height="20"><path d="M544.768 856.064V659.456h131.072L512 462.848 348.16 659.456h131.072v196.608H315.392v-2.048l-16.384 2.048c-69.632-2.048-127.488-26.112-173.568-72.192S55.296 679.936 53.248 610.304c1.364992-64.171008 22.356992-118.612992 62.976-163.328S208.555008 375.808 271.36 367.616c12.288-59.392 40.448-107.179008 84.48-143.36s96.084992-54.955008 156.16-56.32c60.075008 1.364992 112.128 20.139008 156.16 56.32S740.352 308.224 752.64 367.616c62.804992 8.192 114.516992 34.644992 155.136 79.36S969.387008 546.132992 970.752 610.304c-2.048 69.632-26.112 127.488-72.192 173.568S794.624 854.016 724.992 856.064l-16.384-2.048v2.048H544.768z" p-id="4822" fill="#a8abb2"></path></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);
// const rowData = instance.getSourceDataAtRow(row)
//TODO 暂时性 需要修改
if(accessStore.tenantId == 1) 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 = (zIndex: string, 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.zIndex = zIndex.toString()
// TD.style.textAlign = 'center'
const existingIcon = TD.querySelector('ht-id-icon') as HTMLElement | null
if (existingIcon) existingIcon.remove()
const disabled = (cellProperties as { disabled?: boolean }).disabled
if (disabled!= null && disabled) {
cellProperties.editor = false
return TD
}
const iconButton = document.createElement('div')
iconButton.className = 'ht-id-icon'
iconButton.style.zIndex = zIndex.toString()
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 createRootMenus = (getBookId: () => any, isDisabled: () => boolean) => [
{
key: 'add-root',
label: '添加目录',
disabled: isDisabled,
callback: async (command: string, node: any, data: any, treeInstance: any) => {
const source = {
bookId: getBookId(),
code: 'root-'+new Date().getTime(),
name: '目录',
nodeType: 0,
sortOrder: treeInstance.getData().length + 1
}
const id = await createCategoryTreeNode(source)
treeInstance.addData({
id: id,
label: source.name,
children: [],
...source
})
},
},
]
const MAX_TREE_LEVEL = 8
const createNodeSource = (
bookId: any,
parentId: string | number,
nodeType: number,
sortOrder: number,
codePrefix: string
) => ({
bookId,
parentId,
code: `${codePrefix}-`+new Date().getTime(),
name: '目录',
nodeType,
sortOrder,
})
export const createNodeMenus = (getBookId: () => any, isDisabled: () => boolean, maxLevel: number = MAX_TREE_LEVEL) => {
const menus = []
for (let level = 1; level <= maxLevel; level++) {
const currentLevel = level
const canAddChild = currentLevel < maxLevel
const canAddSibling = currentLevel > 1
const items: any[] = []
if (canAddChild) {
items.push({
key: `add-child-level-${currentLevel}`,
label: '添加子目录',
disabled: isDisabled,
callback: async (command: string, node: any, data: any, treeInstance: any) => {
const source = createNodeSource(
getBookId(),
data.id,
1,
(data.children?.length || 0) + 1,
'category'
)
const id = await createCategoryTreeNode(source)
treeInstance.addData({
id: String(id),
label: source.name,
children: [],
...source
})
},
})
}
if (canAddSibling) {
items.push(
{
key: `add-sibling-above-level-${currentLevel}`,
label: '上方添加平级目录',
disabled: isDisabled,
callback: async (command: string, node: any, data: any, treeInstance: any) => {
const parentChildren = node.parent?.children || []
const insertIndex = parentChildren.findIndex((item: any) => item.data.id === data.id)
const parentId = node.parent?.data?.id || null
const source = createNodeSource(
getBookId(),
parentId,
1,
insertIndex === -1 ? 0 : insertIndex,
'category'
)
const id = await createCategoryTreeNode(source)
treeInstance.insertAbove({
id: String(id),
label: source.name,
children: [],
...source
})
},
},
{
key: `add-sibling-below-level-${currentLevel}`,
label: '下方添加平级目录',
disabled: isDisabled,
callback: async (command: string, node: any, data: any, treeInstance: any) => {
const parentChildren = node.parent?.children || []
const insertIndex = parentChildren.findIndex((item: any) => item.data.id === data.id)
const parentId = node.parent?.data?.id || null
const source = createNodeSource(
getBookId(),
parentId,
1,
insertIndex === -1 ? 0 : insertIndex + 1,
'category'
)
const id = await createCategoryTreeNode(source)
treeInstance.insertBelow({
id: String(id),
label: source.name,
children: [],
...source
})
},
}
)
}
items.push({
key: `remove-level-${currentLevel}`,
label: '删除',
divided: true,
disabled: isDisabled,
callback: async (command: string, node: any, data: any, treeInstance: any) => {
await deleteCategoryTreeNode(data.id)
treeInstance.removeData()
ElMessage.success('删除成功')
},
})
menus.push({
level: currentLevel,
items,
})
}
return menus
}
export const createContextMenuItems = (getCategoryTreeId: () => any, isDisabled?: () => boolean) => [
{
key: 'row_above',
name: '新增行',
disabled: isDisabled,
callback: (hotInstance: any) => {
const categoryTreeId = getCategoryTreeId()
if (!categoryTreeId) {
ElMessage.error('请先选择分类节点')
return
}
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]

View File

@@ -0,0 +1,229 @@
import type { Ref } from 'vue';
import { computed } from 'vue';
import { createResource, deleteResource, getResourcePage, updateResource } from '#/api/database/info';
import { getCatalogItemsListLevel } from '#/api/database/materials';
import { validatorRow } from '#/components/db-hst/validator';
import { confirm } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { useAccessStore } from '@vben/stores';
import { ElMessage } from 'element-plus';
import { createContextMenuItems, createIdCellRenderer, createUploadPhotoRender } from './add';
type UploadCallbacks = {
onPhotoPreview: (value: string) => void;
onPhotoUpload: (row: number, col: number, value: string) => void;
onShowMorePrice: (row: number, col: number, value: any) => void;
};
export const useDbHst = (
hstRef: Ref<any>,
bookId: Ref<any>,
currentCategoryTreeId: Ref<any>,
isCcompleted: Ref<boolean>,
callbacks: UploadCallbacks
) => {
const accessStore = useAccessStore();
const contextMenuItems = computed(() =>
createContextMenuItems(
() => currentCategoryTreeId.value,
() => accessStore.tenantId != 1
)
);
const columns = [
{ type: 'text', data: 'number', title: '序号', width: 40, edit: false, className: 'htCenter' },
{ type: 'text', data: 'code', title: '编码', required: true },
{ type: 'text', data: 'name', title: '名称', required: true },
{ type: 'text', data: 'spec', title: '型号规格', required: false },
{
type: 'text',
data: 'unit',
title: '单位',
renderer: 'db-dropdown',
editor: false,
width: 80,
className: 'htCenter',
required: true,
source: getDictOptions(DICT_TYPE.MATERIAL_UNIT, 'string'),
disabled: accessStore.tenantId != 1,
},
{
type: 'numeric',
data: 'priceTaxExcl',
title: '除税编制价',
required: true,
disabled: isCcompleted.value,
renderer: createIdCellRenderer('4', { onShowMorePrice: callbacks.onShowMorePrice }),
className: 'htCenter',
},
{
type: 'numeric',
data: 'taxRate',
title: '税率%',
width: 60,
required: true,
disabled: isCcompleted.value,
renderer: createIdCellRenderer('3', { onShowMorePrice: callbacks.onShowMorePrice }),
className: 'htCenter',
},
{
type: 'numeric',
data: 'priceTaxIncl',
title: '含税编制价',
required: true,
disabled: isCcompleted.value,
renderer: createIdCellRenderer('2', { onShowMorePrice: callbacks.onShowMorePrice }),
className: 'htCenter',
},
{
type: 'text',
data: 'drawingUrl',
title: '图样',
edit: true,
renderer: createUploadPhotoRender({
onPreview: callbacks.onPhotoPreview,
onUpload: callbacks.onPhotoUpload,
}),
},
{
type: 'text',
data: 'categoryId',
title: '分类',
renderer: 'db-dropdown',
className: 'htCenter',
editor: false,
source: [],
disabled: accessStore.tenantId != 1,
},
{ type: 'text', data: 'remark', title: '备注' },
];
const loadResourcePage = async () => {
const res = await getResourcePage({
pageNo: 1,
pageSize: 100,
categoryTreeId: currentCategoryTreeId.value,
});
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.loadData(
(res.list || []).map((item, index) => ({
...item,
number: index + 1,
}))
);
}
};
const loadCategorySource = async () => {
try {
const res = await getCatalogItemsListLevel(2);
if (res && Array.isArray(res)) {
return res.map((item: any) => ({
label: item.name,
value: item.id,
}));
}
} catch (error) {
console.error('加载分类数据失败:', error);
}
return [];
};
const updateCategoryColumnSource = (categorySource: { label: string; value: any }[]) => {
if (hstRef.value?.hotInstance) {
const categoryColIndex = hstRef.value.hotInstance.propToCol('categoryId');
const currentColumns = hstRef.value.hotInstance.getSettings().columns;
if (currentColumns && currentColumns[categoryColIndex]) {
currentColumns[categoryColIndex].source = categorySource;
hstRef.value.hotInstance.updateSettings({
columns: currentColumns,
});
}
}
};
const dbSettings = {
data: [],
columns: columns,
contextMenu: {
items: {
remove_row: {
name: '移除行',
callback: async function (this: any, key: string, selection: any[]) {
const selectedRow = selection[0].start.row;
const rowData = this.getSourceDataAtRow(selectedRow);
if (rowData?.id) {
if (isCcompleted.value) {
ElMessage.info('已完成不能删除');
return;
}
await confirm('确定删除吗?')
.then(async () => {
await deleteResource(rowData.id);
this.alter('remove_row', selectedRow, 1);
})
.catch(() => {});
} else {
this.alter('remove_row', selectedRow, 1);
}
},
},
},
},
afterChange(changes: any, source: string) {
console.log('afterChange-source', source, changes);
if (!changes || source === 'loadData' || source === 'updateId') return;
if (!validatorRow(this, changes, columns)) {
return;
}
const row = changes[0][0];
const rowData = this.getSourceDataAtRow(row);
const sendData: any = {
...rowData,
categoryTreeId: currentCategoryTreeId.value,
};
if (rowData.id == null) {
console.log('moreDbSettings-create', sendData);
createResource(sendData)
.then(res => {
console.log('createResource', res);
this.setDataAtRowProp(row, 'id', res, 'updateId');
ElMessage.success('新增成功');
loadResourcePage();
})
.catch(err => {
console.error('新增失败', err);
ElMessage.error('新增失败');
});
} else {
console.log('moreDbSettings-update', sendData);
updateResource(sendData)
.then(res => {
console.log('updateResource', res);
ElMessage.success('更新成功');
})
.catch(err => {
console.error('更新失败', err);
ElMessage.error('更新失败');
});
}
},
};
return {
columns,
contextMenuItems,
dbSettings,
loadResourcePage,
loadCategorySource,
updateCategoryColumnSource,
};
};

View File

@@ -0,0 +1,91 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
import { getCategoryTree, updateCategoryTreeNode } from '#/api/database/info';
import type { InfoPriceApi } from '#/api/database/info';
import { ElMessage } from 'element-plus';
import { createNodeMenus, createRootMenus } from './add';
type Tree = { id: string; label: string; children?: Tree[] };
export const useDbTree = (
dbTreeRef: Ref<any>,
bookId: Ref<any>,
isCcompleted: Ref<boolean>,
currentCategoryTreeId: Ref<any>,
loadResourcePage: () => Promise<void>
) => {
const treeData = ref<Tree[]>([]);
const _rootMenus = createRootMenus(() => bookId.value, () => isCcompleted.value);
const nodeMenus = createNodeMenus(() => bookId.value, () => isCcompleted.value);
const rootMenus = ref(treeData.value.length > 0 ? [] : _rootMenus);
function transformTreeData(data: any[]): Tree[] {
return data.map(item => ({
...item,
label: item.name,
children: item.children ? transformTreeData(item.children) : []
}));
}
const loadCategoryTree = async () => {
if (!bookId.value) return;
try {
const res = await getCategoryTree(bookId.value);
treeData.value = transformTreeData(res || []);
rootMenus.value = treeData.value.length > 0 ? [] : _rootMenus;
} catch (error) {
console.error('加载分类树失败:', error);
ElMessage.error('加载分类树失败');
} finally {
}
};
const handleTreeNodeSelect = async (payload: { node: any; data: any; event: MouseEvent }) => {
const { node } = payload;
if (!node.data.id) {
currentCategoryTreeId.value = null;
return;
}
currentCategoryTreeId.value = node.data.id;
try {
await loadResourcePage();
} catch (error) {
console.error('加载工料机数据失败:', error);
ElMessage.error('加载数据失败');
}
};
const handleTreeNodeEdit = async (payload: { node: any; data: any; oldLabel: string; newLabel: string }) => {
const { node, oldLabel, newLabel } = payload;
console.log('节点编辑保存:', { node, oldLabel, newLabel });
try {
const data: InfoPriceApi.CategoryTreeNode = {
id: node.data.id,
bookId: bookId.value,
parentId: node.data.parentId,
code: node.data.code || '',
nodeType: node.data.nodeType,
name: newLabel,
sortOrder: node.data.sortOrder
};
await updateCategoryTreeNode(data);
ElMessage.success('更新成功');
} catch (error) {
console.error('更新失败:', error);
ElMessage.error('更新失败');
}
};
return {
treeData,
rootMenus,
nodeMenus,
loadCategoryTree,
handleTreeNodeSelect,
handleTreeNodeEdit,
};
};

View File

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

View File

@@ -0,0 +1,345 @@
<script setup lang="ts">
import { createResourceBatch, getInfoPriceBook } from '#/api/database/info';
import { DbHst } from '#/components/db-hst';
import { DbTree } from '#/components/db-tree';
import { useUpload } from '#/components/upload/use-upload';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { useAccessStore } from '@vben/stores';
import { useElementSize } from '@vueuse/core';
import type { UploadProps } from 'element-plus';
import { ElButton, ElCard, ElDialog, ElImage, ElInput, ElMessage, ElSplitter, ElSplitterPanel, ElText, ElUpload } from 'element-plus';
import { computed, nextTick, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { HistoryDialog } from '../components/history-dialog';
import { MorePriceDialog } from '../components/more-price-dialog';
import { useDbHst } from './dbHst';
import { useDbTree } from './dbTree';
const route = useRoute();
const accessStore = useAccessStore();
const { uploadUrl, httpRequest } = useUpload('info-price');
const bookId = ref<any>(null);
const currentCategoryTreeId = ref<any>(null);
const bookTitle = ref<string>('');
const bookInfo = ref<any>(null);
const containerRef = ref<HTMLElement | null>(null);
const { height: containerHeight } = useElementSize(containerRef);
const isCcompleted = computed(() => accessStore.tenantId != 1 && bookInfo.value?.publishStatus === 'completed');
const dbTreeRef = ref();
const hstRef = ref();
const previewImageUrl = ref<string>('');
const previewVisible = ref(false);
const uploadDialogVisible = ref(false);
const currentUploadRow = ref<number>(0);
const currentUploadCol = ref<number>(0);
const uploadImageUrl = ref<string>('');
const uploading = ref(false);
const morePriceDialogVisible = ref(false);
const currentPriceRow = ref<number>(0);
const selectRowPrice = ref<any>(null);
const historyDialogVisible = ref(false);
const categorySource = ref<{ label: string; value: any }[]>([]);
const search = ref();
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 handleShowMorePrice = (row: number, col: number, value: any) => {
currentPriceRow.value = row;
morePriceDialogVisible.value = true;
selectRowPrice.value = hstRef.value.hotInstance.getSourceDataAtRow(row);
console.log('查看更多单价:', { row, col, value }, selectRowPrice.value);
};
const callbacks = {
onPhotoPreview: handlePhotoPreview,
onPhotoUpload: handlePhotoUpload,
onShowMorePrice: handleShowMorePrice,
};
const {
columns,
contextMenuItems,
dbSettings,
loadResourcePage,
loadCategorySource,
updateCategoryColumnSource,
} = useDbHst(hstRef, bookId, currentCategoryTreeId, isCcompleted, callbacks);
const {
treeData,
rootMenus,
nodeMenus,
loadCategoryTree,
handleTreeNodeSelect,
handleTreeNodeEdit,
} = useDbTree(dbTreeRef, bookId, isCcompleted, currentCategoryTreeId, loadResourcePage);
const loadBookInfo = async () => {
if (!bookId.value) return;
try {
const res = await getInfoPriceBook(bookId.value);
if (res) {
bookInfo.value = res;
bookTitle.value = `${res.name} (${res.startTime} ~ ${res.endTime})`;
}
} catch (error) {
console.error('加载信息价册失败:', error);
}
};
const handleSelectPrice = async (row: any) => {
if (hstRef.value?.hotInstance && selectRowPrice.value) {
const sendData = {
...selectRowPrice.value,
priceTaxExcl: row.priceTaxExcl,
taxRate: row.taxRate,
priceTaxIncl: row.priceTaxIncl,
};
try {
await createResourceBatch([sendData]);
ElMessage.success('更新成功');
loadResourcePage();
} catch (error) {
console.error('更新失败:', error);
ElMessage.error('更新失败');
}
}
};
const handleHistoryConfirm = async (nodes: any[]) => {
if (!nodes || nodes.length === 0) {
ElMessage.warning('请选择要导入的数据');
return;
}
const updatedNodes = nodes.map(node => ({
...node,
categoryTreeId: currentCategoryTreeId.value,
resourceItemId: node.id,
id: null,
}));
const res = await createResourceBatch(updatedNodes);
if (hstRef.value?.hotInstance && res) {
loadResourcePage();
ElMessage.success(`成功导入 ${updatedNodes.length} 条数据`);
}
};
const handleBeforeUpload: UploadProps['beforeUpload'] = rawFile => {
if (!rawFile.type.startsWith('image/')) {
ElMessage.error('只能上传图片文件!');
return false;
}
if (rawFile.size / 1024 / 1024 > 5) {
ElMessage.error('图片大小不能超过 5MB!');
return false;
}
return true;
};
const handleHttpRequest = (options: any) => {
uploading.value = true;
return httpRequest(options.file);
};
const handleUploadSuccess: UploadProps['onSuccess'] = (response: any) => {
uploading.value = false;
const imageUrl = response?.url || response;
uploadImageUrl.value = imageUrl;
if (hstRef.value?.hotInstance) {
hstRef.value.hotInstance.setDataAtCell(currentUploadRow.value, currentUploadCol.value, imageUrl);
}
ElMessage.success('上传成功');
handleDialogClose();
};
const handleUploadError: UploadProps['onError'] = () => {
uploading.value = false;
ElMessage.error('上传失败,请重试');
};
const handleUploadChange: UploadProps['onChange'] = uploadFile => {
if (uploadFile.raw) {
uploadImageUrl.value = URL.createObjectURL(uploadFile.raw);
}
};
const handleDialogClose = () => {
uploadDialogVisible.value = false;
};
const handlePreviewClose = () => {
previewVisible.value = false;
previewImageUrl.value = '';
};
const handleQuery = () => {
console.log('handleQuery');
};
const handleOpenHistoryDialog = () => {
if (!currentCategoryTreeId.value) {
ElMessage.warning('请先选择左侧分类节点');
return;
}
historyDialogVisible.value = true;
};
onMounted(async () => {
bookId.value = route.params.id || route.query.id;
await loadBookInfo();
await loadCategoryTree();
categorySource.value = await loadCategorySource();
updateCategoryColumnSource(categorySource.value);
nextTick(() => {
if (hstRef.value?.hotInstance) {
if (accessStore.tenantId != 1) {
const editableColumns = ['priceTaxExcl', 'taxRate', 'priceTaxIncl'];
const updatedColumns = columns.map(col => ({
...col,
editor: editableColumns.includes(col.data) ? col.editor : false,
}));
hstRef.value.hotInstance.updateSettings({
columns: updatedColumns,
});
}
}
});
});
</script>
<template>
<Page auto-content-height>
<ElCard class="h-full w-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col" header-class="!p-1">
<div class="!p-1">
<ElText class="mx-1">信息价名称: {{ bookTitle }}</ElText>
<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="handleOpenHistoryDialog"
type="primary"
size="small"
style="float: right"
:disabled="isCcompleted"
>
调用历史信息
</ElButton>
</div>
<div class="h-full w-full" ref="containerRef">
<ElSplitter :style="{ height: containerHeight + 'px' }">
<ElSplitterPanel size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbTree
ref="dbTreeRef"
:tree-data="treeData"
:root-menus="rootMenus"
:node-menus="nodeMenus"
@node-edit="handleTreeNodeEdit"
@node-click="handleTreeNodeSelect"
:defaultExpandedLevel="4"
/>
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbHst ref="hstRef" :settings="dbSettings" :contextMenuItems="contextMenuItems" />
</ElCard>
</ElSplitterPanel>
</ElSplitter>
</div>
</ElCard>
<HistoryDialog
v-model:visible="historyDialogVisible"
:bookInfo="bookInfo"
:categorySource="categorySource"
@confirm="handleHistoryConfirm"
/>
<ElDialog v-model="uploadDialogVisible" title="上传图片" width="500px" @close="handleDialogClose">
<div class="upload-container">
<ElUpload
class="upload-demo"
drag
:action="uploadUrl"
:http-request="handleHttpRequest"
:show-file-list="false"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:on-change="handleUploadChange"
accept="image/*"
:disabled="uploading"
>
<div class="flex min-h-[200px] flex-col items-center justify-center py-8">
<span class="icon-[mdi--cloud-upload-outline] mb-4 text-6xl text-gray-400"></span>
<div class="text-base text-gray-600">点击或拖拽文件到此区域上传</div>
<div class="mt-2 text-sm text-gray-400">支持 .jpg.png.gif.webp 格式图片文件</div>
</div>
</ElUpload>
</div>
<template #footer>
<ElButton @click="handleDialogClose" :disabled="uploading">关闭</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>
<MorePriceDialog
v-model:visible="morePriceDialogVisible"
:bookInfo="bookInfo"
:selectRowPrice="selectRowPrice"
@select="handleSelectPrice"
/>
</Page>
</template>
<style lang="css"></style>

View File

@@ -1,34 +1,29 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElDialog, ElForm, ElFormItem, ElInput, ElDatePicker, ElButton, ElMessage } from 'element-plus'
import { createInfoPriceBook, type InfoPriceApi } from '#/api/database/info'
interface AddForm {
name: string
priceDate: string
}
interface AttachmentData {
id: number
name: string
priceDate: string
attachment: string
attachmentUrl: string
priceDate: [string, string] | null
}
const props = defineProps<{
visible: boolean
data: AttachmentData[]
treeNodeId: any
catalogVersion: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:data': [value: AttachmentData[]]
'success': []
}>()
const addFormRef = ref()
const submitting = ref(false)
const addForm = ref<AddForm>({
name: '',
priceDate: ''
priceDate: null
})
const addFormRules = {
@@ -43,35 +38,47 @@ const addFormRules = {
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 {
try {
const valid = await addFormRef.value.validate()
if (!valid) {
ElMessage.error('请填写完整信息')
return false
return
}
})
if (!props.treeNodeId) {
ElMessage.warning('请先选择左侧树节点')
return
}
submitting.value = true
const [startTime, endTime] = addForm.value.priceDate as [string, string]
const data: InfoPriceApi.Book = {
treeNodeId: props.treeNodeId,
catalogVersion: props.catalogVersion,
name: addForm.value.name,
startTime,
endTime
}
await createInfoPriceBook(data)
ElMessage.success('新增成功')
emit('success')
handleClose()
} catch (error) {
console.error('新增失败:', error)
// ElMessage.error('')
} finally {
submitting.value = false
}
}
const handleClose = () => {
addFormRef.value?.resetFields()
addForm.value = {
name: '',
priceDate: ''
priceDate: null
}
emit('update:visible', false)
}
@@ -115,7 +122,7 @@ const handleClose = () => {
<template #footer>
<div class="flex justify-end gap-2">
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">确定</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">确定</ElButton>
</div>
</template>
</ElDialog>

View File

@@ -0,0 +1,230 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElDialog, ElDatePicker, ElTable, ElTableColumn, ElButton, ElMessage, ElPagination, ElMessageBox } from 'element-plus'
import { getInfoPriceBookPage, updateInfoPriceBook, type InfoPriceApi } from '#/api/database/info'
import { formatDateArray } from '../index'
const props = defineProps<{
visible: boolean
treeNodeId: any
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': []
}>()
const attachmentDateRange = ref<[string, string] | null>(null)
const filteredAttachmentData = ref<any>([])
const loading = ref(false)
// 分页参数
const pageNo = ref(1)
const pageSize = ref(10)
const total = ref(0)
// 转换 API 数据为表格数据
const transformBookData = (list: InfoPriceApi.Book[]): any => {
return list.map(item => ({
...item,
priceDate: item.startTime && item.endTime ? (item.startTime+` ~ `+item.endTime) : '',
}))
}
// 加载数据
const loadData = async () => {
if (!props.treeNodeId) {
filteredAttachmentData.value = []
total.value = 0
return
}
loading.value = true
try {
const params: InfoPriceApi.BookPageParams = {
pageNo: pageNo.value,
pageSize: pageSize.value,
treeNodeId: props.treeNodeId,
attachment: true
}
// 如果有日期筛选
if (attachmentDateRange.value && attachmentDateRange.value.length === 2) {
params.startTimeEnd = attachmentDateRange.value[1] // startTime <= 结束日期
params.endTimeBegin = attachmentDateRange.value[0] // endTime >= 开始日期
}
const res = await getInfoPriceBookPage(params)
filteredAttachmentData.value = transformBookData(res?.list || [])
total.value = res?.total || 0
} catch (error) {
console.error('加载附件数据失败:', error)
filteredAttachmentData.value = []
total.value = 0
} finally {
loading.value = false
}
}
// 监听 visible 变化,打开时加载数据
watch(() => props.visible, (val) => {
if (val) {
// 重置分页
pageNo.value = 1
loadData()
}
})
// 监听日期筛选变化
watch(attachmentDateRange, () => {
if (props.visible) {
pageNo.value = 1 // 筛选条件变化时重置页码
loadData()
}
})
// const handleUpload = (row: any) => {
// 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: any) => {
if (!row.attachment) {
ElMessage.warning('暂无附件可下载')
return
}
const link = document.createElement('a')
link.href = row.attachment
link.download = row.attachment
link.click()
link.remove()
}
const handleDelete = async (row: any) => {
try {
ElMessageBox.confirm('确定删除该附件吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
appendTo: document.body // 确保弹窗挂载到 body避免嵌套问题
}).then(async () => {
await updateInfoPriceBook({
...row,
attachment: ''
})
ElMessage.success('删除成功')
emit('success')
loadData()
}).catch(() => {})
} catch (error) {
if (error !== 'cancel') {
console.error('删除附件失败:', error)
ElMessage.error('删除失败')
}
}
}
const handleClose = () => {
attachmentDateRange.value = null
pageNo.value = 1
pageSize.value = 10
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"
size="small"
/>
</div>
<ElTable :data="filteredAttachmentData" border stripe style="width: 100%" v-loading="loading" size="small">
<ElTableColumn prop="name" label="信息价名称" width="200" />
<ElTableColumn prop="priceDate" label="价格时间段" width="200" />
<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="primary"
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 class="mt-2 flex justify-end">
<ElPagination
:total="total"
v-model:current-page="pageNo"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</div>
<template #footer>
<ElButton @click="handleClose">关闭</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.attachment-container {
padding: 10px 0;
}
</style>

View File

@@ -0,0 +1,214 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElDialog, ElTable, ElTableColumn, ElButton, ElMessage, ElMessageBox, ElTooltip, ElText } from 'element-plus'
import { getInfoPriceBookCopyPage, createInfoPriceBookCopy, type InfoPriceApi } from '#/api/database/info'
import { getSimpleTenantList, type SystemTenantApi } from '#/api/system/tenant'
import { DICT_TYPE } from '@vben/constants';
import { DictTag } from '#/components/dict-tag';
const { push } = useRouter()
const props = defineProps<{
visible: boolean
treeNodeId?: any
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': []
}>()
const loading = ref(false)
const tableData = ref<InfoPriceApi.Book[]>([])
const total = ref(0)
const tenantList = ref<SystemTenantApi.Tenant[]>([])
// 租户ID到名称的映射
const tenantMap = computed(() => {
const map = new Map<number, string>()
tenantList.value.forEach(tenant => {
if (tenant.id) {
map.set(tenant.id, tenant.name)
}
})
return map
})
// 根据租户ID获取租户名称
const getTenantName = (tenantId: number | undefined) => {
if (!tenantId) return '-'
return tenantMap.value.get(tenantId) || `租户${tenantId}`
}
// 加载租户列表
const loadTenantList = async () => {
try {
const res = await getSimpleTenantList()
tenantList.value = res || []
} catch (error) {
console.error('加载租户列表失败:', error)
}
}
// 加载数据
const loadData = async () => {
loading.value = true
try {
const res = await getInfoPriceBookCopyPage({
pageNo: 1,
pageSize: 100,
treeNodeId: props.treeNodeId
})
tableData.value = res?.list || []
total.value = res?.total || 0
} catch (error) {
console.error('加载复制数据失败:', error)
tableData.value = []
} finally {
loading.value = false
}
}
// 监听 visible 变化,打开时加载数据
watch(() => props.visible, (val) => {
if (val) {
loadData()
// 如果租户列表为空,加载租户列表
if (tenantList.value.length === 0) {
loadTenantList()
}
}
})
const handleTogglePublish = (row: InfoPriceApi.Book) => {
try {
ElMessageBox.confirm('确定要调入并发布该信息价吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
loading.value = true
await createInfoPriceBookCopy(row.id)
ElMessage.success('发布成功')
// 重新加载数据
await loadData()
emit('success')
}).catch(() => {})
} catch (error: any) {
// 用户取消操作时不显示错误
if (error !== 'cancel') {
console.error('发布失败:', error)
// ElMessage.error('发布失败')
}
} finally {
loading.value = false
}
}
const handlePreview = (row: InfoPriceApi.Book) => {
if (!row.attachment) {
ElMessage.warning('没有可预览的附件')
return
}
window.open(row.attachment, '_blank')
}
const handleView = (row: InfoPriceApi.Book) => {
push({
name: 'InfoAdd',
query: row?.id ? { id: row.id } : {},
})
}
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="tableData" v-loading="loading" border stripe style="width: 100%" height="500" size="small">
<ElTableColumn type="index" label="序号" width="60" fixed="left" />
<ElTableColumn prop="tenantId" label="租户" width="120">
<template #default="{ row }">
{{ getTenantName(row.tenantId) }}
</template>
</ElTableColumn>
<ElTableColumn prop="catalogVersion" label="专业" width="150" >
<template #default="{ row }">
<DictTag
:type="DICT_TYPE.INFO_PROFESSION"
:value="row.catalogVersion"
/>
</template>
</ElTableColumn>
<ElTableColumn prop="region" label="地区" width="150" show-overflow-tooltip/>
<ElTableColumn label="价格时间段" width="180">
<template #default="{ row }">
{{ row.startTime && row.endTime ? `${row.startTime} ~ ${row.endTime}` : '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="name" label="信息价名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<ElText class="mx-1" type="primary" @click="handleView(row)" style="cursor: pointer;">{{ row.name }}</ElText>
</template>
</ElTableColumn>
<!-- <ElTableColumn prop="resourceCount" label="信息价条数" min-width="150" />
<ElTableColumn prop="completedTime" label="已完成时间" width="150" /> -->
<ElTableColumn prop="attachment" label="附件" width="80">
<template #default="{ row }">
<ElButton
v-if="row.attachment"
type="primary"
size="small"
link
@click="handlePreview(row)"
>
预览
</ElButton>
<span v-else class="text-gray-400">-</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<ElButton
v-if="row.publishStatus === 'completed'"
:type="'success'"
size="small"
@click="handleTogglePublish(row)"
>调入</ElButton>
<!-- <ElButton
v-else-if="row.publishStatus === 'uncompleted'"
:type="'info'"
size="small"
>
{{ '未完成' }}
</ElButton> -->
</template>
</ElTableColumn>
</ElTable>
</div>
<template #footer>
<ElButton @click="handleClose">关闭</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.copy-container {
height: 500px !important;
padding: 10px 0;
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ElDialog, ElUpload, ElButton, ElMessage } from 'element-plus'
import type { UploadProps, UploadFile, UploadRawFile } from 'element-plus'
import { useUpload } from '#/components/upload/use-upload'
import { loadPdfLib,checkPdfJavaScript } from './pdf'
interface RowData {
id?: number
name?: string
[key: string]: any
}
const props = defineProps<{
visible: boolean
rowData: RowData | null
directory?: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': [url: string, file: UploadFile]
}>()
const fileList = ref<UploadFile[]>([])
const uploading = ref(false)
const { uploadUrl, httpRequest } = useUpload(props.directory)
// 文件大小限制 10MB
const maxFileSize = 10
// 允许的文件类型
const acceptTypes = '.pdf'
// 监听弹窗关闭时清空文件列表
watch(() => props.visible, (val) => {
if (!val) {
fileList.value = []
uploading.value = false
}
})
// 上传前校验
const beforeUpload: UploadProps['beforeUpload'] = async (file: UploadRawFile) => {
const isLt20M = file.size / 1024 / 1024 < maxFileSize
if (!isLt20M) {
ElMessage.error(`文件大小不能超过 ${maxFileSize}MB!`)
return false
}
try {
await loadPdfLib()
const bytes = await file.arrayBuffer()
const hasJavaScript = await checkPdfJavaScript(bytes)
if (hasJavaScript) {
ElMessage.error('PDF 文件包含 JavaScript 代码,不允许上传')
return false
}
return true
} catch (error) {
console.error('PDF 检测失败:', error)
ElMessage.error('PDF 文件检测失败,请重试')
return false
}
}
// 自定义上传请求
const handleHttpRequest = (options: any) => {
uploading.value = true
return httpRequest(options.file)
}
// 上传成功
const handleSuccess: UploadProps['onSuccess'] = (response: any, file: UploadFile) => {
uploading.value = false
ElMessage.success('上传成功')
emit('success', response, file)
handleClose()
}
// 上传失败
const handleError: UploadProps['onError'] = () => {
uploading.value = false
ElMessage.error('上传失败,请重试')
}
// 关闭弹窗
const handleClose = () => {
emit('update:visible', false)
}
onMounted(async () => {
})
</script>
<template>
<ElDialog
:model-value="visible"
title="附件上传"
width="500px"
@update:model-value="emit('update:visible', $event)"
@close="handleClose"
>
<div class="upload-container">
<ElUpload
v-model:file-list="fileList"
class="upload-demo"
drag
:action="uploadUrl"
:http-request="handleHttpRequest"
:before-upload="beforeUpload"
:on-success="handleSuccess"
:on-error="handleError"
:accept="acceptTypes"
:limit="1"
:disabled="uploading"
>
<div
class="flex min-h-[200px] flex-col items-center justify-center py-8"
>
<span
class="icon-[mdi--cloud-upload-outline] mb-4 text-6xl text-gray-400"
></span>
<div class="text-base text-gray-600">
点击或拖拽文件到此区域上传
</div>
<div class="mt-2 text-sm text-gray-400">
支持 {{acceptTypes}} 文件
</div>
</div>
</ElUpload>
</div>
<template #footer>
<ElButton @click="handleClose" :disabled="uploading">取消</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.upload-container {
padding: 10px 0;
}
.upload-demo {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 40px 20px;
}
</style>

View File

@@ -0,0 +1,57 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
import { getInfoPriceBookList } from '#/api/database/info';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { Action } from 'element-plus';
export const rootMenus: any[] = [];
export const nodeMenus: any[] = [];
function transformTreeData(data: any[]): any[] {
return data.map(item => ({
...item,
label: ((item.startTime && item.startTime) ? `${item.startTime} ~ ${item.endTime}` : `${item.name}`),
children: item.children ? transformTreeData(item.children) : []
}));
}
export const useDbTree = (dbTreeRef: Ref<any>, bookInfo: Ref<any>) => {
const treeData = ref<any[]>([]);
const treeLoading = ref(false);
const loadHistoryBookList = async () => {
if (!bookInfo.value.treeNodeId) return;
treeLoading.value = true;
try {
const res = await getInfoPriceBookList({
treeNodeId: bookInfo.value.treeNodeId,
excludeBookId: bookInfo.value.id,
publishStatus: 'published'
});
treeData.value = transformTreeData(res || []);
} catch (error) {
console.error('加载历史信息价册失败:', error);
ElMessage.error('加载历史信息价册失败');
} finally {
treeLoading.value = false;
}
};
const handleInfo = (node: any, data: any) => {
ElMessageBox.alert('信息价名称: ' + data.name, data.label, {
cancelButtonText: '取消',
callback: (action: Action) => {},
});
};
return {
treeData,
rootMenus,
nodeMenus,
loadHistoryBookList,
handleInfo,
};
};

View File

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

View File

@@ -0,0 +1,215 @@
<script setup lang="ts">
import { getResourcePageHistory } from '#/api/database/info';
import { DbTree } from '#/components/db-tree';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { ElButton, ElCard, ElDialog, ElImage, ElMessage, ElPagination, ElSplitter, ElSplitterPanel, ElTable, ElTableColumn } from 'element-plus';
import { ref, watch, computed } from 'vue';
import { useDbTree } from './dbTree';
const props = defineProps<{
visible: boolean;
bookInfo?: any;
categorySource?: { label: string; value: any }[];
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
'confirm': [node: any];
}>();
const dbTreeRef = ref();
const tableRef = ref();
const bookInfoRef = computed(() => props.bookInfo);
const currentNodeId = ref<any>(null)
const {
treeData,
rootMenus,
nodeMenus,
loadHistoryBookList,
handleInfo,
} = useDbTree(dbTreeRef, bookInfoRef);
const tableData = ref<any[]>([]);
const tableLoading = ref(false);
const tableTotal = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);
const rowSelection = ref<any>(null);
watch(() => props.visible, (val) => {
if (val && props.bookInfo.treeNodeId) {
loadHistoryBookList();
}
});
const loadResourceData = async () => {
if (!currentNodeId.value) return;
tableLoading.value = true;
try {
const resourceRes = await getResourcePageHistory({
pageNo: currentPage.value,
pageSize: pageSize.value,
categoryTreeId: currentNodeId.value
});
tableData.value = resourceRes.list || [];
tableTotal.value = resourceRes.total || 0;
} catch (error) {
console.error('加载工料机数据失败:', error);
tableData.value = [];
tableTotal.value = 0;
} finally {
tableLoading.value = false;
}
};
const handleTreeNodeSelect = async (payload: { node: any; data: any; event: MouseEvent }) => {
const { node, data, event } = payload
// 保存当前节点ID并重置分页
currentNodeId.value = node?.data.id
currentPage.value = 1;
await loadResourceData();
};
const handleCurrentChange = (page: number) => {
currentPage.value = page;
loadResourceData();
};
const handleSizeChange = (size: number) => {
pageSize.value = size;
currentPage.value = 1;
loadResourceData();
};
const getCategoryName = (categoryId: any): string => {
if (!categoryId || !props.categorySource) return '-';
const found = props.categorySource.find(item => item.value === categoryId);
return found?.label || '-';
};
const handleSelectionChange = (newSelection: any[]) => {
rowSelection.value = newSelection;
};
const handleConfirm = () => {
emit('confirm', rowSelection.value);
handleClose();
};
const handleClose = () => {
rowSelection.value = null;
tableRef.value?.clearSelection();
emit('update:visible', false);
};
</script>
<template>
<ElDialog
:model-value="visible"
title="调用历史信息"
width="70%"
@update:model-value="emit('update:visible', $event)"
@close="handleClose"
>
<div class="history-container">
<ElSplitter>
<ElSplitterPanel size="20%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<DbTree
ref="dbTreeRef"
:tree-data="treeData"
:root-menus="rootMenus"
:node-menus="nodeMenus"
:defaultExpandedLevel="4"
@node-click="handleTreeNodeSelect"
>
<template #after-node-label="{ node, data }">
<IconifyIcon
v-if="data.publishStatus"
style="margin-left: auto; margin-right: 5px;"
:size="12"
icon="lucide:info"
@click.stop="handleInfo(node, data)"
/>
</template>
</DbTree>
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full">
<ElTable
ref="tableRef"
:data="tableData"
border
stripe
style="width: 100%"
height="530"
v-loading="tableLoading"
size="small"
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="55" />
<ElTableColumn prop="name" label="名称" width="150" />
<ElTableColumn prop="spec" label="型号规格" width="150" />
<ElTableColumn prop="unit" label="单位" width="100" align="center">
<template #default="{ row }">
{{ getDictLabel(DICT_TYPE.MATERIAL_UNIT, row.unit) }}
</template>
</ElTableColumn>
<ElTableColumn prop="priceTaxExcl" label="除税编制价" width="150" align="center" />
<ElTableColumn prop="taxRate" label="税率%" width="100" align="center" />
<ElTableColumn prop="priceTaxIncl" label="含税编制价" width="150" align="center" />
<ElTableColumn prop="drawingUrl" label="图样" width="150" align="center">
<template #default="{ row }">
<ElImage
v-if="row.drawingUrl"
:src="row.drawingUrl"
:preview-src-list="[row.drawingUrl]"
fit="cover"
style="width: 25px; height: 25px; cursor: pointer;"
preview-teleported
/>
<span v-else class="text-gray-400">-</span>
</template>
</ElTableColumn>
<ElTableColumn prop="categoryId" label="分类" min-width="220">
<template #default="{ row }">
{{ getCategoryName(row.categoryId) }}
</template>
</ElTableColumn>
</ElTable>
<div class="flex justify-end mt-2">
<ElPagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="tableTotal"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</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: 600px;
}
</style>

View File

@@ -0,0 +1,218 @@
import type { Ref } from 'vue';
import { nextTick, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { validatorRow } from '#/components/db-hst/validator';
import { createDateRangeRenderer, checkDateRangeOverlap, createTextRenderer } from '../../index';
import { getResourcePricePageHistory, createResourcePrice, updateResourcePrice, deleteResourcePrice } from '#/api/database/info';
type MorePriceDialogProps = {
visible: boolean;
bookInfo?: any;
selectRowPrice?: any;
};
export const useDbHst = (
hstRef: Ref<any>,
props: Ref<MorePriceDialogProps>,
// emit: (event: 'select', row: any) => void,
visibleDateRange: Ref<boolean>,
popoverDate: Ref<[string, string] | undefined>,
currentEditingRow: Ref<number>,
popoverDateRangeRef: Ref<any>
) => {
const selectedRow = ref<any>(null);
// const handleSelectPrice = (_row: number, data: any) => {
// emit('select', {
// priceTaxExcl: data.priceTaxExcl,
// taxRate: data.taxRate,
// priceTaxIncl: data.priceTaxIncl,
// });
// };
// const textRenderer = createTextRenderer('选择', {
// onSelect: handleSelectPrice,
// });
const dateRangeRenderer = createDateRangeRenderer({
onDateIconClick: (row: number, _data: any, event: MouseEvent) => {
visibleDateRange.value = false;
nextTick(() => {
currentEditingRow.value = row;
popoverDateRangeRef.value = event.target;
visibleDateRange.value = true;
});
},
});
const moreColumns: any[] = [
{ type: 'text', data: 'time', title: '价格时间段', width: 200, renderer: dateRangeRenderer, required: true },
{ type: 'numeric', data: 'priceTaxExcl', title: '除税单价', required: true },
{ type: 'numeric', data: 'taxRate', title: '税率%', width: 60, required: true },
{ type: 'numeric', data: 'priceTaxIncl', title: '含税单价', required: true },
];
const handleAdd = () => {
hstRef.value?.hotInstance?.alter('insert_row_below', hstRef.value?.hotInstance?.countRows(), 1);
};
const handleDelete = () => {
const row = selectedRow.value?.row;
const rowData = hstRef.value?.hotInstance?.getSourceDataAtRow(row);
ElMessageBox.confirm('确定删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
appendTo: document.body,
}).then(() => {
if (rowData?.id) {
deleteResourcePrice(rowData.id);
}
hstRef.value?.hotInstance?.alter('remove_row', row, 1);
selectedRow.value = null;
})
.catch(() => {});
};
const moreContextMenuItems = [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
handleAdd();
nextTick(() => {
hotInstance.render();
});
},
},
];
const moreDbSettings = {
data: [],
columns: moreColumns,
contextMenu: {
items: {
remove_row: {
name: '移除行',
callback: async function (this: any, _key: string, selection: any[]) {
handleDelete();
},
},
},
},
outsideClickDeselects: false,
currentRowClassName: 'row-highlight',
afterOnCellMouseDown(event: MouseEvent, coords: any, TD: HTMLTableCellElement) {
if (event.detail === 1 && event.button === 0) {
selectedRow.value = coords;
}
},
async afterChange(changes: any, source: string) {
if (!changes || source === 'loadData' || source === 'updateId') return;
if (!validatorRow(this, changes, moreColumns)) {
return;
}
const row = changes[0][0];
const rowData = this.getSourceDataAtRow(row);
let startTime = '';
let endTime = '';
if (rowData.time && rowData.time.includes('~')) {
const [start, end] = rowData.time.split('~').map((s: string) => s.trim());
startTime = start;
endTime = end;
}
const sendData: any = {
...rowData,
startTime,
endTime,
resourceId: props.value.selectRowPrice?.resourceItemId || props.value.selectRowPrice?.id,
};
try {
if (rowData.id == null) {
const id = await createResourcePrice(sendData);
this.setDataAtRowProp(row, 'id', id, 'updateId');
ElMessage.success('创建成功');
} else {
await updateResourcePrice(sendData);
ElMessage.success('更新成功');
}
} catch (error) {
console.error('保存价格失败:', error);
ElMessage.error('保存失败');
}
},
};
const handleCalendarChange = (val: any) => {
nextTick(() => {
if (val[0] != null && val[1] != null) {
const existingRanges = (hstRef.value?.hotInstance?.getDataAtProp('time') as string[]) || [];
const [startDate, endDate] = popoverDate.value as [string, string];
if (checkDateRangeOverlap(startDate, endDate, currentEditingRow.value, existingRanges)) {
ElMessage.warning('所选时间段与已有时间段存在交集,请重新选择');
return;
}
visibleDateRange.value = false;
setTimeout(() => {
if (currentEditingRow.value >= 0 && popoverDate.value && hstRef.value?.hotInstance) {
const dateRangeStr = `${startDate} ~ ${endDate}`;
hstRef.value.hotInstance.setDataAtRowProp(currentEditingRow.value, 'time', dateRangeStr);
}
}, 200);
}
});
};
const loadPriceHistory = async () => {
const resourceId = props.value.selectRowPrice?.resourceItemId || props.value.selectRowPrice?.id;
const updatedColumns = moreColumns.map((col) => ({
...col,
}));
hstRef.value?.hotInstance?.updateSettings({ columns: updatedColumns });
if (resourceId) {
try {
const res = await getResourcePricePageHistory({ pageNo: 1, pageSize: 100, resourceId });
const list = (res.list || []).map((item: any) => ({
...item,
time: `${item.startTime} ~ ${item.endTime}`,
}));
nextTick(() => {
hstRef.value?.hotInstance?.loadData(list);
});
} catch (error) {
console.error('获取价格历史失败:', error);
nextTick(() => {
hstRef.value?.hotInstance?.loadData([]);
});
}
} else {
nextTick(() => {
hstRef.value?.hotInstance?.loadData([]);
});
}
};
const getSelectedRowData = () => {
return hstRef.value?.hotInstance?.getSourceDataAtRow(selectedRow.value?.row);
};
return {
selectedRow,
moreColumns,
moreDbSettings,
moreContextMenuItems,
handleAdd,
handleDelete,
handleCalendarChange,
loadPriceHistory,
getSelectedRowData,
};
};

View File

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

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { DbHst } from '#/components/db-hst';
import { createClickOutsideHandler } from '../../index';
import { ElButton, ElDatePickerPanel, ElDialog, ElMessage, ElPopover, ElSpace } from 'element-plus';
import { nextTick, onMounted, onUnmounted, ref, toRef, watch } from 'vue';
import { useDbHst } from './dbHst';
const props = defineProps<{
visible: boolean;
bookInfo?: any;
selectRowPrice?: any;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
'select': [row: any];
}>();
const moreHstRef = ref();
const popoverDateRangeRef = ref();
const visibleDateRange = ref(false);
const popoverDate = ref<[string, string]>();
const currentEditingRow = ref<number>(-1);
const propsRef = toRef(props);
// const emitSelect = (row: any) => emit('select', row);
const {
selectedRow,
moreDbSettings,
moreContextMenuItems,
handleAdd,
handleDelete,
handleCalendarChange,
loadPriceHistory,
getSelectedRowData,
} = useDbHst(moreHstRef, propsRef, visibleDateRange, popoverDate, currentEditingRow, popoverDateRangeRef);
const handleClickOutside = createClickOutsideHandler(
() => visibleDateRange.value,
(value) => {
visibleDateRange.value = value;
}
);
const handleConfirm = () => {
const data = getSelectedRowData();
if (!data || !data.id) return;
emit('select', {
priceTaxExcl: data.priceTaxExcl,
taxRate: data.taxRate,
priceTaxIncl: data.priceTaxIncl,
});
handleClose();
};
const handleClose = () => {
visibleDateRange.value = false;
emit('update:visible', false);
};
const disabledDate = (time: Date) => {
if (!props.bookInfo?.startTime || !props.bookInfo?.endTime) {
return false;
}
const timeStart = new Date(time.getFullYear(), time.getMonth(), time.getDate()).getTime();
const startDate = new Date(props.bookInfo.startTime);
const minDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()).getTime();
const endDate = new Date(props.bookInfo.endTime);
const maxDate = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime();
return timeStart < minDate || timeStart > maxDate;
};
watch(
() => props.visible,
(val) => {
if (val) {
nextTick(() => {
loadPriceHistory();
});
}
}
);
defineExpose({
moreHstRef,
});
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<template>
<ElDialog
:model-value="visible"
title="更多单价"
width="50%"
@update:model-value="emit('update:visible', $event)"
@close="handleClose"
body-class="body-height"
>
<div class="more-price-container1 flex flex-col h-full w-full">
<ElSpace wrap>
<ElButton size="small" @click="handleAdd">添加</ElButton>
<ElButton size="small" @click="handleDelete">删除</ElButton>
</ElSpace>
<DbHst ref="moreHstRef" :settings="moreDbSettings" :contextMenuItems="moreContextMenuItems" />
</div>
<template #footer>
<ElButton @click="handleClose">关闭</ElButton>
<ElButton type="primary" @click="handleConfirm">确定</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"
:disabled-date="disabledDate"
@calendar-change="handleCalendarChange"
value-format="YYYY-MM-DD"
/>
</ElPopover>
</template>
<style>
.body-height {
height: 300px;
}
</style>

View File

@@ -0,0 +1,243 @@
let PDFLib: any = null
interface PDFLibModule {
PDFDocument: {
load(bytes: ArrayBuffer, options?: { ignoreEncryption?: boolean }): Promise<any>
}
PDFName: {
of(name: string): any
}
PDFDict: any
PDFArray: any
}
const loadPdfLib = (): Promise<PDFLibModule> => {
return new Promise((resolve, reject) => {
if (PDFLib) {
resolve(PDFLib)
return
}
const script = document.createElement('script')
script.src = '/static/js/pdf-lib.min.js'
script.onload = () => {
PDFLib = (window as any).PDFLib
resolve(PDFLib)
}
script.onerror = reject
document.head.appendChild(script)
})
}
const checkActionForJavaScript = (
action: any,
PDFDict: any,
PDFName: any,
PDFArray: any,
): boolean => {
if (!(action instanceof PDFDict)) return false
const subtype = action.lookup(PDFName.of('S'))
if (subtype?.toString() === '/JavaScript') {
return true
}
const js = action.lookup(PDFName.of('JS'))
if (js) {
return true
}
const next = action.lookup(PDFName.of('Next'))
if (next instanceof PDFArray) {
for (let i = 0; i < next.size(); i++) {
if (checkActionForJavaScript(next.lookup(i), PDFDict, PDFName, PDFArray)) {
return true
}
}
} else if (next instanceof PDFDict) {
if (checkActionForJavaScript(next, PDFDict, PDFName, PDFArray)) {
return true
}
}
return false
}
const checkFieldForJavaScript = (
field: any,
PDFDict: any,
PDFName: any,
PDFArray: any,
): boolean => {
if (!(field instanceof PDFDict)) return false
const aa = field.lookup(PDFName.of('AA'))
if (aa instanceof PDFDict) {
const actionKeys = ['K', 'F', 'V', 'C', 'E', 'X', 'D', 'U', 'Fo', 'PO', 'PC', 'PV', 'PI']
for (const key of actionKeys) {
const action = aa.lookup(PDFName.of(key))
if (action && checkActionForJavaScript(action, PDFDict, PDFName, PDFArray)) {
return true
}
}
}
const kids = field.lookup(PDFName.of('Kids'))
if (kids instanceof PDFArray) {
for (let i = 0; i < kids.size(); i++) {
if (checkFieldForJavaScript(kids.lookup(i), PDFDict, PDFName, PDFArray)) {
return true
}
}
}
return false
}
const checkAcroFormForJavaScript = (
acroForm: any,
PDFDict: any,
PDFName: any,
PDFArray: any,
): boolean => {
if (!(acroForm instanceof PDFDict)) return false
const co = acroForm.lookup(PDFName.of('CO'))
if (co instanceof PDFArray) {
for (let i = 0; i < co.size(); i++) {
if (checkFieldForJavaScript(co.lookup(i), PDFDict, PDFName, PDFArray)) {
return true
}
}
}
const fields = acroForm.lookup(PDFName.of('Fields'))
if (fields instanceof PDFArray) {
for (let i = 0; i < fields.size(); i++) {
if (checkFieldForJavaScript(fields.lookup(i), PDFDict, PDFName, PDFArray)) {
return true
}
}
}
return false
}
const checkAnnotationForJavaScript = (
annot: any,
PDFDict: any,
PDFName: any,
PDFArray: any,
): boolean => {
if (!(annot instanceof PDFDict)) return false
const aa = annot.lookup(PDFName.of('AA'))
if (aa instanceof PDFDict) {
const actionKeys = ['E', 'X', 'D', 'U', 'Fo', 'Bl', 'PO', 'PC', 'PV', 'PI']
for (const key of actionKeys) {
const action = aa.lookup(PDFName.of(key))
if (action && checkActionForJavaScript(action, PDFDict, PDFName, PDFArray)) {
return true
}
}
}
const action = annot.lookup(PDFName.of('A'))
if (action && checkActionForJavaScript(action, PDFDict, PDFName, PDFArray)) {
return true
}
return false
}
const checkPageForJavaScript = (
page: any,
PDFDict: any,
PDFName: any,
PDFArray: any,
): boolean => {
if (!(page instanceof PDFDict)) return false
const aa = page.lookup(PDFName.of('AA'))
if (aa instanceof PDFDict) {
const actionKeys = ['O', 'C']
for (const key of actionKeys) {
const action = aa.lookup(PDFName.of(key))
if (action && checkActionForJavaScript(action, PDFDict, PDFName, PDFArray)) {
return true
}
}
}
const annots = page.lookup(PDFName.of('Annots'))
if (annots instanceof PDFArray) {
for (let i = 0; i < annots.size(); i++) {
if (checkAnnotationForJavaScript(annots.lookup(i), PDFDict, PDFName, PDFArray)) {
return true
}
}
}
return false
}
const checkPdfJavaScript = async (bytes: ArrayBuffer): Promise<boolean> => {
if (!PDFLib) {
console.error('PDFLib not loaded')
return false
}
try {
const { PDFDocument, PDFName, PDFDict, PDFArray } = PDFLib
const pdfDoc = await PDFDocument.load(bytes, { ignoreEncryption: true })
const catalog = pdfDoc.catalog
const names = catalog.lookup(PDFName.of('Names'))
if (names instanceof PDFDict) {
const javaScriptDict = names.lookup(PDFName.of('JavaScript'))
if (javaScriptDict instanceof PDFDict) {
console.log('检测到文档级 JavaScript')
return true
}
}
const openAction = catalog.lookup(PDFName.of('OpenAction'))
if (openAction && checkActionForJavaScript(openAction, PDFDict, PDFName, PDFArray)) {
console.log('检测到 OpenAction 中的 JavaScript')
return true
}
const acroForm = catalog.lookup(PDFName.of('AcroForm'))
if (acroForm && checkAcroFormForJavaScript(acroForm, PDFDict, PDFName, PDFArray)) {
console.log('检测到表单中的 JavaScript')
return true
}
const pages = catalog.lookup(PDFName.of('Pages'))
if (pages instanceof PDFDict) {
const kids = pages.lookup(PDFName.of('Kids'))
if (kids instanceof PDFArray) {
for (let i = 0; i < kids.size(); i++) {
const page = kids.lookup(i)
if (page instanceof PDFDict && checkPageForJavaScript(page, PDFDict, PDFName, PDFArray)) {
console.log('检测到页面中的 JavaScript')
return true
}
}
}
}
return false
} catch (error) {
console.error('Error checking JavaScript:', error)
return false
}
}
export {
loadPdfLib,
checkPdfJavaScript,
}

View File

@@ -0,0 +1,401 @@
import type { Ref } from 'vue';
import { nextTick, ref } from 'vue';
import { createInfoPriceBook, deleteInfoPriceBook, getInfoPriceBookPage, updateInfoPriceBook, type InfoPriceApi } from '#/api/database/info';
import { validatorRow } from '#/components/db-hst/validator';
import { confirm } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import {
createAttachmentRenderer,
createClickOutsideHandler,
createContextMenuItems,
createDateRangeRenderer,
createOperationRenderer
} from './index';
export type BookTableRow = {
id?: string | number;
number: number;
time: string;
name: string;
region: string;
publishTime: string;
attachment: string;
publishStatus?: string;
[key: string]: any;
};
export type UseDbHstOptions = {
getTreeNodeId: () => any;
getSelectedSpecialty: () => string;
onFileUploadClick?: (data: any) => void;
};
export const useDbHst = (hstRef: Ref<any>, options: UseDbHstOptions) => {
const { push } = useRouter();
const rowMap = ref<Map<string | number, any>>(new Map());
const popoverDateRangeRef = ref();
const visibleDateRange = ref(false);
const popoverDate = ref<[string, string]>();
const currentEditingRow = ref();
const handleClickOutside = createClickOutsideHandler(
() => visibleDateRange.value,
(value) => { visibleDateRange.value = value; }
);
const handleCalendarChange = (val: any) => {
nextTick(() => {
console.log('handleCalendarChange', val, popoverDate.value);
if (val[0] != null && val[1] != null) {
const hot = hstRef.value?.hotInstance;
if (!hot) return;
const [startDate, endDate] = popoverDate.value as [string, string];
visibleDateRange.value = false;
setTimeout(() => {
if (currentEditingRow.value >= 0 && popoverDate.value && hstRef.value?.hotInstance) {
const dateRangeStr = `${startDate} ~ ${endDate}`;
hot.setDataAtRowProp([
[currentEditingRow.value, 'time', dateRangeStr],
]);
console.log(`已更新第 ${currentEditingRow.value} 行的价格时间段为: ${dateRangeStr}`);
}
}, 200);
}
});
};
const handleDateIconClick = (row: number, data: any, event: MouseEvent) => {
console.log('时间图标点击:', data);
visibleDateRange.value = false;
nextTick(() => {
currentEditingRow.value = row;
popoverDateRangeRef.value = event.target;
visibleDateRange.value = true;
});
};
const dateRangeRenderer = createDateRangeRenderer({
onDateIconClick: handleDateIconClick
});
const handleAttachmentUpload = (row: number, data: any) => {
console.log('上传附件:', data);
options.onFileUploadClick?.(data);
};
const handleAttachmentDownload = (row: number, data: any) => {
const fileName = data.attachment;
if (!fileName) {
ElMessage.warning('没有可预览的附件');
return;
}
window.open(fileName, '_blank');
};
const handleAttachmentDelete = async (row: number, data: any) => {
await confirm('确定删除该附件吗?').then(async () => {
await updateInfoPriceBook({
...data,
attachment: ''
});
ElMessage.success('附件删除成功');
loadBookData(options.getTreeNodeId());
}).catch(() => { });
};
const attachmentRenderer = createAttachmentRenderer({
onUpload: handleAttachmentUpload,
onDownload: handleAttachmentDownload,
onDelete: handleAttachmentDelete
});
const handleComplete = async (row: number, data: any) => {
console.log('完成:', data);
await confirm('确定完成吗?').then(async () => {
try {
await updateInfoPriceBook({
...data,
publishStatus: 'completed'
});
ElMessage.success('操作成功');
loadBookData(options.getTreeNodeId());
} catch (error) {
console.error('完成操作失败:', error);
ElMessage.error('操作失败');
}
}).catch(() => { });
};
const handleCompleted = async (row: number, data: any) => {
console.log('handleCompleted:', data);
await confirm('确定取消已完成吗?').then(async () => {
try {
await updateInfoPriceBook({
...data,
publishStatus: 'uncompleted'
});
ElMessage.success('操作成功');
loadBookData(options.getTreeNodeId());
} catch (error) {
console.error('操作失败:', error);
ElMessage.error('操作失败');
}
}).catch(() => { });
};
const handlePublish = async (row: number, data: any) => {
console.log('发布:', data);
await confirm('确定发布吗?').then(async () => {
try {
await updateInfoPriceBook({
...data,
publishStatus: 'published',
publishTime: new Date().getTime()
});
ElMessage.success('操作成功');
loadBookData(options.getTreeNodeId());
} catch (error) {
console.error('发布操作失败:', error);
ElMessage.error('操作失败');
}
}).catch(() => { });
};
const handleView = (row: number, data: any) => {
console.log('查看:', data);
push({
name: 'InfoAdd',
query: data?.id ? { id: data.id } : {},
});
};
const handlePublished = async (row: number, data: any) => {
console.log('handlePublished:', data);
await confirm('确定取消发布吗?').then(async () => {
try {
await updateInfoPriceBook({
...data,
publishStatus: 'unpublished',
});
ElMessage.success('操作成功');
loadBookData(options.getTreeNodeId());
} catch (error) {
console.error('取消发布操作失败:', error);
ElMessage.error('操作失败');
}
}).catch(() => { });
};
const handleDelete = async (row: number, data: any) => {
console.log('handleDelete:', data);
if (data.publishStatus == 'published') {
ElMessage.info('已发布不能删除');
return;
}
await confirm('确定删除吗?').then(async () => {
try {
await deleteInfoPriceBook(data.id);
ElMessage.success('操作成功');
loadBookData(options.getTreeNodeId());
} catch (error) {
console.error('删除操作失败:', error);
ElMessage.error('操作失败');
}
}).catch(() => { });
};
const operationRenderer = createOperationRenderer({
onComplete: handleComplete,
onCompleted: handleCompleted,
onPublish: handlePublish,
onPublished: handlePublished,
onView: handleView,
onDelete: handleDelete
});
const columns = [
{ type: 'text', data: 'number', title: '序号', width: 40, readOnly: true, className: 'htCenter' },
{ type: 'text', data: 'time', title: '价格时间段', width: 200, renderer: dateRangeRenderer, readOnly: true, required: true },
{ type: 'text', data: 'name', title: '信息价名称', width: 300, allowInvalid: true, required: true },
{ type: 'text', data: 'region', title: '地区', width: 300, editor: false, allowInvalid: true },
{ type: 'text', data: 'publishTime', title: '发布时间', width: 150, readOnly: true, allowInvalid: true },
{ type: 'text', data: 'attachment', title: '附件下载', width: 100, renderer: attachmentRenderer, readOnly: true },
{ type: 'text', data: 'operation', title: '操作', width: 200, renderer: operationRenderer, readOnly: true },
];
const transformBookData = (list: InfoPriceApi.Book[]): BookTableRow[] => {
const newMap = new Map<string | number, any>();
const tableData = list.map((item, index) => {
const rowItem = {
...item,
number: index + 1,
time: item.startTime && item.endTime ? (item.startTime + ` ~ ` + item.endTime) : '',
};
newMap.set(item.id, rowItem);
return rowItem;
});
rowMap.value = newMap;
return tableData;
};
const loadBookData = async (treeNodeId: any, startTimeEnd?: string, endTimeBegin?: string) => {
try {
const params: InfoPriceApi.BookPageParams = {
pageNo: 1,
pageSize: 100,
treeNodeId
};
if (startTimeEnd) {
params.startTimeEnd = startTimeEnd;
}
if (endTimeBegin) {
params.endTimeBegin = endTimeBegin;
}
const res = await getInfoPriceBookPage(params);
const tableData = transformBookData(res?.list || []);
hstRef.value?.hotInstance?.loadData(tableData);
hstRef.value?.hotInstance?.render();
} catch (error) {
console.error('加载信息价册失败:', error);
hstRef.value?.hotInstance?.loadData([]);
}
};
const contextMenuItems = createContextMenuItems(() => options.getTreeNodeId());
const afterChange = (changes: any[], source: string) => {
console.log('source', source);
if (!changes || source === 'loadData' || source === 'updateId') return;
const hasRealChange = changes.some((change: any) => {
const [, , oldValue, newValue] = change;
return oldValue !== newValue;
});
if (!hasRealChange) {
console.log('值未改变,跳过更新');
return;
}
const hot = hstRef.value?.hotInstance;
if (!hot) return;
if (!validatorRow(hot, changes, columns)) {
return;
}
const row = changes[0][0];
const rowData = hot.getSourceDataAtRow(row);
let startTime = '';
let endTime = '';
if (rowData.time && rowData.time.includes('~')) {
const [start, end] = rowData.time.split('~').map((s: string) => s.trim());
startTime = start;
endTime = end;
}
const sendData: InfoPriceApi.Book = {
id: rowData.id,
treeNodeId: options.getTreeNodeId(),
catalogVersion: options.getSelectedSpecialty(),
name: rowData.name || '',
startTime,
endTime,
attachment: rowData.attachment || ''
};
if (rowData.id == null) {
console.log('createInfoPriceBook', sendData);
createInfoPriceBook(sendData).then(res => {
console.log('createInfoPriceBook result', res);
hot.setDataAtRowProp(row, 'id', res, 'updateId');
ElMessage.success('新增成功');
loadBookData(options.getTreeNodeId());
}).catch(err => {
console.error('新增失败', err);
});
} else {
console.log('updateInfoPriceBook', sendData);
updateInfoPriceBook(sendData).then(res => {
console.log('updateInfoPriceBook result', res);
ElMessage.success('更新成功');
}).catch(err => {
console.error('更新失败', err);
ElMessage.error('更新失败');
});
}
};
const hstSettings = {
data: [],
columns: columns,
colWidths: 150,
contextMenu: {
items: {
remove_row: {
name: '移除行',
callback: async function (key: string, selection: any[]) {
const selectedRow = selection[0].start.row;
const rowData = this.getSourceDataAtRow(selectedRow);
if (rowData?.id) {
if (rowData.publishStatus == 'published') {
ElMessage.info('已发布不能删除');
return;
}
await confirm('确定删除吗?').then(async () => {
await deleteInfoPriceBook(rowData.id);
this.alter('remove_row', selectedRow, 1);
}).catch(() => { });
} else {
this.alter('remove_row', selectedRow, 1);
}
},
}
}
},
afterChange
};
const handleSearch = (searchText: any) => {
console.log('handleSearch', searchText);
let startTimeEnd: string | undefined;
let endTimeBegin: string | undefined;
if (searchText && Array.isArray(searchText.value) && searchText.value.length === 2) {
startTimeEnd = searchText.value[1];
endTimeBegin = searchText.value[0];
}
loadBookData(options.getTreeNodeId(), startTimeEnd, endTimeBegin);
};
const handleFileUploadSuccess = (url: string, currentUploadRowData: any) => {
console.log('文件上传成功:', url, currentUploadRowData);
if (currentUploadRowData && hstRef.value?.hotInstance) {
const hot = hstRef.value.hotInstance;
const rowIndex = hot.getSourceData().findIndex((item: any) => item.id === currentUploadRowData.id);
if (rowIndex >= 0) {
hot.setDataAtRowProp(rowIndex, 'attachment', url);
}
}
};
return {
hstSettings,
hstContextMenuItems: contextMenuItems,
rowMap,
popoverDateRangeRef,
visibleDateRange,
popoverDate,
handleClickOutside,
handleCalendarChange,
loadBookData,
handleSearch,
handleFileUploadSuccess
};
};

View File

@@ -0,0 +1,117 @@
import type { Ref } from 'vue';
import { computed, ref } from 'vue';
import { getInfoPriceTree, updateInfoPriceTreeNode, type InfoPriceApi } from '#/api/database/info';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions, type StringDictDataType } from '@vben/hooks';
import { ElMessage } from 'element-plus';
import { createRootMenus, createNodeMenus } from './index';
export type TreeNodeData = {
id: string | number;
label: string;
name: string;
children?: TreeNodeData[];
[key: string]: any;
};
export type TreeSelectPayload = {
node: any;
data: any;
event: MouseEvent;
};
export type TreeEditPayload = {
node: any;
data: any;
oldLabel: string;
newLabel: string;
};
export type UseDbTreeOptions = {
onNodeSelect?: (treeNodeId: any) => void;
};
export const useDbTree = (dbTreeRef: Ref<any>, options?: UseDbTreeOptions) => {
const specialtyOptions = getDictOptions(DICT_TYPE.INFO_PROFESSION, 'string') as StringDictDataType[];
const selectedSpecialty = ref<string>(specialtyOptions[0]?.value || '');
const treeData = ref<TreeNodeData[]>([]);
const selectedTreeNodeId = ref<any>(null);
const _rootMenus = createRootMenus(() => selectedSpecialty.value);
const rootMenus = computed(() => treeData.value.length > 0 ? [] : _rootMenus);
const nodeMenus = createNodeMenus(() => selectedSpecialty.value);
const transformTreeData = (data: InfoPriceApi.TreeNode[]): TreeNodeData[] => {
return data.map(item => ({
...item,
id: item.id,
label: item.name,
children: item.children ? transformTreeData(item.children) : undefined
}));
};
const loadTreeData = async (enumType: string) => {
if (!enumType) return;
try {
const res = await getInfoPriceTree(enumType);
treeData.value = transformTreeData(res || []);
} catch (error) {
console.error('加载信息价树失败:', error);
treeData.value = [];
} finally {
}
};
const handleSpecialtyChange = (value: string) => {
console.log('专业切换:', value);
selectedTreeNodeId.value = null;
loadTreeData(value);
};
const handleTreeNodeSelect = (payload: TreeSelectPayload) => {
const { node } = payload;
console.log('categoryhandleSelect', node);
selectedTreeNodeId.value = node.data.id;
options?.onNodeSelect?.(node.data.id);
};
const handleTreeNodeEdit = async (payload: TreeEditPayload) => {
const { node, newLabel } = payload;
console.log('节点编辑保存:', { node, newLabel });
try {
const data: InfoPriceApi.TreeNode = {
id: node.data.id,
enumType: selectedSpecialty.value,
parentId: node.data.parentId,
code: node.data.code || '',
name: newLabel,
sortOrder: node.data.sortOrder
};
await updateInfoPriceTreeNode(data);
ElMessage.success('更新成功');
} catch (error) {
console.error('更新失败:', error);
ElMessage.error('更新失败');
}
};
const initTree = () => {
if (selectedSpecialty.value) {
loadTreeData(selectedSpecialty.value);
}
};
return {
selectedSpecialty,
specialtyOptions,
treeData,
selectedTreeNodeId,
rootMenus,
nodeMenus,
handleSpecialtyChange,
handleTreeNodeSelect,
handleTreeNodeEdit,
loadTreeData,
initTree
};
};

View File

@@ -0,0 +1,626 @@
import { ref, nextTick } from 'vue'
import type { Ref } from 'vue'
import { createInfoPriceTreeNode, deleteInfoPriceTreeNode, type InfoPriceApi } from '#/api/database/info'
import { ElMessage } from 'element-plus'
import { generateRandomCode } from '#/components/db-tree/base';
// 操作列回调函数类型
export interface OperationCallbacks {
onUpload?: (row: number, data: any) => void
onComplete?: (row: number, data: any) => void
onCompleted?: (row: number, data: any) => void
onPublish?: (row: number, data: any) => void
onPublished?: (row: number, data: any) => void
onView?: (row: number, data: any) => void
onDelete?: (row: number, data: any) => void
}
// 价格时间段渲染器回调类型
export interface DateRangeCallbacks {
onDateIconClick?: (row: number, data: any, event: MouseEvent) => void
}
// 附件渲染器回调类型
export interface AttachmentCallbacks {
onUpload?: (row: number, data: any) => void
onDownload?: (row: number, data: any) => void
onDelete?: (row: number, data: any) => void
}
// 格式化日期数组为字符串
export function formatDateArray(dateArr: number[] | string | null | undefined): string {
if (!dateArr) return ''
if (typeof dateArr === 'string') return dateArr
if (Array.isArray(dateArr) && dateArr.length >= 3) {
const [year, month, day] = dateArr
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
return ''
}
// 价格时间段渲染器
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 cellMeta = instance.getCellMeta(row, col)
const isValid = cellMeta?.valid !== false
// 如果单元格被标记为无效,设置红色背景
if (!isValid) {
td.className = 'htInvalid' // 淡红色背景
}
// 获取行数据,处理 startTime 和 endTime
/*const rowData = instance.getSourceDataAtRow(row)
let displayValue = ''
if (rowData) {
const startTime = formatDateArray(rowData.startTime)
const endTime = formatDateArray(rowData.endTime)
if (startTime && endTime) {
displayValue = `${startTime} ~ ${endTime}`
}
}*/
const container = document.createElement('div')
container.style.display = 'flex'
container.style.alignItems = 'center'
container.style.justifyContent = 'center'
container.style.gap = '8px'
container.style.height = '100%'
// container.style.padding = '0 8px'
// 文字部分
const textSpan = document.createElement('span')
// console.log('displayValue',displayValue)
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 colMeta = instance.getCellMeta(row, col)
// if (colMeta?.readOnly) {
// return
// }
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
}
}
import { useAccess } from '@vben/access';
import { useAccessStore } from '@vben/stores';
const { hasAccessByCodes } = useAccess();
const accessStore = useAccessStore();
const isQuery = hasAccessByCodes(['core:info-price:query'])
const isDelete = hasAccessByCodes(['core:info-price:delete'])
// const isCreate = hasAccessByCodes(['core:info-price:create'])
// const isUpdate = hasAccessByCodes(['core:info-price:update'])
const isFinish = hasAccessByCodes(['core:info-price:finish'])
// const isPublish = hasAccessByCodes(['core:info-price:publish'])
// 附件渲染器
export function createAttachmentRenderer(callbacks: AttachmentCallbacks) {
return function attachmentRenderer(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 = 'center'
container.style.gap = '4px'
container.style.flexWrap = 'wrap'
const rowData = instance.getSourceDataAtRow(row)
if (!value) {
// 无附件时显示"上传"
const uploadSpan = document.createElement('span')
uploadSpan.textContent = '上传'
uploadSpan.style.cssText = `
cursor: pointer;
color: #409eff;
font-size: 12px;
padding: 2px 6px;
transition: all 0.3s;
white-space: nowrap;
`
uploadSpan.addEventListener('mouseenter', () => {
uploadSpan.style.color = '#66b1ff'
})
uploadSpan.addEventListener('mouseleave', () => {
uploadSpan.style.color = '#409eff'
})
uploadSpan.addEventListener('click', (e) => {
e.stopPropagation()
const rowData = instance.getSourceDataAtRow(row)
if (callbacks.onUpload) {
callbacks.onUpload(row, rowData)
}
})
if(rowData?.publishStatus === 'uncompleted' || accessStore.tenantId == 1) container.appendChild(uploadSpan)
} else {
// 有附件时显示"预览"和"删除"
const downloadSpan = document.createElement('span')
downloadSpan.textContent = '预览'
downloadSpan.style.cssText = `
cursor: pointer;
color: #409eff;
font-size: 12px;
padding: 2px 6px;
transition: all 0.3s;
white-space: nowrap;
`
downloadSpan.addEventListener('mouseenter', () => {
downloadSpan.style.color = '#66b1ff'
})
downloadSpan.addEventListener('mouseleave', () => {
downloadSpan.style.color = '#409eff'
})
downloadSpan.addEventListener('click', (e) => {
e.stopPropagation()
const rowData = instance.getSourceDataAtRow(row)
if (callbacks.onDownload) {
callbacks.onDownload(row, rowData)
}
})
const deleteSpan = document.createElement('span')
deleteSpan.textContent = '删除'
deleteSpan.style.cssText = `
cursor: pointer;
color: #f56c6c;
font-size: 12px;
padding: 2px 6px;
transition: all 0.3s;
white-space: nowrap;
`
deleteSpan.addEventListener('mouseenter', () => {
deleteSpan.style.color = '#f78989'
})
deleteSpan.addEventListener('mouseleave', () => {
deleteSpan.style.color = '#f56c6c'
})
deleteSpan.addEventListener('click', (e) => {
e.stopPropagation()
if (callbacks.onDelete) {
callbacks.onDelete(row, rowData)
}
})
container.appendChild(downloadSpan)
if(rowData?.publishStatus === 'uncompleted' || accessStore.tenantId == 1) container.appendChild(deleteSpan)
}
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 = 'left'
container.style.alignItems = 'center'
container.style.flexWrap = 'wrap'
// 获取行数据以判断状态
const rowData = instance.getSourceDataAtRow(row)
const publishStatus = rowData?.publishStatus
// 根据 publishStatus 状态决定按钮显示
// const isUncompleted = publishStatus === 'uncompleted'
// const isUnpublished = publishStatus === 'completed' || publishStatus === 'unpublished'
const actions = [
// { text: '附件上传', color: '#409eff', callback: callbacks.onUpload, show: true },
// { text: '已发布', color: '#409eff',callback: callbacks.onPublished, show: publishStatus == 'published' },
]
if(accessStore.tenantId != 1){
actions.push(
{ text: '编辑', color: '#409eff', callback: callbacks.onView, show: isQuery },
// { text: '删除', color: '#409eff', callback: callbacks.onDelete, show: publishStatus === 'uncompleted' && isDelete },
{ text: '完成', color: '#409eff', callback: callbacks.onComplete, show: publishStatus === 'uncompleted' && isFinish },
{ text: '已完成', color: '#409eff', callback: callbacks.onCompleted, show: publishStatus === 'completed' },
)
}else{
actions.push(
{ text: '编辑', color: '#409eff', callback: callbacks.onView, show: isQuery },
// { text: '删除', color: '#409eff', callback: callbacks.onDelete, show: isDelete },
{ text: '未发布', color: '#409eff', callback: callbacks.onPublish, show: publishStatus === 'unpublished' || publishStatus === 'uncompleted' },
{ text: '已发布', color: '#409eff',callback: callbacks.onPublished, show: publishStatus == 'published' },
)
}
actions.forEach(action => {
if (!action.show) return
const btn = document.createElement('span')
btn.textContent = action.text
btn.style.cssText = `
cursor: pointer;
color: ${action.color};
font-size: 12px;
padding: 2px 6px;
transition: all 0.3s;
white-space: nowrap;
`
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 createRootMenus = (getEnumType: () => string) => [
{
key: 'add-root',
label: '添加总类',
callback: async (command: string, node: any, data: any, treeInstance: any) => {
//console.log('catalogsTreeId',catalogsTreeId.value)
const source = {
enumType: getEnumType(),
code: 'root-'+new Date().getTime(),
name: '总类',
sortOrder: treeInstance.getData().length + 1
}
const id = await createInfoPriceTreeNode(source)
treeInstance.addData({
id: id,
label: source.name,
children: [],
...source
})
// console.log(command, node, data,treeInstance.getData())
},
},
]
export const createNodeMenus = (getEnumType: () => string) => [
{
level: 1,
items: [
{
key: 'add-profession',
label: '添加省市',
callback: async (command: string, node: any, data: any, treeInstance: any) => {
// console.log(command, node, data)
const source = {
enumType: getEnumType(),
parentId: data.id,
code: 'profession-'+new Date().getTime(),
name: '省市',
sortOrder: data.children.length + 1,
}
// console.log(source)
const id = await createInfoPriceTreeNode(source)
treeInstance.addData({
id: String(id),
label: source.name,
children: [],
...source
})
},
},
{
key: 'remove-root',
label: '删除',
divided: true,
callback: async (command: string, node: any, data: any, treeInstance: any) => {
// console.log(command, node, data)
// 调用 API 删除根节点
await deleteInfoPriceTreeNode(node.id)
treeInstance.removeData()
ElMessage.success('删除成功')
},
},
],
},
{
level: 2,
items: [
{
key: 'add-city',
label: '添加地市',
callback: async (command: string, node: any, data: any, treeInstance: any) => {
//console.log(command, node, data)
const source = {
enumType: getEnumType(),
parentId: data.id,
code: 'city-'+new Date().getTime(),
name: '地市',
sortOrder: data.children.length + 1,
}
const id = await createInfoPriceTreeNode(source)
treeInstance.addData({
id: String(id),
label: source.name,
children: [],
...source
})
},
},
{
key: 'remove-profession',
label: '删除',
divided: true,
callback: async (command: string, node: any, data: any, treeInstance: any) => {
// console.log(command, node, data)
// 调用 API 删除省市节点
await deleteInfoPriceTreeNode(data.id)
treeInstance.removeData()
ElMessage.success('删除成功')
},
},
],
},
{
level: 3,
items: [
{
key: 'add-region',
label: '添加地区',
callback: async (command: string, node: any, data: any, treeInstance: any) => {
//console.log(command, node, data)
const source = {
enumType: getEnumType(),
parentId: data.id,
code: 'region-'+new Date().getTime(),
name: '地区',
sortOrder: data.children.length + 1,
}
const id = await createInfoPriceTreeNode(source)
treeInstance.addData({
id: String(id),
label: source.name,
children: [],
...source
})
},
},
{
key: 'remove-city',
label: '删除',
divided: true,
callback: async (command: string, node: any, data: any, treeInstance: any) => {
// console.log(command, node, data)
// 调用 API 删除省市节点
await deleteInfoPriceTreeNode(data.id)
treeInstance.removeData()
ElMessage.success('删除成功')
},
},
],
},
{
level: 4,
items: [
{
key: 'add-region-child-up',
label: '上方添加地区',
callback: async (command: string, node: any, data: any, treeInstance: any) => {
//console.log(command, node, data)
const insertIndex = node.parent.children.findIndex((item: any) => item.data.id === data.id)
// 创建新节点数据
const source = {
enumType: getEnumType(),
parentId: node.parent.data.id,
code: 'city-'+new Date().getTime(),
name: '地区',
sortOrder: (insertIndex == -1 || insertIndex == 0) ? 0 : insertIndex-1,
}
// console.log('insertIndex', insertIndex,source)
const id = await createInfoPriceTreeNode(source)
treeInstance.insertAbove({
id: String(id),
label: source.name,
children: [],
...source
})
},
},
{
key: 'add-region-child-down',
label: '下方添加地区',
callback: async (command: string, node: any, data: any, treeInstance: any) => {
// console.log(command, node, data)
const insertIndex = node.parent.children.findIndex((item: any) => item.data.id === data.id)
// 创建新节点数据
const source = {
enumType: getEnumType(),
parentId: node.parent.data.id,
code: 'region-'+new Date().getTime(),
name: '地区',
sortOrder: (insertIndex == -1) ? 0 : insertIndex+1,
}
// console.log('insertIndex', insertIndex,source.sortOrder)
const id = await createInfoPriceTreeNode(source)
treeInstance.insertBelow({
id: String(id),
label: source.name,
children: [],
...source
})
},
},
{
key: 'remove-region',
label: '删除',
divided: true,
callback: async (command: string, node: any, data: any, treeInstance: any) => {
//console.log(command, node, data,treeInstance)
// 调用 API 删除节点
await deleteInfoPriceTreeNode(data.id)
treeInstance.removeData()
ElMessage.success('删除成功')
},
},
],
},
]
// 操作列文字渲染器(居中显示,可点击)
export interface TextRendererCallbacks {
onSelect?: (row: number, data: any) => void
}
export function createTextRenderer(text: string, callbacks?: TextRendererCallbacks) {
return function textRenderer(
instance: any,
td: HTMLElement,
row: number,
_col: number,
_prop: string,
_value: any,
_cellProperties: any
) {
td.innerHTML = ''
td.style.textAlign = 'center'
td.style.verticalAlign = 'middle'
const span = document.createElement('span')
span.textContent = text
span.style.cssText = `
cursor: pointer;
color: #409eff;
font-size: 12px;
`
span.addEventListener('click', (e) => {
e.stopPropagation()
const rowData = instance.getSourceDataAtRow(row)
if(rowData?.id){
if (callbacks?.onSelect) {
callbacks.onSelect(row, rowData)
}
}
})
td.appendChild(span)
return td
}
}
// 点击外部关闭 popover 的处理函数工厂
export function createClickOutsideHandler(
getVisibleState: () => boolean,
setVisibleState: (value: boolean) => void
) {
return (e: MouseEvent) => {
if (getVisibleState()) {
const target = e.target as HTMLElement
// 检查点击是否在 popover 或触发元素内
const popoverEl = document.querySelector('.el-popover')
const isClickInside = popoverEl?.contains(target) ||
target.closest('.el-popover') ||
target.closest('svg') // 日历图标
if (!isClickInside) {
setVisibleState(false)
}
}
}
}
// 时间段工具函数
export const parseDateRange = (str: string): [Date, Date] | null => {
if (!str || typeof str !== 'string') return null
const [s, e] = str.split(' ~ ')
const start = new Date(s), end = new Date(e)
return isNaN(start.getTime()) || isNaN(end.getTime()) ? null : [start, end]
}
export const hasDateOverlap = (r1: [Date, Date], r2: [Date, Date]) => !(r1[1] < r2[0] || r1[0] > r2[1])
export const checkDateRangeOverlap = (start: string, end: string, currentRow: number, existingRanges: string[]) => {
const newRange: [Date, Date] = [new Date(start), new Date(end)]
return existingRanges.some((range, i) => {
if (i === currentRow) return false
const existing = parseDateRange(range)
return existing && hasDateOverlap(newRange, existing)
})
}
export function createContextMenuItems(getSelectedTreeNodeId: () => any) {
return [
{
key: 'row_above',
name: '新增行',
callback: (hotInstance: any) => {
// 判断是否选中了树节点
const treeNodeId = getSelectedTreeNodeId()
if (!treeNodeId) {
ElMessage.warning('请先选择左侧树节点')
return
}
// 执行新增行操作
hotInstance.alter('insert_row_below', hotInstance.countRows(), 1)
// 等待 DOM 更新后重新渲染以应用验证样式
nextTick(() => {
hotInstance.render()
})
}
},
]
}

View File

@@ -0,0 +1,209 @@
<script setup lang="ts">
import { DbHst } from '#/components/db-hst';
import { DbTree } from '#/components/db-tree';
import { Page } from '@vben/common-ui';
import { useAccess } from '@vben/access';
import { ElButton, ElCard, ElDatePicker, ElDatePickerPanel, ElForm, ElFormItem, ElMessage, ElOption, ElPopover, ElSelect, ElSplitter, ElSplitterPanel } from 'element-plus';
import { onMounted, onUnmounted, ref } from 'vue';
import AddDialog from './components/AddDialog.vue';
import AttachmentDialog from './components/AttachmentDialog.vue';
import CopyDialog from './components/CopyDialog.vue';
import FileUploadDialog from './components/FileUploadDialog.vue';
import { useDbHst } from './dbHst';
import { useDbTree } from './dbTree';
const { hasAccessByCodes } = useAccess();
const dbTreeRef = ref();
const hstRef = ref();
const {
selectedSpecialty,
specialtyOptions,
treeData,
selectedTreeNodeId,
rootMenus,
nodeMenus,
handleSpecialtyChange,
handleTreeNodeSelect,
handleTreeNodeEdit,
initTree
} = useDbTree(dbTreeRef, {
onNodeSelect: (treeNodeId) => {
loadBookData(treeNodeId);
}
});
const currentUploadRowData = ref<any>(null);
const attachmentDialogVisible = ref(false);
const addDialogVisible = ref(false);
const copyDialogVisible = ref(false);
const fileUploadDialogVisible = ref(false);
const {
hstSettings,
hstContextMenuItems,
popoverDateRangeRef,
visibleDateRange,
popoverDate,
handleClickOutside,
handleCalendarChange,
loadBookData,
handleSearch: hstHandleSearch,
handleFileUploadSuccess
} = useDbHst(hstRef, {
getTreeNodeId: () => selectedTreeNodeId.value,
getSelectedSpecialty: () => selectedSpecialty.value,
onFileUploadClick: (data) => {
currentUploadRowData.value = data;
fileUploadDialogVisible.value = true;
}
});
const searchText = ref('');
const handleSearch = () => {
hstHandleSearch(searchText);
};
const handleReset = () => {
searchText.value = '';
handleSearch();
};
const handleAttachment = () => {
attachmentDialogVisible.value = true;
};
const handleAdd = () => {
addDialogVisible.value = true;
};
const handleCopy = () => {
if (selectedTreeNodeId.value == null) {
ElMessage.error('请选择左侧的地区');
return;
}
copyDialogVisible.value = true;
};
const onFileUploadSuccess = (url: string, file: any) => {
console.log('文件上传成功:', url, file);
handleFileUploadSuccess(url, currentUploadRowData.value);
};
onMounted(() => {
initTree();
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<template>
<Page auto-content-height>
<ElSplitter>
<ElSplitterPanel collapsible size="15%" :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full flex flex-col">
<el-form label-position="left" label-width="auto" style="padding: 0px 5px;">
<el-form-item label="专业" style="margin-bottom: 0px;">
<el-select v-model="selectedSpecialty" placeholder="请选择专业" size="small" @change="handleSpecialtyChange"
style="width: 100%;">
<el-option v-for="item in specialtyOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
<DbTree ref="dbTreeRef" :tree-data="treeData" :root-menus="rootMenus" :node-menus="nodeMenus"
@node-edit="handleTreeNodeEdit" @node-click="handleTreeNodeSelect" :defaultExpandedLevel="4" />
</ElCard>
</ElSplitterPanel>
<ElSplitterPanel collapsible :min="200">
<ElCard class="w-full h-full border-radius-0" body-class="!p-0 h-full w-full flex flex-col">
<div class="flex items-center justify-between p-1">
<div class="flex gap-2">
<ElDatePicker v-model="searchText" type="daterange" start-placeholder="开始时间" end-placeholder="结束时间"
value-format="YYYY-MM-DD" size="small" style="width: 300px" />
<ElButton @click="handleSearch" type="primary" size="small">查询</ElButton>
<ElButton @click="handleReset" type="info" size="small">重置</ElButton>
</div>
<div class="flex gap-2">
<!-- <ElButton @click="handleAttachment" type="primary" size="small">附件</ElButton> -->
<ElButton @click="handleAdd" type="primary" size="small"
v-if="hasAccessByCodes(['core:info-price:create'])">新增
</ElButton>
<ElButton @click="handleCopy" type="primary" size="small"
v-if="hasAccessByCodes(['core:info-price:publish'])">
查看租户录入</ElButton>
<!-- <ElButton @click="handleHistory" type="primary" size="small">调用历史信息</ElButton> -->
</div>
</div>
<DbHst ref="hstRef" :settings="hstSettings" :contextMenuItems="hstContextMenuItems"></DbHst>
</ElCard>
</ElSplitterPanel>
</ElSplitter>
<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>
<!-- 附件管理对话框 -->
<AttachmentDialog v-model:visible="attachmentDialogVisible" :tree-node-id="selectedTreeNodeId"
@success="loadBookData(selectedTreeNodeId)" />
<!-- 新增对话框 -->
<AddDialog v-model:visible="addDialogVisible" :tree-node-id="selectedTreeNodeId"
:catalog-version="selectedSpecialty" @success="loadBookData(selectedTreeNodeId)" />
<!-- 复制软件商对话框 -->
<CopyDialog v-model:visible="copyDialogVisible" :tree-node-id="selectedTreeNodeId" @success="handleSearch" />
<!-- 文件上传对话框 -->
<FileUploadDialog v-model:visible="fileUploadDialogVisible" :row-data="currentUploadRowData"
@success="onFileUploadSuccess" />
</Page>
</template>
<style lang="css">
.el-card__header {
padding: 5px;
}
.upload-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.image-preview {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background-color: #f5f7fa;
}
.image-preview img {
max-width: 100%;
max-height: 300px;
object-fit: contain;
}
.upload-demo {
width: 100%;
}
.preview-image-container {
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More