第二阶段代码
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -49,4 +49,7 @@ vite.config.ts.*
|
||||
*.sln
|
||||
*.sw?
|
||||
.history
|
||||
|
||||
**/ele-docs
|
||||
docs/
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -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
|
||||
└
|
||||
现在,你可以在浏览器访问
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
16
apps/web-ele/public/static/js/pdf-lib.min.js
vendored
Normal file
16
apps/web-ele/public/static/js/pdf-lib.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 495 KiB |
290
apps/web-ele/src/api/database/info/index.ts
Normal file
290
apps/web-ele/src/api/database/info/index.ts
Normal 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 });
|
||||
}
|
||||
|
||||
186
apps/web-ele/src/api/database/interface/cardinalRate.ts
Normal file
186
apps/web-ele/src/api/database/interface/cardinalRate.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
132
apps/web-ele/src/api/database/interface/config.ts
Normal file
132
apps/web-ele/src/api/database/interface/config.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
8
apps/web-ele/src/api/database/interface/project.ts
Normal file
8
apps/web-ele/src/api/database/interface/project.ts
Normal 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`);
|
||||
}
|
||||
263
apps/web-ele/src/api/database/list/index.ts
Normal file
263
apps/web-ele/src/api/database/list/index.ts
Normal 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}`);
|
||||
}
|
||||
@@ -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>>(
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
72
apps/web-ele/src/api/database/quota/marketMaterials.ts
Normal file
72
apps/web-ele/src/api/database/quota/marketMaterials.ts
Normal 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 });
|
||||
}
|
||||
88
apps/web-ele/src/api/database/quota/variableSetting.ts
Normal file
88
apps/web-ele/src/api/database/quota/variableSetting.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
@@ -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=工料机(返回类别ID),fee=定额取费(返回对象)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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` 配置与自定义菜单会自动互斥
|
||||
@@ -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)
|
||||
69
apps/web-ele/src/components/db-hst/checkbox.ts
Normal file
69
apps/web-ele/src/components/db-hst/checkbox.ts
Normal 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)
|
||||
}
|
||||
};
|
||||
}
|
||||
32
apps/web-ele/src/components/db-hst/command.ts
Normal file
32
apps/web-ele/src/components/db-hst/command.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
79
apps/web-ele/src/components/db-hst/component/ColoumApply.vue
Normal file
79
apps/web-ele/src/components/db-hst/component/ColoumApply.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 || ''
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '"')
|
||||
|
||||
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(/"/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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
9
apps/web-ele/src/components/db-tree/base.ts
Normal file
9
apps/web-ele/src/components/db-tree/base.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
// }
|
||||
|
||||
|
||||
@@ -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>
|
||||
1
apps/web-ele/src/components/lazy-load/index.ts
Normal file
1
apps/web-ele/src/components/lazy-load/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LazyLoad } from './lazy-load.vue';
|
||||
40
apps/web-ele/src/components/lazy-load/lazy-load.vue
Normal file
40
apps/web-ele/src/components/lazy-load/lazy-load.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
47
apps/web-ele/src/views/database/demo/index.vue
Normal file
47
apps/web-ele/src/views/database/demo/index.vue
Normal 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>
|
||||
16
apps/web-ele/src/views/database/demo/use-hst.ts
Normal file
16
apps/web-ele/src/views/database/demo/use-hst.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
26
apps/web-ele/src/views/database/demo/use-tree.ts
Normal file
26
apps/web-ele/src/views/database/demo/use-tree.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
35
apps/web-ele/src/views/database/feature/bottomHst.ts
Normal file
35
apps/web-ele/src/views/database/feature/bottomHst.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
57
apps/web-ele/src/views/database/feature/dbTree.ts
Normal file
57
apps/web-ele/src/views/database/feature/dbTree.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
35
apps/web-ele/src/views/database/feature/topHst.ts
Normal file
35
apps/web-ele/src/views/database/feature/topHst.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
338
apps/web-ele/src/views/database/info/add/add.ts
Normal file
338
apps/web-ele/src/views/database/info/add/add.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
},
|
||||
]
|
||||
229
apps/web-ele/src/views/database/info/add/dbHst.ts
Normal file
229
apps/web-ele/src/views/database/info/add/dbHst.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
91
apps/web-ele/src/views/database/info/add/dbTree.ts
Normal file
91
apps/web-ele/src/views/database/info/add/dbTree.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
1
apps/web-ele/src/views/database/info/add/index.ts
Normal file
1
apps/web-ele/src/views/database/info/add/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './index.vue';
|
||||
345
apps/web-ele/src/views/database/info/add/index.vue
Normal file
345
apps/web-ele/src/views/database/info/add/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
214
apps/web-ele/src/views/database/info/components/CopyDialog.vue
Normal file
214
apps/web-ele/src/views/database/info/components/CopyDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HistoryDialog } from './index.vue';
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default as MorePriceDialog } from './index.vue';
|
||||
@@ -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>
|
||||
243
apps/web-ele/src/views/database/info/components/pdf.ts
Normal file
243
apps/web-ele/src/views/database/info/components/pdf.ts
Normal 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,
|
||||
}
|
||||
401
apps/web-ele/src/views/database/info/dbHst.ts
Normal file
401
apps/web-ele/src/views/database/info/dbHst.ts
Normal 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
|
||||
};
|
||||
};
|
||||
117
apps/web-ele/src/views/database/info/dbTree.ts
Normal file
117
apps/web-ele/src/views/database/info/dbTree.ts
Normal 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
|
||||
};
|
||||
};
|
||||
626
apps/web-ele/src/views/database/info/index.ts
Normal file
626
apps/web-ele/src/views/database/info/index.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
209
apps/web-ele/src/views/database/info/index.vue
Normal file
209
apps/web-ele/src/views/database/info/index.vue
Normal 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>
|
||||
173
apps/web-ele/src/views/database/interface/cardinalRate.vue
Normal file
173
apps/web-ele/src/views/database/interface/cardinalRate.vue
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
]
|
||||
103
apps/web-ele/src/views/database/interface/cardinalRate/item.ts
Normal file
103
apps/web-ele/src/views/database/interface/cardinalRate/item.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
]
|
||||
166
apps/web-ele/src/views/database/interface/cardinalRate/tree.ts
Normal file
166
apps/web-ele/src/views/database/interface/cardinalRate/tree.ts
Normal 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),
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
95
apps/web-ele/src/views/database/interface/unit/Materials.vue
Normal file
95
apps/web-ele/src/views/database/interface/unit/Materials.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
97
apps/web-ele/src/views/database/interface/unit/Workbench.vue
Normal file
97
apps/web-ele/src/views/database/interface/unit/Workbench.vue
Normal 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>
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { createDivisionSettings } from './division'
|
||||
export { createMeasureSettings } from './measure'
|
||||
export { createOtherSettings } from './other'
|
||||
export { createUnitSummarySettings } from './summary'
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user