diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-custom-style_2025-12-01-07-29.json b/common/changes/@visactor/vtable/feat-filter-plugin-custom-style_2025-12-01-07-29.json new file mode 100644 index 0000000000..c1ee271a0c --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-custom-style_2025-12-01-07-29.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: scroll bug when update option", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-custom-style_2025-12-02-12-32.json b/common/changes/@visactor/vtable/feat-filter-plugin-custom-style_2025-12-02-12-32.json new file mode 100644 index 0000000000..8e091649cb --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-custom-style_2025-12-02-12-32.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "feat: add update styles api for filter plugin.close#4790", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-08-59.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-08-59.json new file mode 100644 index 0000000000..528d3230cc --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-08-59.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "feat: support custom styles. close#4720", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-11-45.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-11-45.json new file mode 100644 index 0000000000..a8e47d8ccc --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-11-45.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "feat: support custom conditionCategories. close#4781", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-12-00.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-12-00.json new file mode 100644 index 0000000000..fff83e6fe2 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-12-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: filter swtich enable erroe. fix#4783", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-12-31.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-12-31.json new file mode 100644 index 0000000000..65435cc87c --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-12-31.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "feat: emit event when filter menu hide or show. close#4784", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-12-44.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-12-44.json new file mode 100644 index 0000000000..838d05be18 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-12-44.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: apply filter after update table data. fix#4785", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-13-00.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-13-00.json new file mode 100644 index 0000000000..4a09adec33 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-03-13-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "feat: add option to format display value. close#4786", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-07-02.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-07-02.json new file mode 100644 index 0000000000..6551a912d4 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-07-02.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: update filter state and keys when update data. fix#4787", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-07-30.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-07-30.json new file mode 100644 index 0000000000..4be05f6552 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-07-30.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "feat: menu limit to body range. close#4791", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-07-34.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-07-34.json new file mode 100644 index 0000000000..7e5b5ce497 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-07-34.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: select none not effect. fix#4792", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-08-19.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-08-19.json new file mode 100644 index 0000000000..c1ea5c239b --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-08-19.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "feat: add config to disable sync multiple filter state. close#4793", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-08-27.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-08-27.json new file mode 100644 index 0000000000..7814b5890b --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-08-27.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: empty line bug", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-11-29.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-11-29.json new file mode 100644 index 0000000000..84d6a2027c --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-04-11-29.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: update checkbox state after update data. fix#4795", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-09-14-53.json b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-09-14-53.json new file mode 100644 index 0000000000..202c096cb3 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-filter-plugin-for-business_2025-12-09-14-53.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "add config to control filter result", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/common/changes/@visactor/vtable/pre-release-1.22.7-alpha.8_2025-12-10-11-49.json b/common/changes/@visactor/vtable/pre-release-1.22.7-alpha.8_2025-12-10-11-49.json new file mode 100644 index 0000000000..33642e2f80 --- /dev/null +++ b/common/changes/@visactor/vtable/pre-release-1.22.7-alpha.8_2025-12-10-11-49.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vtable", + "comment": "fix: panel hide when press enter. fix#4813", + "type": "none" + } + ], + "packageName": "@visactor/vtable" +} \ No newline at end of file diff --git a/docs/assets/guide/en/plugin/filter.md b/docs/assets/guide/en/plugin/filter.md index d206efaf37..a847f271ac 100644 --- a/docs/assets/guide/en/plugin/filter.md +++ b/docs/assets/guide/en/plugin/filter.md @@ -29,29 +29,51 @@ export interface FilterOptions { defaultEnabled?: boolean; /** Filter modes: value-based filtering, condition-based filtering */ filterModes?: FilterMode[]; + /** + * Filter styles + * If style configuration is updated, you need to additionally call `filterPlugin.updateStyles` before updating the chart + */ + styles?: FilterStyles; + /** Custom filter categories */ + conditionCategories?: FilterOperatorCategoryOption[]; + /** Custom filter option display format */ + checkboxItemFormat?: (rawValue: any, formatValue: any) => any; + /** Whether multiple filters are linked + * @default true + */ + syncFilterItemsState?: boolean; + /** Filter records end callback */ + onFilterRecordsEnd?: (records: any[]) => void; } ``` ### Configuration Parameters -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `id` | string | `filter-${Date.now()}` | Plugin instance unique identifier | -| `filterIcon` | ColumnIconOption | Default filter icon | Filter icon for inactive state | -| `filteringIcon` | ColumnIconOption | Default active icon | Filter icon for active state | -| `enableFilter` | function | - | Custom column filter enable logic | -| `defaultEnabled` | boolean | true | Default filter enabled state | -| `filterModes` | FilterMode[] | ['byValue', 'byCondition'] | Supported filter modes | +| Parameter | Type | Default | Description | +| ---------------------- | ---------------------------------------- | -------------------------- | ----------------------------------- | +| `id` | string | `filter-${Date.now()}` | Plugin instance unique identifier | +| `filterIcon` | ColumnIconOption | Default filter icon | Filter icon for inactive state | +| `filteringIcon` | ColumnIconOption | Default active icon | Filter icon for active state | +| `enableFilter` | function | - | Custom column filter enable logic | +| `defaultEnabled` | boolean | true | Default filter enabled state | +| `filterModes` | FilterMode[] | ['byValue', 'byCondition'] | Supported filter modes | +| `styles` | FilterStyles | - | Custom filter styles | +| `conditionCategories` | FilterOperatorCategoryOption[] | - | Custom filter categories | +| `checkboxItemFormat` | (rawValue: any, formatValue: any) => any | - | Custom filter option display format | +| `syncFilterItemsState` | boolean | true | Whether multiple filters are linked | +| `onFilterRecordsEnd` | (records: any[]) => void | - | Filter records end callback | ### Filter Operators The plugin supports the following filter operators: **General Operators** + - `equals` - Equals - `notEquals` - Not equals **Numeric Operators** + - `greaterThan` - Greater than - `lessThan` - Less than - `greaterThanOrEqual` - Greater than or equal @@ -60,6 +82,7 @@ The plugin supports the following filter operators: - `notBetween` - Not between **Text Operators** + - `contains` - Contains - `notContains` - Does not contain - `startsWith` - Starts with @@ -68,6 +91,7 @@ The plugin supports the following filter operators: - `notEndsWith` - Does not end with **Boolean Operators** + - `isChecked` - Is checked - `isUnchecked` - Is unchecked @@ -129,7 +153,7 @@ const filterPlugin = new FilterPlugin({ const columns = [ { field: 'name', title: 'Name', width: 120 }, // Default enable filtering { field: 'age', title: 'Age', width: 100, filter: false }, // Disable filtering - { field: 'department', title: 'Department', width: 150 }, // Default enable filtering + { field: 'department', title: 'Department', width: 150 } // Default enable filtering ]; ``` @@ -158,7 +182,7 @@ if (savedState) { // Normal usage: const filterPlugin = new FilterPlugin({}); // In the website editor, VTable.plugins is renamed to VTablePlugins -const generateDemoData = (count) => { +const generateDemoData = count => { const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance']; const statuses = ['Active', 'On Leave', 'Inactive']; @@ -184,8 +208,7 @@ const option = { { field: 'name', title: 'Name', width: 120 }, { field: 'age', title: 'Age', width: 100 }, { field: 'department', title: 'Department', width: 120 }, - { field: 'salary', title: 'Salary', width: 120, - fieldFormat: (record) => '$' + record.salary }, + { field: 'salary', title: 'Salary', width: 120, fieldFormat: record => '$' + record.salary }, { field: 'status', title: 'Status', width: 100 }, { field: 'isFullTime', title: 'Full Time', width: 80, cellType: 'checkbox' } ], @@ -196,6 +219,416 @@ const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID) window.tableInstance = tableInstance; ``` +## Large Screen Business Scenario Examples + +```javascript livedemo template=vtable +// import * as VTable from '@visactor/vtable'; +// When using, you need to import the plugin package @visactor/vtable-plugins +// import { FilterPlugin } from '@visactor/vtable-plugins'; +// Normal usage: const filterPlugin = new FilterPlugin({}); +// In the website editor, VTable.plugins is renamed to VTablePlugins +const columnStyle = { + textAlign: 'center', + borderColor: ['rgba(63,63,86,0)', null, null, null], + borderLineWidth: [1, 0, 0, 0], + borderLineDash: [null, null, null, null], + padding: [0, 0, 0, 0], + hover: { + cellBgColor: 'rgba(186, 215, 255, 0.7)', + inlineRowBgColor: 'rgba(186, 215, 255, 0.3)', + inlineColumnBgColor: 'rgba(186, 215, 255, 0.3)' + }, + fontFamily: 'D-DIN', + fontSize: 12, + fontStyle: 'normal', + fontWeight: 'normal', + fontVariant: 'normal', + color: 'rgba(255,255,255,1)', + lineHeight: 18, + underline: false +}; +const headerStyle = { + textAlign: 'center', + borderColor: [null, null, null, null], + borderLineWidth: [null, 0, 0, 0], + borderLineDash: [null, null, null, null], + padding: [0, 0, 0, 0], + hover: { + cellBgColor: 'rgba(0, 100, 250, 0.16)', + inlineRowBgColor: 'rgba(255, 255, 255, 0)', + inlineColumnBgColor: 'rgba(255, 255, 255, 0)' + }, + frameStyle: { + borderColor: [null, null, null, null], + borderLineWidth: 2 + }, + fontFamily: 'SourceHanSansCN-Normal', + fontSize: 12, + fontVariant: 'normal', + fontStyle: 'normal', + fontWeight: 'bold', + color: '#FFFFFF', + bgColor: '#0e305c', + lineHeight: 18, + underline: false +}; +// Special filter configuration: +// 1. syncFilterItemsState: +// - When set to false, it means: +// - The filter panel does not sync with data, the filter displays whatever conditions the user configures, and the filter result is the combined effect of multiple filters +// - For value filtering, after configuring and applying filters, when data is updated, new data will not be automatically added to the checked configuration, i.e., will not be selected +// 2. styles: Custom styles +// 3. conditionCategories: Filter types for condition-based filtering +// 4. checkboxItemFormat: Ensure filter options display as original values through callback +const getTableFilterPluginAttrFromProps = () => { + const filterProps = { + visible: false, + fillColor: '#0E1119', + strokeColor: '#272A30', + strokeWidth: 0, + borderRadius: 4, + highlightColor: '#006EFF', + defaultColor: '#FFF', + textStyle: { + color: '#FFF', + fontFamily: 'SourceHanSansCN-Normal', + fontSize: 12 + } + }; + const { + fillColor, + strokeColor, + strokeWidth, + borderRadius, + highlightColor, + defaultColor, + textStyle: { color: textColor, fontFamily: textFontFamily, fontSize: textFontSize, fontWeight: fontFontWeight } + } = filterProps; + return { + syncFilterItemsState: false, + filterModes: ['byValue', 'byCondition'], + filterIcon: { + name: 'filter-icon', + type: 'svg', + width: 12, + height: 12, + positionType: 'right', + cursor: 'pointer', + marginRight: 4, + svg: `` + }, + filteringIcon: { + name: 'filtering-icon', + type: 'svg', + width: 12, + height: 12, + positionType: 'right', + cursor: 'pointer', + marginRight: 4, + svg: `` + }, + styles: { + filterMenu: { + position: 'absolute', + backgroundColor: fillColor, + border: `${strokeWidth}px solid ${strokeColor}`, + boxShadow: '0 4px 8px rgba(0,0,0,0.15)', + zIndex: '100', + borderRadius: `${borderRadius}px`, + color: textColor, + fontFamily: textFontFamily, + fontSize: `${textFontSize}px` + }, + searchInput: { + width: '100%', + padding: '8px 10px', + border: '1px solid #272a30', + borderRadius: '4px', + backgroundColor: '#0e1119', + boxSizing: 'border-box', + placeholder: '请输入关键字搜索', + color: textColor + }, + tabsContainer: { + borderBottom: '0px solid #e0e0e0' + }, + + tabStyle: isActive => ({ + backgroundColor: 'transparent', + border: 'none', + flex: '1', + padding: '10px 15px', + cursor: 'pointer', + fontWeight: isActive ? 'bold' : 'normal', + color: isActive ? highlightColor : defaultColor, + borderBottom: isActive ? `3px solid ${highlightColor}` : '2px solid transparent' + }), + + countSpan: { + color: 'transparent' + }, + + footerContainer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '10px 15px', + borderTop: '0px solid #e0e0e0', + backgroundColor: 'transparent' + }, + + footerButton: isPrimary => ({ + padding: '6px 12px', + border: '1px solid #ccc', + borderRadius: '4px', + cursor: 'pointer', + marginLeft: '5px', + backgroundColor: isPrimary ? highlightColor : 'transparent', + color: isPrimary ? defaultColor : highlightColor, + borderColor: isPrimary ? highlightColor : 'transparent' + }), + + clearLink: { + color: highlightColor, + textDecoration: 'none' + }, + + buttonStyle: (isPrimary = false) => ({ + padding: '6px 12px', + border: '1px solid #ccc', + borderRadius: `${borderRadius}px`, + cursor: 'pointer', + marginLeft: '5px', + backgroundColor: isPrimary ? highlightColor : 'white', + color: isPrimary ? 'white' : textColor, + borderColor: isPrimary ? highlightColor : '#ccc' + }), + + conditionContainer: { + marginBottom: '15px', + padding: '10px', + backgroundColor: fillColor, + color: textColor + }, + + formLabel: { + display: 'block', + marginBottom: '8px', + fontWeight: 'normal', + backgroundColor: 'transparent', + color: textColor + }, + + operatorSelect: { + width: '100%', + padding: '8px', + marginBottom: '15px', + border: '1px solid #272a30', + borderRadius: '4px', + backgroundColor: '#0e1119', + color: textColor + }, + + rangeInputContainer: { + display: 'flex', + alignItems: 'center', + gap: '8px', + backgroundColor: fillColor, + color: textColor + } + }, + conditionCategories: [ + { value: 'all', label: '全部' }, + { value: 'text', label: '文本' }, + { value: 'number', label: '数值' } + // { value: FilterOperatorCategory.COLOR, label: '颜色' }, + // { value: FilterOperatorCategory.CHECKBOX, label: '复选框' }, + // { value: FilterOperatorCategory.RADIO, label: '单选框' } + ], + checkboxItemFormat: (rawValue, formatValue) => rawValue + }; +}; +const filterPlugin = new VTablePlugins.FilterPlugin(getTableFilterPluginAttrFromProps()); +const option = { + columns: [ + { + field: '0#LINE_NUMBER_DIM_ID_STR', + title: '序号', + width: '12%', + style: columnStyle + }, + { + field: 'PZwFghHcsvwt', + title: 'From Province', + showSort: false, + style: columnStyle, + headerStyle, + width: '29.333333333333332%' + }, + { + field: 'CKQPX3dQXKYk', + title: 'To Province', + showSort: false, + style: columnStyle, + headerStyle, + width: '29.333333333333332%' + }, + { + firstRow: 0.8, + field: 'RMLcpglcOTHo', + title: 'Profit', + showSort: false, + style: columnStyle, + headerStyle, + width: '29.333333333333332%', + fieldFormat: datum => { + return '¥' + datum['RMLcpglcOTHo']; + } + } + ], + records: [ + { + PZwFghHcsvwt: '河北', + CKQPX3dQXKYk: '河南', + RMLcpglcOTHo: 0.8, + '0#LINE_NUMBER_DIM_ID_STR': 1, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '山西', + CKQPX3dQXKYk: '湖北', + RMLcpglcOTHo: 15, + '0#LINE_NUMBER_DIM_ID_STR': 2, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '内蒙古', + CKQPX3dQXKYk: '湖南', + RMLcpglcOTHo: 50, + '0#LINE_NUMBER_DIM_ID_STR': 3, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '辽宁', + CKQPX3dQXKYk: '广东', + RMLcpglcOTHo: 15, + '0#LINE_NUMBER_DIM_ID_STR': 4, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '吉林', + CKQPX3dQXKYk: '广西', + RMLcpglcOTHo: 57, + '0#LINE_NUMBER_DIM_ID_STR': 5, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '江西', + CKQPX3dQXKYk: '湖南', + RMLcpglcOTHo: 44, + '0#LINE_NUMBER_DIM_ID_STR': 6, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '山东', + CKQPX3dQXKYk: '福建', + RMLcpglcOTHo: 20, + '0#LINE_NUMBER_DIM_ID_STR': 7, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '河南', + CKQPX3dQXKYk: '广东', + RMLcpglcOTHo: 65, + '0#LINE_NUMBER_DIM_ID_STR': 8, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '湖北', + CKQPX3dQXKYk: '江西', + RMLcpglcOTHo: 40, + '0#LINE_NUMBER_DIM_ID_STR': 9, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '湖南', + CKQPX3dQXKYk: '湖北', + RMLcpglcOTHo: 35, + '0#LINE_NUMBER_DIM_ID_STR': 10, + '0#LINE_NUMBER_ICON': + '' + } + ], + theme: { + underlayBackgroundColor: 'rgba(255,255,255,0)', + headerStyle, + bodyStyle: { + bgColor: ({ row, col }) => { + return row % 2 === 0 ? 'rgba(16,34,58,1)' : 'rgba(12,9,41,1)'; + } + } + }, + transpose: false, + widthMode: 'adaptive', + columnResizeMode: 'all', + heightMode: 'standard', + heightAdaptiveMode: 'all', + autoFillHeight: true, + autoWrapText: true, + maxCharactersNumber: 256, + defaultHeaderColWidth: 'auto', + keyboardOptions: { + selectAllOnCtrlA: true, + copySelected: false + }, + menu: { + renderMode: 'html' + }, + disableScroll: true, + customConfig: { + _disableColumnAndRowSizeRound: true, + imageMargin: 4, + multilinesForXTable: true, + shrinkSparklineFirst: true, + limitContentHeight: false + }, + frozenColCount: 0, + showHeader: true, + hover: { + disableHover: true + }, + select: { + highlightMode: 'row', + headerSelectMode: 'cell', + blankAreaClickDeselect: true, + disableSelect: true + }, + autoHeightInAdaptiveMode: false, + defaultRowHeight: 61.25, + animationAppear: { + duration: 100, + delay: 0, + type: 'one-by-one', + direction: 'row' + }, + hash: '0fa7dcedd7d638eff65f3a5bd3906361', + width: 400, + height: 245, + plugins: [filterPlugin] +}; +const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); +window.tableInstance = tableInstance; +``` + ## Usage Instructions 1. **Click filter icon**: Click the filter icon on the right side of the column header to open the filter panel @@ -208,9 +641,10 @@ window.tableInstance = tableInstance; - The filter plugin currently only supports `ListTable`, not `PivotTable` - When using column-level filter control, you need to add the `filter` property to column definitions -- Filter states are automatically synchronized when table configuration is updated +- `syncFilterItemsState` defaults to true, in which case the filter state will automatically sync when the table configuration is updated - It is recommended to enable filtering for large data tables to improve user experience +- If the style configuration is updated, you need to additionally call `filterPlugin.updateStyles` before updating the chart -# This plugin was contributed by +# This plugin was contributed by [PoorShawn](https://github.com/PoorShawn) diff --git a/docs/assets/guide/zh/plugin/filter.md b/docs/assets/guide/zh/plugin/filter.md index 394791bd6b..d054792784 100644 --- a/docs/assets/guide/zh/plugin/filter.md +++ b/docs/assets/guide/zh/plugin/filter.md @@ -29,29 +29,51 @@ export interface FilterOptions { defaultEnabled?: boolean; /** 筛选模式:按值筛选、按条件筛选 */ filterModes?: FilterMode[]; + /** + * 筛选器样式 + * 如果样式配置更新, 需在更新图表之前额外调用 `filterPlugin.updateStyles` + * */ + styles?: FilterStyles; + /** 自定义筛选分类 */ + conditionCategories?: FilterOperatorCategoryOption[]; + /** 自定义筛选选项展示格式 */ + checkboxItemFormat?: (rawValue: any, formatValue: any) => any; + /** 多个筛选器之间是否联动 + * @default true + */ + syncFilterItemsState?: boolean; + /** 筛选记录结束回调 */ + onFilterRecordsEnd?: (records: any[]) => void; } ``` ### 配置参数说明 -| 参数名 | 类型 | 默认值 | 说明 | -|--------|------|--------|------| -| `id` | string | `filter-${Date.now()}` | 插件实例唯一标识符 | -| `filterIcon` | ColumnIconOption | 默认筛选图标 | 未激活状态的筛选图标 | -| `filteringIcon` | ColumnIconOption | 默认激活图标 | 激活状态的筛选图标 | -| `enableFilter` | function | - | 自定义列筛选启用逻辑 | -| `defaultEnabled` | boolean | true | 默认是否启用筛选 | -| `filterModes` | FilterMode[] | ['byValue', 'byCondition'] | 支持的筛选模式 | +| 参数名 | 类型 | 默认值 | 说明 | +| ---------------------- | ---------------------------------------- | -------------------------- | ---------------------- | +| `id` | string | `filter-${Date.now()}` | 插件实例唯一标识符 | +| `filterIcon` | ColumnIconOption | 默认筛选图标 | 未激活状态的筛选图标 | +| `filteringIcon` | ColumnIconOption | 默认激活图标 | 激活状态的筛选图标 | +| `enableFilter` | function | - | 自定义列筛选启用逻辑 | +| `defaultEnabled` | boolean | true | 默认是否启用筛选 | +| `filterModes` | FilterMode[] | ['byValue', 'byCondition'] | 支持的筛选模式 | +| `styles` | FilterStyles | - | 自定义筛选器样式 | +| `conditionCategories` | FilterOperatorCategoryOption[] | - | 自定义筛选分类 | +| `checkboxItemFormat` | (rawValue: any, formatValue: any) => any | - | 自定义筛选选项展示格式 | +| `syncFilterItemsState` | boolean | true | 多个筛选器之间是否联动 | +| `onFilterRecordsEnd` | (records: any[]) => void | - | 筛选记录结束回调 | ### 筛选操作符 插件支持以下筛选操作符: **通用操作符** + - `equals` - 等于 - `notEquals` - 不等于 **数值操作符** + - `greaterThan` - 大于 - `lessThan` - 小于 - `greaterThanOrEqual` - 大于等于 @@ -60,6 +82,7 @@ export interface FilterOptions { - `notBetween` - 不介于 **文本操作符** + - `contains` - 包含 - `notContains` - 不包含 - `startsWith` - 开始于 @@ -68,6 +91,7 @@ export interface FilterOptions { - `notEndsWith` - 不结束于 **布尔操作符** + - `isChecked` - 已选中 - `isUnchecked` - 未选中 @@ -129,7 +153,7 @@ const filterPlugin = new FilterPlugin({ const columns = [ { field: 'name', title: '姓名', width: 120 }, // 默认启用筛选 { field: 'age', title: '年龄', width: 100, filter: false }, // 禁用筛选 - { field: 'department', title: '部门', width: 150 }, // 默认启用筛选 + { field: 'department', title: '部门', width: 150 } // 默认启用筛选 ]; ``` @@ -158,7 +182,7 @@ if (savedState) { // 正常使用方式 const filterPlugin = new FilterPlugin({}); // 官网编辑器中将 VTable.plugins 重命名成了 VTablePlugins -const generateDemoData = (count) => { +const generateDemoData = count => { const departments = ['研发部', '市场部', '销售部', '人事部', '财务部']; const statuses = ['在职', '请假', '离职']; @@ -184,8 +208,7 @@ const option = { { field: 'name', title: '姓名', width: 120 }, { field: 'age', title: '年龄', width: 100 }, { field: 'department', title: '部门', width: 120 }, - { field: 'salary', title: '薪资', width: 120, - fieldFormat: (record) => '¥' + record.salary }, + { field: 'salary', title: '薪资', width: 120, fieldFormat: record => '¥' + record.salary }, { field: 'status', title: '状态', width: 100 }, { field: 'isFullTime', title: '全职', width: 80, cellType: 'checkbox' } ], @@ -196,6 +219,430 @@ const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID) window.tableInstance = tableInstance; ``` +## 大屏业务场景案例 + +```javascript livedemo template=vtable +// import * as VTable from '@visactor/vtable'; +// 使用时需要引入插件包 @visactor/vtable-plugins +// import { FilterPlugin } from '@visactor/vtable-plugins'; +// 正常使用方式 const filterPlugin = new FilterPlugin({}); +// 官网编辑器中将 VTable.plugins 重命名成了 VTablePlugins + +const columnStyle = { + textAlign: 'center', + borderColor: ['rgba(63,63,86,0)', null, null, null], + borderLineWidth: [1, 0, 0, 0], + borderLineDash: [null, null, null, null], + padding: [0, 0, 0, 0], + hover: { + cellBgColor: 'rgba(186, 215, 255, 0.7)', + inlineRowBgColor: 'rgba(186, 215, 255, 0.3)', + inlineColumnBgColor: 'rgba(186, 215, 255, 0.3)' + }, + fontFamily: 'D-DIN', + fontSize: 12, + fontStyle: 'normal', + fontWeight: 'normal', + fontVariant: 'normal', + color: 'rgba(255,255,255,1)', + lineHeight: 18, + underline: false +}; +const headerStyle = { + textAlign: 'center', + borderColor: [null, null, null, null], + borderLineWidth: [null, 0, 0, 0], + borderLineDash: [null, null, null, null], + padding: [0, 0, 0, 0], + hover: { + cellBgColor: 'rgba(0, 100, 250, 0.16)', + inlineRowBgColor: 'rgba(255, 255, 255, 0)', + inlineColumnBgColor: 'rgba(255, 255, 255, 0)' + }, + frameStyle: { + borderColor: [null, null, null, null], + borderLineWidth: 2 + }, + fontFamily: 'SourceHanSansCN-Normal', + fontSize: 12, + fontVariant: 'normal', + fontStyle: 'normal', + fontWeight: 'bold', + color: '#FFFFFF', + bgColor: '#0e305c', + lineHeight: 18, + underline: false +}; +// filter特殊配置: +// 1. syncFilterItemsState: +// - 配置为false, 表示: +// - 筛选面板不同步数据, 用户配置什么条件筛选器就回显什么条件, 筛选结果为多个筛选器共同作用的结果 +// - 对于值筛选而言, 配置筛选且生效后, 更新数据, 则新数据不会被自动加到已勾选配置中, 即不被选中 +// 2. styles: 自定义样式 +// 3. conditionCategories: 条件筛选的筛选类型 +// 4. checkboxItemFormat: 通过回调保证筛选项展示为原始值 +const getTableFilterPluginAttrFromProps = () => { + const filterProps = { + visible: false, + fillColor: '#0E1119', + strokeColor: '#272A30', + strokeWidth: 0, + borderRadius: 4, + highlightColor: '#006EFF', + defaultColor: '#FFF', + textStyle: { + color: '#FFF', + fontFamily: 'SourceHanSansCN-Normal', + fontSize: 12 + } + }; + const { + fillColor, + strokeColor, + strokeWidth, + borderRadius, + highlightColor, + defaultColor, + textStyle: { color: textColor, fontFamily: textFontFamily, fontSize: textFontSize, fontWeight: fontFontWeight } + } = filterProps; + return { + syncFilterItemsState: false, + filterModes: ['byValue', 'byCondition'], + filterIcon: { + name: 'filter-icon', + type: 'svg', + width: 12, + height: 12, + positionType: 'right', + cursor: 'pointer', + marginRight: 4, + svg: `` + }, + filteringIcon: { + name: 'filtering-icon', + type: 'svg', + width: 12, + height: 12, + positionType: 'right', + cursor: 'pointer', + marginRight: 4, + svg: `` + }, + styles: { + // 筛选菜单 + filterMenu: { + position: 'absolute', + backgroundColor: fillColor, + border: `${strokeWidth}px solid ${strokeColor}`, + boxShadow: '0 4px 8px rgba(0,0,0,0.15)', + zIndex: '100', + borderRadius: `${borderRadius}px`, + color: textColor, + fontFamily: textFontFamily, + fontSize: `${textFontSize}px` + }, + + // 搜索输入框 + searchInput: { + width: '100%', + padding: '8px 10px', + border: '1px solid #272a30', + borderRadius: '4px', + backgroundColor: '#0e1119', + boxSizing: 'border-box', + // vtable内部没有做国际化, 所以这里也不做国际化 + placeholder: '请输入关键字搜索', + color: textColor + }, + tabsContainer: { + borderBottom: '0px solid #e0e0e0' + }, + + // 标签样式 + tabStyle: isActive => ({ + backgroundColor: 'transparent', + border: 'none', + flex: '1', + padding: '10px 15px', + cursor: 'pointer', + fontWeight: isActive ? 'bold' : 'normal', + color: isActive ? highlightColor : defaultColor, + borderBottom: isActive ? `3px solid ${highlightColor}` : '2px solid transparent' + }), + + countSpan: { + color: 'transparent' + }, + + // 页脚容器 + footerContainer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '10px 15px', + borderTop: '0px solid #e0e0e0', + backgroundColor: 'transparent' + }, + + footerButton: isPrimary => ({ + padding: '6px 12px', + border: '1px solid #ccc', + borderRadius: '4px', + cursor: 'pointer', + marginLeft: '5px', + backgroundColor: isPrimary ? highlightColor : 'transparent', + color: isPrimary ? defaultColor : highlightColor, + borderColor: isPrimary ? highlightColor : 'transparent' + }), + + // 清除链接 + clearLink: { + color: highlightColor, + textDecoration: 'none' + }, + + // 按钮样式 + buttonStyle: (isPrimary = false) => ({ + padding: '6px 12px', + border: '1px solid #ccc', + borderRadius: `${borderRadius}px`, + cursor: 'pointer', + marginLeft: '5px', + backgroundColor: isPrimary ? highlightColor : 'white', + color: isPrimary ? 'white' : textColor, + borderColor: isPrimary ? highlightColor : '#ccc' + }), + + // === 条件筛选相关样式 === + + // 条件筛选容器 + conditionContainer: { + marginBottom: '15px', + padding: '10px', + backgroundColor: fillColor, + color: textColor + }, + + // 表单标签样式 + formLabel: { + display: 'block', + marginBottom: '8px', + fontWeight: 'normal', + backgroundColor: 'transparent', + color: textColor + }, + + // 操作符选择框样式 + operatorSelect: { + width: '100%', + padding: '8px', + marginBottom: '15px', + border: '1px solid #272a30', + borderRadius: '4px', + backgroundColor: '#0e1119', + color: textColor + }, + + rangeInputContainer: { + display: 'flex', + alignItems: 'center', + gap: '8px', + backgroundColor: fillColor, + color: textColor + } + }, + conditionCategories: [ + { value: 'all', label: '全部' }, + { value: 'text', label: '文本' }, + { value: 'number', label: '数值' } + // { value: FilterOperatorCategory.COLOR, label: '颜色' }, + // { value: FilterOperatorCategory.CHECKBOX, label: '复选框' }, + // { value: FilterOperatorCategory.RADIO, label: '单选框' } + ], + checkboxItemFormat: (rawValue, formatValue) => rawValue + }; +}; +const filterPlugin = new VTablePlugins.FilterPlugin(getTableFilterPluginAttrFromProps()); +const option = { + columns: [ + { + field: '0#LINE_NUMBER_DIM_ID_STR', + title: '序号', + width: '12%', + style: columnStyle + }, + { + field: 'PZwFghHcsvwt', + title: 'From Province', + showSort: false, + style: columnStyle, + headerStyle, + width: '29.333333333333332%' + }, + { + field: 'CKQPX3dQXKYk', + title: 'To Province', + showSort: false, + style: columnStyle, + headerStyle, + width: '29.333333333333332%' + }, + { + firstRow: 0.8, + field: 'RMLcpglcOTHo', + title: 'Profit', + showSort: false, + style: columnStyle, + headerStyle, + width: '29.333333333333332%', + fieldFormat: datum => { + return '¥' + datum['RMLcpglcOTHo']; + } + } + ], + records: [ + { + PZwFghHcsvwt: '河北', + CKQPX3dQXKYk: '河南', + RMLcpglcOTHo: 0.8, + '0#LINE_NUMBER_DIM_ID_STR': 1, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '山西', + CKQPX3dQXKYk: '湖北', + RMLcpglcOTHo: 15, + '0#LINE_NUMBER_DIM_ID_STR': 2, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '内蒙古', + CKQPX3dQXKYk: '湖南', + RMLcpglcOTHo: 50, + '0#LINE_NUMBER_DIM_ID_STR': 3, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '辽宁', + CKQPX3dQXKYk: '广东', + RMLcpglcOTHo: 15, + '0#LINE_NUMBER_DIM_ID_STR': 4, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '吉林', + CKQPX3dQXKYk: '广西', + RMLcpglcOTHo: 57, + '0#LINE_NUMBER_DIM_ID_STR': 5, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '江西', + CKQPX3dQXKYk: '湖南', + RMLcpglcOTHo: 44, + '0#LINE_NUMBER_DIM_ID_STR': 6, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '山东', + CKQPX3dQXKYk: '福建', + RMLcpglcOTHo: 20, + '0#LINE_NUMBER_DIM_ID_STR': 7, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '河南', + CKQPX3dQXKYk: '广东', + RMLcpglcOTHo: 65, + '0#LINE_NUMBER_DIM_ID_STR': 8, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '湖北', + CKQPX3dQXKYk: '江西', + RMLcpglcOTHo: 40, + '0#LINE_NUMBER_DIM_ID_STR': 9, + '0#LINE_NUMBER_ICON': + '' + }, + { + PZwFghHcsvwt: '湖南', + CKQPX3dQXKYk: '湖北', + RMLcpglcOTHo: 35, + '0#LINE_NUMBER_DIM_ID_STR': 10, + '0#LINE_NUMBER_ICON': + '' + } + ], + theme: { + underlayBackgroundColor: 'rgba(255,255,255,0)', + headerStyle, + bodyStyle: { + bgColor: ({ row, col }) => { + return row % 2 === 0 ? 'rgba(16,34,58,1)' : 'rgba(12,9,41,1)'; + } + } + }, + transpose: false, + widthMode: 'adaptive', + columnResizeMode: 'all', + heightMode: 'standard', + heightAdaptiveMode: 'all', + autoFillHeight: true, + autoWrapText: true, + maxCharactersNumber: 256, + defaultHeaderColWidth: 'auto', + keyboardOptions: { + selectAllOnCtrlA: true, + copySelected: false + }, + menu: { + renderMode: 'html' + }, + disableScroll: true, + customConfig: { + _disableColumnAndRowSizeRound: true, + imageMargin: 4, + multilinesForXTable: true, + shrinkSparklineFirst: true, + limitContentHeight: false + }, + frozenColCount: 0, + showHeader: true, + hover: { + disableHover: true + }, + select: { + highlightMode: 'row', + headerSelectMode: 'cell', + blankAreaClickDeselect: true, + disableSelect: true + }, + autoHeightInAdaptiveMode: false, + defaultRowHeight: 61.25, + animationAppear: { + duration: 100, + delay: 0, + type: 'one-by-one', + direction: 'row' + }, + hash: '0fa7dcedd7d638eff65f3a5bd3906361', + width: 400, + height: 245, + plugins: [filterPlugin] +}; +const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID), option); +window.tableInstance = tableInstance; +``` + ## 使用说明 1. **点击筛选图标**:点击列标题右侧的筛选图标打开筛选面板 @@ -208,8 +655,9 @@ window.tableInstance = tableInstance; - 筛选插件目前仅支持 `ListTable`,不支持 `PivotTable` - 使用列级别筛选控制时,需要在列定义中添加 `filter` 属性 -- 筛选状态会在表格配置更新时自动同步 +- `syncFilterItemsState`默认为true, 此时筛选状态会在表格配置更新时自动同步 - 建议为大数据量表格启用筛选功能以提升用户体验 +- 如果样式配置更新, 需在更新图表之前额外调用 `filterPlugin.updateStyles` # 本插件贡献者及文档作者 diff --git a/packages/vtable-plugins/demo/filter/filter.ts b/packages/vtable-plugins/demo/filter/filter.ts index e1d247129e..e3dea746d6 100644 --- a/packages/vtable-plugins/demo/filter/filter.ts +++ b/packages/vtable-plugins/demo/filter/filter.ts @@ -1,13 +1,14 @@ import * as VTable from '@visactor/vtable'; import { bindDebugTool } from '@visactor/vtable/es/scenegraph/debug-tool'; -import { FilterPlugin } from '../../src/filter'; +import { FilterOperatorCategory, FilterPlugin } from '../../src/filter'; +import { ListTable } from '@visactor/vtable'; const CONTAINER_ID = 'vTable'; /** * 生成展示筛选功能的演示数据 * 包含各种类型的数据:文本、数值、日期、布尔值、颜色等 */ -const generateDemoData = (count: number) => { +const generateDemoData = (count: number, prefix: string) => { const colors = ['#f5a623', '#7ed321', '#bd10e0', '#4a90e2', '#50e3c2', '#ff5a5f', '#000000']; const departments = ['研发部', '市场部', '销售部', '人事部', '财务部', '设计部', '客服部', '运营部']; @@ -19,7 +20,7 @@ const generateDemoData = (count: number) => { return { id: i + 1, - name: `员工${i + 1}`, + name: `${prefix}_员工${i + 1}`, gender: i % 2 === 0 ? '男' : '女', salary, sales, @@ -39,7 +40,7 @@ const generateDemoData = (count: number) => { }; export function createTable() { - const records = generateDemoData(50); + const records = generateDemoData(50, '第一次'); const columns: VTable.ColumnsDefine = [ { field: 'id', @@ -143,20 +144,233 @@ export function createTable() { } ]; - const filterPlugin = new FilterPlugin({}); + const filterPlugin = new FilterPlugin({ + filterIcon: { + name: 'filter-icon', + type: 'svg', + width: 20, + height: 20, + positionType: VTable.TYPES.IconPosition.right, + cursor: 'pointer', + marginRight: 4, + svg: `` + }, + filteringIcon: { + name: 'filtering-icon', + type: 'svg', + width: 20, + height: 20, + positionType: VTable.TYPES.IconPosition.right, + cursor: 'pointer', + marginRight: 4, + svg: `` + }, + styles: { + filterMenu: { + display: 'none', + position: 'absolute', + backgroundColor: '#0E1119', + border: '0px solid #272A30', + boxShadow: '0 4px 8px rgba(0,0,0,0.15)', + zIndex: '99999', + borderRadius: '4px', + color: '#FFF', + fontFamily: 'SourceHanSansCN-Normal', + fontSize: '12px' + }, + filterPanel: { + padding: '10px', + display: 'block' + }, + searchContainer: { + padding: '5px' + }, + searchInput: { + width: '100%', + padding: '8px 10px', + border: '1px solid #272a30', + borderRadius: '4px', + fontSize: '14px', + boxSizing: 'border-box', + backgroundColor: '#0e1119', + color: 'red' + }, + optionsContainer: { + maxHeight: '200px', + overflowY: 'auto', + marginTop: '10px' + }, + optionItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '8px 5px', + color: 'red' + }, + optionLabel: { + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + flexGrow: '1', + fontWeight: 'normal' + }, + checkbox: { + marginRight: '10px' + }, + countSpan: { + color: '#888', + fontSize: '12px' + }, + tabsContainer: { + display: 'flex', + justifyContent: 'space-around', + borderBottom: '0px solid #e0e0e0' + }, + footerContainer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '10px 15px', + borderTop: '0px solid #e0e0e0', + backgroundColor: 'transparent' + }, + clearLink: { + color: '#006EFF', + textDecoration: 'none' + }, + conditionContainer: { + marginBottom: '15px', + padding: '10px', + backgroundColor: '#0E1119' + }, + formLabel: { + display: 'block', + marginBottom: '8px', + fontWeight: 'normal', + backgroundColor: 'transparent' + }, + operatorSelect: { + width: '100%', + padding: '8px', + marginBottom: '15px', + border: '1px solid #272a30', + borderRadius: '4px', + boxSizing: 'border-box', + backgroundColor: '#0e1119', + color: 'red' + }, + rangeInputContainer: { + display: 'flex', + alignItems: 'center', + gap: '8px', + backgroundColor: '#0E1119' + }, + addLabel: { + display: 'none', + padding: '0 5px' + } + }, + conditionCategories: [ + { value: FilterOperatorCategory.ALL, label: '全部' }, + { value: FilterOperatorCategory.TEXT, label: '文本' }, + { value: FilterOperatorCategory.NUMBER, label: '数值' } + // { value: FilterOperatorCategory.COLOR, label: '颜色' }, + // { value: FilterOperatorCategory.CHECKBOX, label: '复选框' }, + // { value: FilterOperatorCategory.RADIO, label: '单选框' } + ], + checkboxItemFormat: (formatValue, rawValue) => { + return formatValue; + }, + syncFilterItemsState: false, + onFilterRecordsEnd: records => { + console.log('onFilterRecordsEnd'); + return [...records, null, undefined]; + } + }); (window as any).filterPlugin = filterPlugin; const option: VTable.ListTableConstructorOptions = { container: document.getElementById(CONTAINER_ID), - records, + records: [...records, null, undefined], columns, padding: 10, - plugins: [filterPlugin] + plugins: [filterPlugin], + emptyTip: { + text: 'no data' + } }; const tableInstance = new VTable.ListTable(option); (window as any).tableInstance = tableInstance; + tableInstance.on(ListTable.EVENT_TYPE.FILTER_MENU_SHOW, (...args) => { + console.log('filter_menu_show', args); + tableInstance.arrangeCustomCellStyle({ col: 1, row: 0 }, 'header_highlight'); + }); + + tableInstance.on(ListTable.EVENT_TYPE.FILTER_MENU_HIDE, (...args) => { + console.log('filter_menu_hide', args); + tableInstance.arrangeCustomCellStyle({ col: 1, row: 0 }, 'header_highlight'); + }); + + tableInstance.registerCustomCellStyle('header_highlight', { + bgColor: 'red' + }); + + // 数据更新 + setTimeout(() => { + filterPlugin.updatePluginOptions({ + styles: { + searchInput: { + placeholder: 'xxx', + color: 'blue' + }, + optionItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '8px 5px', + color: 'blue' + }, + filterMenu: { + color: 'red' + } + }, + onFilterRecordsEnd: records => { + console.log('onFilterRecordsEnd-2'); + records.push(null, undefined); + } + }); + console.log('update'); + tableInstance.updateOption({ + ...option, + plugins: [filterPlugin], + records: [...generateDemoData(50, '第二次'), null, undefined] + }); + }, 2000); + + // 插件更新 + // setTimeout(() => { + // console.log('update'); + // tableInstance.updateOption({ + // ...option, + // plugins: [filterPlugin] + // }); + // }, 8000); + + // 实例释放 + // setTimeout(() => { + // tableInstance.release(); + // }, 3000); bindDebugTool(tableInstance.scenegraph.stage, { customGrapicKeys: ['col', 'row'] }); + + tableInstance.on('click_cell', (...args) => { + console.log('click_cell', args); + }); + tableInstance.on('icon_click', (...args) => { + args[0].event.stopPropagation(); + args[0].event.preventDefault(); + console.log('icon_click'); + }); } diff --git a/packages/vtable-plugins/src/filter/condition-filter.ts b/packages/vtable-plugins/src/filter/condition-filter.ts index 0955ae78ca..9129534549 100644 --- a/packages/vtable-plugins/src/filter/condition-filter.ts +++ b/packages/vtable-plugins/src/filter/condition-filter.ts @@ -1,8 +1,15 @@ import type { ListTable, PivotTable } from '@visactor/vtable'; import type { FilterStateManager } from './filter-state-manager'; import { applyStyles, filterStyles, createElement } from './styles'; -import type { FilterOperator, OperatorOption } from './types'; +import type { + FilterOperator, + FilterOperatorCategoryOption, + FilterOptions, + FilterStyles, + OperatorOption +} from './types'; import { FilterActionType, FilterOperatorCategory } from './types'; +import { operators } from './constant'; /** * 按条件筛选组件 @@ -10,66 +17,42 @@ import { FilterActionType, FilterOperatorCategory } from './types'; export class ConditionFilter { private table: ListTable | PivotTable; private filterStateManager: FilterStateManager; + private pluginOptions: FilterOptions; + private filterToolBarHide: () => void; + + private styles: Record; + private filterByConditionPanel: HTMLElement; + private conditionContainer: HTMLElement; + private categoryLabel: HTMLElement; private selectedField: string | number; private operatorSelect: HTMLSelectElement; private valueInput: HTMLInputElement; + private andLabel: HTMLElement; private valueInputMax: HTMLInputElement; private categorySelect: HTMLSelectElement; + private operatorLabel: HTMLElement; + private rangeInputContainer: HTMLElement; + private valueLabel: HTMLElement; + private currentCategory: FilterOperatorCategory = FilterOperatorCategory.ALL; - // 按分类组织的操作符选项 - private operators: OperatorOption[] = [ - // 通用操作符 (全部分类中显示) - { value: 'equals', label: '等于', category: FilterOperatorCategory.ALL }, - { value: 'notEquals', label: '不等于', category: FilterOperatorCategory.ALL }, - - // 数值操作符 - { value: 'equals', label: '等于', category: FilterOperatorCategory.NUMBER }, - { value: 'notEquals', label: '不等于', category: FilterOperatorCategory.NUMBER }, - { value: 'greaterThan', label: '大于', category: FilterOperatorCategory.NUMBER }, - { value: 'lessThan', label: '小于', category: FilterOperatorCategory.NUMBER }, - { value: 'greaterThanOrEqual', label: '大于等于', category: FilterOperatorCategory.NUMBER }, - { value: 'lessThanOrEqual', label: '小于等于', category: FilterOperatorCategory.NUMBER }, - { value: 'between', label: '介于', category: FilterOperatorCategory.NUMBER }, - { value: 'notBetween', label: '不介于', category: FilterOperatorCategory.NUMBER }, - - // 文本操作符 - { value: 'equals', label: '等于', category: FilterOperatorCategory.TEXT }, - { value: 'notEquals', label: '不等于', category: FilterOperatorCategory.TEXT }, - { value: 'contains', label: '包含', category: FilterOperatorCategory.TEXT }, - { value: 'notContains', label: '不包含', category: FilterOperatorCategory.TEXT }, - { value: 'startsWith', label: '开头是', category: FilterOperatorCategory.TEXT }, - { value: 'notStartsWith', label: '开头不是', category: FilterOperatorCategory.TEXT }, - { value: 'endsWith', label: '结尾是', category: FilterOperatorCategory.TEXT }, - { value: 'notEndsWith', label: '结尾不是', category: FilterOperatorCategory.TEXT }, - - // 颜色操作符 - { value: 'equals', label: '等于', category: FilterOperatorCategory.COLOR }, - { value: 'notEquals', label: '不等于', category: FilterOperatorCategory.COLOR }, - - // 复选框操作符 - { value: 'isChecked', label: '已选中', category: FilterOperatorCategory.CHECKBOX }, - { value: 'isUnchecked', label: '未选中', category: FilterOperatorCategory.CHECKBOX }, - - // 单选框操作符 - { value: 'isChecked', label: '已选中', category: FilterOperatorCategory.RADIO }, - { value: 'isUnchecked', label: '未选中', category: FilterOperatorCategory.RADIO } - ]; - - // 分类下拉选项 - private categories = [ - { value: FilterOperatorCategory.ALL, label: '全部' }, - { value: FilterOperatorCategory.TEXT, label: '文本' }, - { value: FilterOperatorCategory.NUMBER, label: '数值' }, - { value: FilterOperatorCategory.COLOR, label: '颜色' }, - { value: FilterOperatorCategory.CHECKBOX, label: '复选框' }, - { value: FilterOperatorCategory.RADIO, label: '单选框' } - ]; - - constructor(table: ListTable | PivotTable, filterStateManager: FilterStateManager) { + private categories: FilterOperatorCategoryOption[] = []; + protected operators: OperatorOption[] = []; + + constructor( + table: ListTable | PivotTable, + filterStateManager: FilterStateManager, + pluginOptions: FilterOptions, + filterToolBarHide: () => void + ) { this.table = table; this.filterStateManager = filterStateManager; + this.pluginOptions = pluginOptions; + this.styles = pluginOptions.styles || {}; + this.categories = pluginOptions.conditionCategories; + this.operators = operators; + this.filterToolBarHide = filterToolBarHide; } setSelectedField(fieldId: string | number): void { @@ -89,13 +72,15 @@ export class ConditionFilter { let filteredOperators: OperatorOption[]; if (this.currentCategory === FilterOperatorCategory.ALL) { - // 当选择"全部"时,收集所有不重复的操作符 + // 当选择"全部"时,收集所有配置的分类中, 不重复的操作符 const uniqueOperators = new Map(); - this.operators.forEach(op => { - if (!uniqueOperators.has(op.value)) { - uniqueOperators.set(op.value, op); - } - }); + this.operators + .filter(op => this.categories.map(cat => cat.value).includes(op.category)) + .forEach(op => { + if (!uniqueOperators.has(op.value)) { + uniqueOperators.set(op.value, op); + } + }); filteredOperators = Array.from(uniqueOperators.values()); } else { // 其他类别正常筛选 @@ -128,22 +113,24 @@ export class ConditionFilter { */ private loadCurrentFilterState(): void { const filter = this.filterStateManager.getFilterState(this.selectedField); + const syncFilterItemsState = this.pluginOptions?.syncFilterItemsState ?? true; - if (filter && filter.type === 'byCondition') { + // 不联动的场景下, 用户的配置始终会被展示出来 + if ((filter && filter.type === 'byCondition') || !syncFilterItemsState) { // 设置操作符 - if (filter.operator && this.operatorSelect) { - this.operatorSelect.value = filter.operator; + if (this.operatorSelect) { + this.operatorSelect.value = filter?.operator ?? operators[0].value; } // 设置条件值 - if (filter.condition !== undefined && this.valueInput) { - if (Array.isArray(filter.condition)) { + if (this.valueInput) { + if (Array.isArray(filter?.condition)) { this.valueInput.value = String(filter.condition[0]); if (this.valueInputMax) { this.valueInputMax.value = String(filter.condition[1]); } } else { - this.valueInput.value = String(filter.condition); + this.valueInput.value = String(filter?.condition ?? ''); if (this.valueInputMax) { this.valueInputMax.value = ''; } @@ -277,7 +264,7 @@ export class ConditionFilter { } // TODO:处理单元格颜色和字体颜色的筛选 - + const syncFilterItemsState = this.pluginOptions?.syncFilterItemsState ?? true; this.filterStateManager.dispatch({ type: FilterActionType.APPLY_FILTERS, payload: { @@ -285,11 +272,10 @@ export class ConditionFilter { type: 'byCondition', operator, condition: conditionValue, - enable: true + enable: true, + shouldKeepUnrelatedState: !syncFilterItemsState } }); - - this.hide(); } /** @@ -310,17 +296,18 @@ export class ConditionFilter { * 渲染条件筛选面板 */ render(container: HTMLElement): void { + const filterStyles = this.styles; // 按条件筛选面板 this.filterByConditionPanel = document.createElement('div'); applyStyles(this.filterByConditionPanel, filterStyles.filterPanel); // 条件选择区域 - const conditionContainer = document.createElement('div'); - applyStyles(conditionContainer, filterStyles.conditionContainer); + this.conditionContainer = document.createElement('div'); + applyStyles(this.conditionContainer, this.styles.conditionContainer); // 分类选择下拉框 - const categoryLabel = createElement('label', {}, ['筛选类型:']); - applyStyles(categoryLabel, filterStyles.formLabel); + this.categoryLabel = createElement('label', {}, ['筛选类型:']); + applyStyles(this.categoryLabel, this.styles.formLabel); this.categorySelect = createElement('select') as HTMLSelectElement; applyStyles(this.categorySelect, filterStyles.operatorSelect); @@ -334,19 +321,19 @@ export class ConditionFilter { }); // 操作符选择下拉框 - const operatorLabel = createElement('label', {}, ['筛选条件:']); - applyStyles(operatorLabel, filterStyles.formLabel); + this.operatorLabel = createElement('label', {}, ['筛选条件:']); + applyStyles(this.operatorLabel, this.styles.formLabel); this.operatorSelect = createElement('select') as HTMLSelectElement; applyStyles(this.operatorSelect, filterStyles.operatorSelect); // 条件值输入框 - const valueLabel = createElement('label', {}, ['筛选值:']); - applyStyles(valueLabel, filterStyles.formLabel); + this.valueLabel = createElement('label', {}, ['筛选值:']); + applyStyles(this.valueLabel, this.styles.formLabel); // 一个容器来包装两个输入框和"和"字 - const rangeInputContainer = createElement('div'); - applyStyles(rangeInputContainer, filterStyles.rangeInputContainer); + this.rangeInputContainer = createElement('div'); + applyStyles(this.rangeInputContainer, this.styles.rangeInputContainer); this.valueInput = createElement('input', { type: 'text', @@ -355,9 +342,9 @@ export class ConditionFilter { applyStyles(this.valueInput, filterStyles.searchInput); // "和"字标签 - const andLabel = createElement('span', {}, ['和']); - applyStyles(andLabel, filterStyles.addLabel); - andLabel.style.display = 'none'; // 默认隐藏 + this.andLabel = createElement('span', {}, ['和']); + applyStyles(this.andLabel, this.styles.addLabel); + this.andLabel.style.display = 'none'; // 默认隐藏 // 范围筛选的最大值输入框 this.valueInputMax = createElement('input', { @@ -368,19 +355,19 @@ export class ConditionFilter { this.valueInputMax.style.display = 'none'; // 默认隐藏 // 将输入框和"和"字添加到容器中 - rangeInputContainer.appendChild(this.valueInput); - rangeInputContainer.appendChild(andLabel); - rangeInputContainer.appendChild(this.valueInputMax); + this.rangeInputContainer.appendChild(this.valueInput); + this.rangeInputContainer.appendChild(this.andLabel); + this.rangeInputContainer.appendChild(this.valueInputMax); // 将元素添加到容器中 - conditionContainer.appendChild(categoryLabel); - conditionContainer.appendChild(this.categorySelect); - conditionContainer.appendChild(operatorLabel); - conditionContainer.appendChild(this.operatorSelect); - conditionContainer.appendChild(valueLabel); - conditionContainer.appendChild(rangeInputContainer); - - this.filterByConditionPanel.appendChild(conditionContainer); + this.conditionContainer.appendChild(this.categoryLabel); + this.conditionContainer.appendChild(this.categorySelect); + this.conditionContainer.appendChild(this.operatorLabel); + this.conditionContainer.appendChild(this.operatorSelect); + this.conditionContainer.appendChild(this.valueLabel); + this.conditionContainer.appendChild(this.rangeInputContainer); + + this.filterByConditionPanel.appendChild(this.conditionContainer); container.appendChild(this.filterByConditionPanel); // 默认隐藏 @@ -391,6 +378,20 @@ export class ConditionFilter { this.bindEvents(); } + updateStyles(styles: FilterStyles) { + applyStyles(this.filterByConditionPanel, styles.filterPanel); + applyStyles(this.conditionContainer, styles.conditionContainer); + applyStyles(this.categoryLabel, styles.formLabel); + applyStyles(this.categorySelect, styles.operatorSelect); + applyStyles(this.operatorLabel, styles.formLabel); + applyStyles(this.operatorSelect, styles.operatorSelect); + applyStyles(this.valueLabel, styles.formLabel); + applyStyles(this.rangeInputContainer, styles.rangeInputContainer); + applyStyles(this.valueInput, styles.searchInput); + applyStyles(this.andLabel, styles.addLabel); + applyStyles(this.valueInputMax, styles.searchInput); + } + /** * 绑定事件 */ @@ -399,6 +400,7 @@ export class ConditionFilter { this.valueInput.addEventListener('keypress', event => { if (event.key === 'Enter') { this.applyFilter(); + this.filterToolBarHide(); } }); @@ -406,6 +408,7 @@ export class ConditionFilter { this.valueInputMax.addEventListener('keypress', event => { if (event.key === 'Enter') { this.applyFilter(); + this.filterToolBarHide(); } }); diff --git a/packages/vtable-plugins/src/filter/constant.ts b/packages/vtable-plugins/src/filter/constant.ts new file mode 100644 index 0000000000..d7e75c107d --- /dev/null +++ b/packages/vtable-plugins/src/filter/constant.ts @@ -0,0 +1,51 @@ +import type { OperatorOption } from './types'; +import { FilterOperatorCategory } from './types'; + +// 分类下拉选项 +export const categories = [ + { value: FilterOperatorCategory.ALL, label: '全部' }, + { value: FilterOperatorCategory.TEXT, label: '文本' }, + { value: FilterOperatorCategory.NUMBER, label: '数值' }, + { value: FilterOperatorCategory.COLOR, label: '颜色' }, + { value: FilterOperatorCategory.CHECKBOX, label: '复选框' }, + { value: FilterOperatorCategory.RADIO, label: '单选框' } +]; + +// 按分类组织的操作符选项 +export const operators: OperatorOption[] = [ + // 通用操作符 (全部分类中显示) + { value: 'equals', label: '等于', category: FilterOperatorCategory.ALL }, + { value: 'notEquals', label: '不等于', category: FilterOperatorCategory.ALL }, + + // 数值操作符 + { value: 'equals', label: '等于', category: FilterOperatorCategory.NUMBER }, + { value: 'notEquals', label: '不等于', category: FilterOperatorCategory.NUMBER }, + { value: 'greaterThan', label: '大于', category: FilterOperatorCategory.NUMBER }, + { value: 'lessThan', label: '小于', category: FilterOperatorCategory.NUMBER }, + { value: 'greaterThanOrEqual', label: '大于等于', category: FilterOperatorCategory.NUMBER }, + { value: 'lessThanOrEqual', label: '小于等于', category: FilterOperatorCategory.NUMBER }, + { value: 'between', label: '介于', category: FilterOperatorCategory.NUMBER }, + { value: 'notBetween', label: '不介于', category: FilterOperatorCategory.NUMBER }, + + // 文本操作符 + { value: 'equals', label: '等于', category: FilterOperatorCategory.TEXT }, + { value: 'notEquals', label: '不等于', category: FilterOperatorCategory.TEXT }, + { value: 'contains', label: '包含', category: FilterOperatorCategory.TEXT }, + { value: 'notContains', label: '不包含', category: FilterOperatorCategory.TEXT }, + { value: 'startsWith', label: '开头是', category: FilterOperatorCategory.TEXT }, + { value: 'notStartsWith', label: '开头不是', category: FilterOperatorCategory.TEXT }, + { value: 'endsWith', label: '结尾是', category: FilterOperatorCategory.TEXT }, + { value: 'notEndsWith', label: '结尾不是', category: FilterOperatorCategory.TEXT }, + + // 颜色操作符 + { value: 'equals', label: '等于', category: FilterOperatorCategory.COLOR }, + { value: 'notEquals', label: '不等于', category: FilterOperatorCategory.COLOR }, + + // 复选框操作符 + { value: 'isChecked', label: '已选中', category: FilterOperatorCategory.CHECKBOX }, + { value: 'isUnchecked', label: '未选中', category: FilterOperatorCategory.CHECKBOX }, + + // 单选框操作符 + { value: 'isChecked', label: '已选中', category: FilterOperatorCategory.RADIO }, + { value: 'isUnchecked', label: '未选中', category: FilterOperatorCategory.RADIO } +]; diff --git a/packages/vtable-plugins/src/filter/filter-engine.ts b/packages/vtable-plugins/src/filter/filter-engine.ts index 4136b8e80f..8e09ab2819 100644 --- a/packages/vtable-plugins/src/filter/filter-engine.ts +++ b/packages/vtable-plugins/src/filter/filter-engine.ts @@ -1,5 +1,5 @@ import type { ListTable, PivotTable, TYPES } from '@visactor/vtable'; -import type { FilterState, FilterOperator, FilterConfig } from './types'; +import type { FilterState, FilterConfig, FilterOptions } from './types'; /** * 筛选引擎,用于进行实际的筛选操作 @@ -8,6 +8,12 @@ export class FilterEngine { filterFuncRule: (TYPES.FilterFuncRule & { fieldId?: string })[] = []; filterValueRule: TYPES.FilterValueRule[] = []; + private pluginOptions: FilterOptions; + + constructor(filterPluginOptions: FilterOptions) { + this.pluginOptions = filterPluginOptions; + } + applyFilter(state: FilterState, table: ListTable | PivotTable) { const { filters } = state; this.filterFuncRule = []; @@ -34,7 +40,8 @@ export class FilterEngine { }); table.updateFilterRules([...this.filterFuncRule, ...this.filterValueRule], { - clearRowHeightCache: false + clearRowHeightCache: false, + onFilterRecordsEnd: this.pluginOptions?.onFilterRecordsEnd }); } @@ -117,8 +124,14 @@ export class FilterEngine { return value === condition ? 0 : -1; } + // 进入字符串比较 const valueStr = String(value).toLowerCase(); const conditionStr = String(condition).toLowerCase(); + + // 如果两个字符串都能正确转换成数字, 则仍然按数字比较 + if (!isNaN(Number(valueStr)) && !isNaN(Number(conditionStr))) { + return Number(valueStr) === Number(conditionStr) ? 0 : Number(valueStr) > Number(conditionStr) ? 1 : -1; + } return valueStr === conditionStr ? 0 : valueStr > conditionStr ? 1 : -1; } diff --git a/packages/vtable-plugins/src/filter/filter-state-manager.ts b/packages/vtable-plugins/src/filter/filter-state-manager.ts index 1af4422370..507c0098e1 100644 --- a/packages/vtable-plugins/src/filter/filter-state-manager.ts +++ b/packages/vtable-plugins/src/filter/filter-state-manager.ts @@ -80,10 +80,24 @@ export class FilterStateManager { const newFilter = new Map(state.filters); switch (type) { case FilterActionType.ADD_FILTER: - newFilter.set(payload.field, payload); + if (payload.shouldKeepUnrelatedState) { + newFilter.set(payload.field, { ...newFilter.get(payload.field), ...payload }); + } else { + newFilter.set(payload.field, payload); + } break; case FilterActionType.REMOVE_FILTER: - newFilter.delete(payload.field); + if (payload.shouldKeepUnrelatedState && payload.type === 'byValue') { + delete newFilter.get(payload.field).values; + newFilter.set(payload.field, { ...newFilter.get(payload.field), enable: false }); + } else if (payload.shouldKeepUnrelatedState && payload.type === 'byCondition') { + delete newFilter.get(payload.field).condition; + delete newFilter.get(payload.field).operator; + newFilter.set(payload.field, { ...newFilter.get(payload.field), enable: false }); + } else { + newFilter.delete(payload.field); + } + break; case FilterActionType.UPDATE_FILTER: newFilter.set(payload.field, { ...newFilter.get(payload.field), ...payload }); diff --git a/packages/vtable-plugins/src/filter/filter-toolbar.ts b/packages/vtable-plugins/src/filter/filter-toolbar.ts index 851b05c116..2fb8d486c1 100644 --- a/packages/vtable-plugins/src/filter/filter-toolbar.ts +++ b/packages/vtable-plugins/src/filter/filter-toolbar.ts @@ -1,9 +1,9 @@ -import type { ListTable, PivotTable } from '@visactor/vtable'; +import { TABLE_EVENT_TYPE, type ListTable, type PivotTable } from '@visactor/vtable'; import type { FilterStateManager } from './filter-state-manager'; import { ValueFilter } from './value-filter'; import { ConditionFilter } from './condition-filter'; import { applyStyles, filterStyles } from './styles'; -import type { FilterMode } from './types'; +import type { FilterMode, FilterOptions, FilterStyles } from './types'; /** * 筛选工具栏,管理按值和按条件筛选组件 @@ -11,6 +11,7 @@ import type { FilterMode } from './types'; export class FilterToolbar { table: ListTable | PivotTable; filterStateManager: FilterStateManager; + pluginOptions: FilterOptions; valueFilter: ValueFilter | null = null; conditionFilter: ConditionFilter | null = null; activeTab: 'byValue' | 'byCondition' = 'byValue'; @@ -19,20 +20,25 @@ export class FilterToolbar { filterModes: FilterMode[] = []; private filterMenu: HTMLElement; + private filterTabsContainer: HTMLElement; private filterMenuWidth: number; private currentCol?: number | null; private currentRow?: number | null; private filterTabByValue: HTMLButtonElement; private filterTabByCondition: HTMLButtonElement; + private footerContainer: HTMLElement; private clearFilterOptionLink: HTMLAnchorElement; private cancelFilterButton: HTMLButtonElement; private applyFilterButton: HTMLButtonElement; - constructor(table: ListTable | PivotTable, filterStateManager: FilterStateManager) { + private activeType: 'byValue' | 'byCondition' = 'byValue'; + + constructor(table: ListTable | PivotTable, filterStateManager: FilterStateManager, pluginOptions: FilterOptions) { this.table = table; this.filterStateManager = filterStateManager; - this.valueFilter = new ValueFilter(this.table, this.filterStateManager); - this.conditionFilter = new ConditionFilter(this.table, this.filterStateManager); + this.valueFilter = new ValueFilter(this.table, this.filterStateManager, pluginOptions); + this.conditionFilter = new ConditionFilter(this.table, this.filterStateManager, pluginOptions, this.hide); + this.pluginOptions = pluginOptions; this.filterMenuWidth = 300; // 待优化,可能需要自适应内容的宽度 @@ -103,14 +109,16 @@ export class FilterToolbar { } render(container: HTMLElement): void { + const filterStyles = this.pluginOptions.styles || {}; // === 主容器 === this.filterMenu = document.createElement('div'); + this.filterMenu.classList.add('vtable-filter-menu'); applyStyles(this.filterMenu, filterStyles.filterMenu); this.filterMenu.style.width = `${this.filterMenuWidth}px`; // === 筛选 Tab === - const filterTabsContainer = document.createElement('div'); - applyStyles(filterTabsContainer, filterStyles.tabsContainer); + this.filterTabsContainer = document.createElement('div'); + applyStyles(this.filterTabsContainer, filterStyles.tabsContainer); this.filterTabByValue = document.createElement('button'); this.filterTabByValue.innerText = '按值筛选'; @@ -120,11 +128,11 @@ export class FilterToolbar { this.filterTabByCondition.innerText = '按条件筛选'; applyStyles(this.filterTabByCondition, filterStyles.tabStyle(false)); - filterTabsContainer.append(this.filterTabByValue, this.filterTabByCondition); + this.filterTabsContainer.append(this.filterTabByValue, this.filterTabByCondition); // === 页脚(清除、取消、确定 筛选按钮) === - const footerContainer = document.createElement('div'); - applyStyles(footerContainer, filterStyles.footerContainer); + this.footerContainer = document.createElement('div'); + applyStyles(this.footerContainer, filterStyles.footerContainer); this.clearFilterOptionLink = document.createElement('a'); this.clearFilterOptionLink.href = '#'; @@ -141,29 +149,52 @@ export class FilterToolbar { applyStyles(this.applyFilterButton, filterStyles.footerButton(true)); footerButtons.append(this.cancelFilterButton, this.applyFilterButton); - footerContainer.append(this.clearFilterOptionLink, footerButtons); + this.footerContainer.append(this.clearFilterOptionLink, footerButtons); // --- 筛选器头部 Tab --- - this.filterMenu.append(filterTabsContainer); + this.filterMenu.append(this.filterTabsContainer); // --- 筛选器内容 --- this.valueFilter.render(this.filterMenu); this.conditionFilter.render(this.filterMenu); // --- 筛选器页脚 --- - this.filterMenu.append(footerContainer); + this.filterMenu.append(this.footerContainer); container.appendChild(this.filterMenu); // 将筛选器添加到 DOM 中 this.attachEventListeners(); } + updateStyles(styles: FilterStyles) { + const realDisplay = (this.filterMenu.style.display ?? styles.filterMenu.display) || 'none'; + applyStyles(this.filterMenu, { ...styles.filterMenu, display: realDisplay }); + applyStyles(this.filterTabsContainer, styles.tabsContainer); + applyStyles(this.filterTabByValue, styles.tabStyle(true)); + applyStyles(this.footerContainer, styles.footerContainer); + applyStyles(this.clearFilterOptionLink, styles.clearLink); + applyStyles(this.cancelFilterButton, styles.footerButton(false)); + applyStyles(this.applyFilterButton, styles.footerButton(true)); + this.valueFilter.updateStyles(styles); + this.conditionFilter.updateStyles(styles); + + // 面板处于显示状态, 更新了样式, 则需要手动控制tab显隐 + // 面板显示按值筛选或按条件筛选 + if (this.activeType === 'byCondition') { + this.onTabSwitch('byCondition'); + } else { + this.onTabSwitch('byValue'); + } + } + attachEventListeners() { // 按值筛选/按条件筛选的事件监听 this.filterTabByValue.addEventListener('click', () => { + this.activeType = 'byValue'; this.onTabSwitch('byValue'); }); this.filterTabByCondition.addEventListener('click', () => { + this.activeType = 'byCondition'; this.onTabSwitch('byCondition'); }); @@ -220,6 +251,11 @@ export class FilterToolbar { const canvasBounds = this.table.canvas.getBoundingClientRect(); const cell = this.table.getCellRelativeRect(effectiveCol, effectiveRow); + const filterMenuWidth = this.filterMenuWidth; + // 最高高度预估值 + // TODO: 需要获取精确高度 + const filterMenuHeight = 380; + if (cell.right < this.filterMenuWidth) { // 无法把筛选菜单完整地显示在左侧,那么显示在右侧 left = cell.left + canvasBounds.left; @@ -230,6 +266,10 @@ export class FilterToolbar { top = cell.bottom + canvasBounds.top; } + // 确保筛选菜单不会超出窗口边界 + left = Math.max(0, Math.min(window.innerWidth - filterMenuWidth, left)); + top = Math.max(0, Math.min(window.innerHeight - filterMenuHeight, top)); + this.filterMenu.style.display = this.isVisible ? 'block' : 'none'; this.filterMenu.style.left = `${left}px`; this.filterMenu.style.top = `${top}px`; @@ -266,11 +306,24 @@ export class FilterToolbar { // 确保在事件冒泡完成后才设置 isVisible 为 true setTimeout(() => { this.isVisible = true; + this.table.fireListeners(TABLE_EVENT_TYPE.FILTER_MENU_SHOW, { + col: col, + row: row + }); }, 0); } - hide(): void { + hide = (currentCol?: number, currentRow?: number): void => { this.filterMenu.style.display = 'none'; this.isVisible = false; + this.table.fireListeners(TABLE_EVENT_TYPE.FILTER_MENU_HIDE, { + col: currentCol ?? this.currentCol, + row: currentRow ?? this.currentRow + }); + }; + + destroy() { + this.valueFilter.destroy(); + this.filterMenu.remove(); } } diff --git a/packages/vtable-plugins/src/filter/filter.ts b/packages/vtable-plugins/src/filter/filter.ts index 121a049f38..21bb737cdc 100644 --- a/packages/vtable-plugins/src/filter/filter.ts +++ b/packages/vtable-plugins/src/filter/filter.ts @@ -13,6 +13,9 @@ import type { ColumnDefine, ColumnsDefine } from '@visactor/vtable'; +import { cloneDeep, merge } from 'lodash'; +import { filterStyles } from './styles'; +import { categories } from './constant'; /** * 筛选插件,负责初始化筛选引擎、状态管理器和工具栏 @@ -42,7 +45,7 @@ export class FilterPlugin implements pluginsDefinition.IVTablePlugin { constructor(pluginOptions: FilterOptions) { this.id = pluginOptions?.id ?? this.id; - this.pluginOptions = pluginOptions; + this.pluginOptions = cloneDeep(pluginOptions); // 不污染用户的配置, 以便上层业务做diff的时候使用 this.pluginOptions.filterIcon = pluginOptions.filterIcon ?? { name: 'filter-icon', type: 'svg', @@ -64,6 +67,29 @@ export class FilterPlugin implements pluginsDefinition.IVTablePlugin { if (!this.pluginOptions.filterModes || !this.pluginOptions.filterModes.length) { this.pluginOptions.filterModes = ['byValue', 'byCondition']; } + + this.pluginOptions.styles = merge(filterStyles, this.pluginOptions.styles ?? {}); + this.pluginOptions.conditionCategories = pluginOptions.conditionCategories ?? categories; + } + + initFilterPlugin(eventArgs: any) { + this.filterEngine = new FilterEngine(this.pluginOptions); + this.filterStateManager = new FilterStateManager(this.table, this.filterEngine); + this.filterToolbar = new FilterToolbar(this.table, this.filterStateManager, this.pluginOptions); + this.columns = eventArgs.options.columns; + + this.filterToolbar.render(document.body); + this.updateFilterIcons(this.columns); + this.filterStateManager.subscribe((_: FilterState, action?: FilterAction) => { + // 新增筛选配置时,不需要更新筛选图标以及表格 + if (action?.type === FilterActionType.ADD_FILTER) { + return; + } + this.updateFilterIcons(this.columns); + (this.table as ListTable).updateColumns(this.columns, { + clearRowHeightCache: false + }); + }); } run(...args: any[]) { @@ -73,27 +99,14 @@ export class FilterPlugin implements pluginsDefinition.IVTablePlugin { this.table = table as ListTable | PivotTable; if (runtime === TABLE_EVENT_TYPE.BEFORE_INIT) { - this.filterEngine = new FilterEngine(); - this.filterStateManager = new FilterStateManager(this.table, this.filterEngine); - this.filterToolbar = new FilterToolbar(this.table, this.filterStateManager); - this.columns = eventArgs.options.columns; - - this.filterToolbar.render(document.body); - this.updateFilterIcons(this.columns); - this.filterStateManager.subscribe((_: FilterState, action?: FilterAction) => { - // 新增筛选配置时,不需要更新筛选图标以及表格 - if (action?.type === FilterActionType.ADD_FILTER) { - return; - } - this.updateFilterIcons(this.columns); - (this.table as ListTable).updateColumns(this.columns, { - clearRowHeightCache: false - }); - }); + this.initFilterPlugin(eventArgs); } else if (runtime === TABLE_EVENT_TYPE.BEFORE_UPDATE_OPTION) { + if (!this.filterEngine || !this.filterStateManager || !this.filterToolbar) { + this.initFilterPlugin(eventArgs); + } this.pluginOptions = { ...this.pluginOptions, - ...(eventArgs.options.plugins as FilterPlugin[]).find(plugin => plugin.id === this.id).pluginOptions + ...(eventArgs.options.plugins as FilterPlugin[])?.find(plugin => plugin.id === this.id)?.pluginOptions }; this.columns = eventArgs.options.columns; this.handleOptionUpdate(eventArgs.options); @@ -111,9 +124,13 @@ export class FilterPlugin implements pluginsDefinition.IVTablePlugin { const col = eventArgs.col; const row = eventArgs.row; if (this.filterToolbar.isVisible) { - this.filterToolbar.hide(); + this.filterToolbar.hide(eventArgs.col, eventArgs.row); } else { this.filterToolbar.show(col, row, this.pluginOptions.filterModes); + this.table.fireListeners(TABLE_EVENT_TYPE.FILTER_MENU_SHOW, { + col: eventArgs.col, + row: eventArgs.row + }); } } else if (runtime === TABLE_EVENT_TYPE.SCROLL) { if (eventArgs.scrollDirection === 'horizontal') { @@ -131,6 +148,13 @@ export class FilterPlugin implements pluginsDefinition.IVTablePlugin { } } + updatePluginOptions(pluginOptions: FilterOptions) { + // TODO: 目前额外只处理了styles,其他的后续再处理 + this.pluginOptions = merge(this.pluginOptions, pluginOptions); + // 更新筛选器UI样式 + this.filterToolbar.updateStyles(this.pluginOptions.styles); + } + // 当用户的配置项更新时调用 update() { if (this.filterStateManager) { @@ -342,10 +366,13 @@ export class FilterPlugin implements pluginsDefinition.IVTablePlugin { } release() { + this.columns.forEach(column => { + column.headerIcon = undefined; + }); this.table = null; this.filterEngine = null; this.filterStateManager = null; - this.filterToolbar.valueFilter.destroy(); + this.filterToolbar.destroy(); this.filterToolbar = null; } } diff --git a/packages/vtable-plugins/src/filter/styles.ts b/packages/vtable-plugins/src/filter/styles.ts index d974d75448..7060cce26b 100644 --- a/packages/vtable-plugins/src/filter/styles.ts +++ b/packages/vtable-plugins/src/filter/styles.ts @@ -9,7 +9,7 @@ export const filterStyles = { backgroundColor: 'white', border: '1px solid #ccc', boxShadow: '0 4px 8px rgba(0,0,0,0.15)', - zIndex: '100' + zIndex: '99999' }, // 筛选面板 diff --git a/packages/vtable-plugins/src/filter/types.ts b/packages/vtable-plugins/src/filter/types.ts index 604c4c80c5..42774bac74 100644 --- a/packages/vtable-plugins/src/filter/types.ts +++ b/packages/vtable-plugins/src/filter/types.ts @@ -13,6 +13,23 @@ export interface FilterOptions { defaultEnabled?: boolean; /** 是否展示按条件筛选,按值筛选 UI */ filterModes?: FilterMode[]; + /** 筛选器样式 */ + styles?: FilterStyles; + /** 自定义筛选分类 */ + conditionCategories?: FilterOperatorCategoryOption[]; + /** 筛选选项是否展示为原始值 */ + checkboxItemFormat?: (rawValue: any, formatValue: any) => any; + /** 多个筛选器之间是否联动 + * @default true + */ + syncFilterItemsState?: boolean; + /** 筛选记录结束回调 */ + onFilterRecordsEnd?: (records: any[]) => void; +} + +export interface FilterOperatorCategoryOption { + value: FilterOperatorCategory; + label: string; } export type FilterMode = 'byValue' | 'byCondition'; @@ -92,3 +109,38 @@ export enum FilterOperatorCategory { } export type FilterListener = (state: FilterState, action?: FilterAction) => void; + +/** + * 筛选组件样式类型定义 + */ + +// 静态样式类型 +interface StaticStyles { + filterMenu?: Record; + filterPanel?: Record; + searchContainer?: Record; + searchInput?: Record; + optionsContainer?: Record; + optionItem?: Record; + optionLabel?: Record; + checkbox?: Record; + countSpan?: Record; + tabsContainer?: Record; + footerContainer?: Record; + clearLink?: Record; + conditionContainer?: Record; + formLabel?: Record; + operatorSelect?: Record; + rangeInputContainer?: Record; + addLabel?: Record; +} + +// 函数样式类型 +interface FunctionStyles { + tabStyle?: (isActive: boolean) => Record; + footerButton?: (isPrimary: boolean) => Record; + buttonStyle?: (isPrimary?: boolean) => Record; +} + +// 完整的筛选样式类型 +export type FilterStyles = StaticStyles & FunctionStyles; diff --git a/packages/vtable-plugins/src/filter/value-filter.ts b/packages/vtable-plugins/src/filter/value-filter.ts index f7158586cb..270ebf1af8 100644 --- a/packages/vtable-plugins/src/filter/value-filter.ts +++ b/packages/vtable-plugins/src/filter/value-filter.ts @@ -1,13 +1,15 @@ import type { ListTable, PivotTable } from '@visactor/vtable'; -import { arrayEqual } from '@visactor/vutils'; -import type { FilterConfig, ValueFilterOptionDom, FilterState } from './types'; +import { arrayEqual, isValid } from '@visactor/vutils'; +import type { ValueFilterOptionDom, FilterState, FilterOptions, FilterStyles } from './types'; import { FilterActionType } from './types'; import type { FilterStateManager } from './filter-state-manager'; -import { applyStyles, filterStyles } from './styles'; +import { applyStyles } from './styles'; export class ValueFilter { private table: ListTable | PivotTable; private filterStateManager: FilterStateManager; + private pluginOptions: FilterOptions; + private styles: Record; private selectedField: string | number; private selectedKeys = new Map>(); // 存储 format 之前的原始数据 private candidateKeys = new Map>(); // 存储 format 后的数据 @@ -15,7 +17,12 @@ export class ValueFilter { private toUnformattedCache = new Map>>(); private valueFilterOptionList: Map = new Map(); + private filterByValuePanel: HTMLElement; + private searchContainer: HTMLElement; + private optionsContainer: HTMLElement; + private selectAllItemDiv: HTMLElement; + private selectAllLabel: HTMLElement; private filterByValueSearchInput: HTMLInputElement; private selectAllCheckbox: HTMLInputElement; private totalCountSpan: HTMLSpanElement; @@ -24,9 +31,11 @@ export class ValueFilter { private _onInputKeyUpHandler: (event: KeyboardEvent) => void; private _onCheckboxChangeHandler: (event: Event) => void; - constructor(table: ListTable | PivotTable, filterStateManager: FilterStateManager) { + constructor(table: ListTable | PivotTable, filterStateManager: FilterStateManager, pluginOptions: FilterOptions) { this.table = table; this.filterStateManager = filterStateManager; + this.pluginOptions = pluginOptions; + this.styles = pluginOptions.styles || {}; } setSelectedField(fieldId: string | number): void { @@ -79,22 +88,33 @@ export class ValueFilter { * 为未应用筛选的列,收集候选值集合 */ private collectCandidateKeysForUnfilteredColumn(fieldId: string | number): void { + const syncFilterItemsState = this.pluginOptions?.syncFilterItemsState ?? true; const countMap = new Map(); // 计算每个候选值的计数 - const records = this.getRecords(this.table, false); // 未筛选:使用当前表格数据 + let records = []; + // 如果各个筛选器之间不联动, 则永远从原数据中获取候选值 + if (!syncFilterItemsState) { + records = this.table.internalProps.records; + } else { + records = this.getRecords(this.table, false); // 未筛选:使用当前表格数据 + } + const formatFn = this.getFormatFnCache(fieldId); const toUnformatted = new Map(); records.forEach(record => { - const originalValue = record[fieldId]; - const formattedValue = formatFn(record); - if (formattedValue !== undefined && formattedValue !== null) { - countMap.set(formattedValue, (countMap.get(formattedValue) || 0) + 1); - - const unformattedSet = toUnformatted.get(formattedValue); - if (unformattedSet !== undefined && unformattedSet !== null) { - unformattedSet.add(originalValue); - } else { - toUnformatted.set(formattedValue, new Set([originalValue])); + // 空行不做处理 + if (isValid(record)) { + const originalValue = record[fieldId]; + const formattedValue = formatFn(record); + if (formattedValue !== undefined && formattedValue !== null) { + countMap.set(formattedValue, (countMap.get(formattedValue) || 0) + 1); + + const unformattedSet = toUnformatted.get(formattedValue); + if (unformattedSet !== undefined && unformattedSet !== null) { + unformattedSet.add(originalValue); + } else { + toUnformatted.set(formattedValue, new Set([originalValue])); + } } } }); @@ -107,33 +127,43 @@ export class ValueFilter { * 为已应用筛选的列,收集候选值集合 */ private collectCandidateKeysForFilteredColumn(candidateField: string | number): void { + const syncFilterItemsState = this.pluginOptions?.syncFilterItemsState ?? true; const filteredFields = this.filterStateManager.getActiveFilterFields().filter(field => field !== candidateField); const toUnformatted = new Map(); const formatFn = this.getFormatFnCache(candidateField); const countMap = new Map(); // 计算每个候选值的计数 - const recordsList = this.getRecords(this.table, true); // 已筛选:使用原始表格数据 - const records = recordsList.filter(record => - filteredFields.every(field => { - const filterType = this.filterStateManager.getFilterState(field)?.type; - if (filterType !== 'byValue' && filterType !== null && filterType !== undefined) { - this.syncSingleStateFromTableData(field); - } - const set = this.selectedKeys.get(field); - return set.has(record[field]); - }) - ); + let records = []; + // 如果各个筛选器之间不联动, 则永远从原数据中获取候选值 + if (!syncFilterItemsState) { + records = this.table.internalProps.records; + } else { + const recordsList = this.getRecords(this.table, true); // 已筛选:使用原始表格数据 + const records = recordsList.filter(record => + filteredFields.every(field => { + const filterType = this.filterStateManager.getFilterState(field)?.type; + if (filterType !== 'byValue' && filterType !== null && filterType !== undefined) { + this.syncSingleStateFromTableData(field); + } + const set = this.selectedKeys.get(field); + return set.has(record[field]); + }) + ); + } records.forEach(record => { - const originalValue = record[candidateField]; - const formattedValue = formatFn(record); - countMap.set(formattedValue, (countMap.get(formattedValue) || 0) + 1); - if (formattedValue !== undefined && formattedValue !== null) { - const unformattedSet = toUnformatted.get(formattedValue); - if (unformattedSet !== undefined && unformattedSet !== null) { - unformattedSet.add(originalValue); - } else { - toUnformatted.set(formattedValue, new Set([originalValue])); + // 空行不做处理 + if (isValid(record)) { + const originalValue = record[candidateField]; + const formattedValue = formatFn(record); + countMap.set(formattedValue, (countMap.get(formattedValue) || 0) + 1); + if (formattedValue !== undefined && formattedValue !== null) { + const unformattedSet = toUnformatted.get(formattedValue); + if (unformattedSet !== undefined && unformattedSet !== null) { + unformattedSet.add(originalValue); + } else { + toUnformatted.set(formattedValue, new Set([originalValue])); + } } } }); @@ -182,29 +212,62 @@ export class ValueFilter { syncSingleStateFromTableData(fieldId: string | number): void { const selectedValues = new Set(); const originalValues = new Set(); - - const currentRecords = this.table.internalProps.dataSource.records; // 当前数据 - currentRecords.forEach(record => { - selectedValues.add(record[fieldId]); - }); - const originalRecords = this.table.internalProps.records; // 原始数据 originalRecords.forEach(record => { - originalValues.add(record[fieldId]); + // 空行不做处理 + if (isValid(record)) { + originalValues.add(record[fieldId]); + } }); + const syncFilterItemsState = this.pluginOptions?.syncFilterItemsState ?? true; const hasFiltered = !arrayEqual(Array.from(originalValues), Array.from(selectedValues)); - if (hasFiltered) { - this.selectedKeys.set(fieldId, selectedValues); - - this.filterStateManager.dispatch({ - type: FilterActionType.UPDATE_FILTER, - payload: { - field: fieldId, - values: Array.from(selectedValues), - enable: true + if (syncFilterItemsState) { + if (hasFiltered) { + this.selectedKeys.set(fieldId, selectedValues); + + this.filterStateManager.dispatch({ + type: FilterActionType.UPDATE_FILTER, + payload: { + field: fieldId, + values: Array.from(selectedValues), + enable: true + } + }); + const hasFiltered = !arrayEqual(Array.from(originalValues), Array.from(selectedValues)); + if (hasFiltered) { + this.selectedKeys.set(fieldId, selectedValues); + this.filterStateManager.dispatch({ + type: FilterActionType.ADD_FILTER, + payload: { + field: fieldId, + type: 'byValue', + values: Array.from(selectedValues), + enable: true + } + }); } - }); + } + } else { + const selectedRules = this.filterStateManager.getFilterState(fieldId)?.values; // 如果按值筛选没有状态, 则默认选中所有值 + if (selectedRules) { + const hasFiltered = !arrayEqual(Array.from(originalValues), selectedRules); + if (hasFiltered) { + this.selectedKeys.set(fieldId, new Set(selectedRules)); + this.filterStateManager.dispatch({ + type: FilterActionType.ADD_FILTER, + payload: { + field: fieldId, + type: 'byValue', + values: selectedRules, + enable: true, + shouldKeepUnrelatedState: true + } + }); + } + } else { + this.selectedKeys.set(fieldId, originalValues); + } } } @@ -226,21 +289,28 @@ export class ValueFilter { this.selectedKeys.set(fieldId, new Set(selections)); - if (selections.length > 0 && selections.length < this.valueFilterOptionList.get(fieldId).length) { + const syncFilterItemsState = this.pluginOptions?.syncFilterItemsState ?? true; + + if ( + (selections.length >= 0 && selections.length < this.valueFilterOptionList.get(fieldId).length) || + !syncFilterItemsState + ) { this.filterStateManager.dispatch({ type: FilterActionType.APPLY_FILTERS, payload: { field: fieldId, type: 'byValue', values: selections, - enable: true + enable: true, + shouldKeepUnrelatedState: !syncFilterItemsState } }); } else { this.filterStateManager.dispatch({ type: FilterActionType.REMOVE_FILTER, payload: { - field: fieldId + field: fieldId, + type: 'byValue' } }); } @@ -258,30 +328,31 @@ export class ValueFilter { } render(container: HTMLElement): void { + const filterStyles = this.styles; // === 按值筛选的菜单内容 === this.filterByValuePanel = document.createElement('div'); applyStyles(this.filterByValuePanel, filterStyles.filterPanel); // -- 搜索栏 --- - const searchContainer = document.createElement('div'); - applyStyles(searchContainer, filterStyles.searchContainer); + this.searchContainer = document.createElement('div'); + applyStyles(this.searchContainer, this.styles.searchContainer); this.filterByValueSearchInput = document.createElement('input'); this.filterByValueSearchInput.type = 'text'; - this.filterByValueSearchInput.placeholder = '可使用空格分隔多个关键词'; + this.filterByValueSearchInput.placeholder = filterStyles.searchInput?.placeholder || '可使用空格分隔多个关键词'; applyStyles(this.filterByValueSearchInput, filterStyles.searchInput); - searchContainer.appendChild(this.filterByValueSearchInput); + this.searchContainer.appendChild(this.filterByValueSearchInput); // --- 筛选选项 --- - const optionsContainer = document.createElement('div'); - applyStyles(optionsContainer, filterStyles.optionsContainer); + this.optionsContainer = document.createElement('div'); + applyStyles(this.optionsContainer, this.styles.optionsContainer); - const selectAllItemDiv = document.createElement('div'); - applyStyles(selectAllItemDiv, filterStyles.optionItem); + this.selectAllItemDiv = document.createElement('div'); + applyStyles(this.selectAllItemDiv, this.styles.optionItem); - const selectAllLabel = document.createElement('label'); - applyStyles(selectAllLabel, filterStyles.optionLabel); + this.selectAllLabel = document.createElement('label'); + applyStyles(this.selectAllLabel, this.styles.optionLabel); this.selectAllCheckbox = document.createElement('input'); this.selectAllCheckbox.type = 'checkbox'; @@ -292,20 +363,32 @@ export class ValueFilter { this.totalCountSpan.textContent = ''; applyStyles(this.totalCountSpan, filterStyles.countSpan); - selectAllLabel.append(this.selectAllCheckbox, ' 全选'); - selectAllItemDiv.append(selectAllLabel, this.totalCountSpan); + this.selectAllLabel.append(this.selectAllCheckbox, ' 全选'); + this.selectAllItemDiv.appendChild(this.selectAllLabel); this.filterItemsContainer = document.createElement('div'); // 筛选条目的容器,后续应动态 appendChild - optionsContainer.append(selectAllItemDiv, this.filterItemsContainer); - this.filterByValuePanel.append(searchContainer, optionsContainer); + this.optionsContainer.append(this.selectAllItemDiv, this.filterItemsContainer); + this.filterByValuePanel.append(this.searchContainer, this.optionsContainer); container.appendChild(this.filterByValuePanel); this.bindEventForFilterByValue(); } + updateStyles(styles: FilterStyles): void { + applyStyles(this.filterByValuePanel, styles.filterPanel); + applyStyles(this.searchContainer, styles.searchContainer); + this.filterByValueSearchInput.placeholder = styles.searchInput?.placeholder || '可使用空格分隔多个关键词'; + applyStyles(this.filterByValueSearchInput, styles.searchInput); + applyStyles(this.optionsContainer, styles.optionsContainer); + applyStyles(this.selectAllItemDiv, styles.optionItem); + applyStyles(this.selectAllLabel, styles.optionLabel); + applyStyles(this.selectAllCheckbox, styles.checkbox); + } + private renderFilterOptions(field: string | number): void { + const filterStyles = this.styles; this.filterItemsContainer.innerHTML = ''; this.valueFilterOptionList.delete(field); this.valueFilterOptionList.set(field, []); @@ -354,7 +437,7 @@ export class ValueFilter { countSpan.textContent = String(count); applyStyles(countSpan, filterStyles.countSpan); - label.append(checkbox, ` ${val}`); // UI显示格式化值 + label.append(checkbox, ` ${this.pluginOptions.checkboxItemFormat?.(val, unformattedArr) || val}`); // UI显示格式化值 或 用户二次加工的值 itemDiv.append(label, countSpan); this.filterItemsContainer.appendChild(itemDiv); diff --git a/packages/vtable-plugins/src/focus-highlight.ts b/packages/vtable-plugins/src/focus-highlight.ts index 13dad2da00..797a2b8139 100644 --- a/packages/vtable-plugins/src/focus-highlight.ts +++ b/packages/vtable-plugins/src/focus-highlight.ts @@ -155,6 +155,8 @@ export class FocusHighlightPlugin implements pluginsDefinition.IVTablePlugin { }); } update() { - this.setFocusHighlightRange(this.range, true); + if (this.table) { + this.setFocusHighlightRange(this.range, true); + } } } diff --git a/packages/vtable-sheet/src/formula/formula-engine.ts b/packages/vtable-sheet/src/formula/formula-engine.ts index bae7260d73..f75dfb61eb 100644 --- a/packages/vtable-sheet/src/formula/formula-engine.ts +++ b/packages/vtable-sheet/src/formula/formula-engine.ts @@ -3252,5 +3252,8 @@ export class FormulaEngine { } class FormulaError { - constructor(public message: string, public type: 'REF' | 'VALUE' | 'DIV0' | 'NAME' | 'NA' = 'VALUE') {} + constructor( + public message: string, + public type: 'REF' | 'VALUE' | 'DIV0' | 'NAME' | 'NA' = 'VALUE' + ) {} } diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index 8538d12e19..4224a65291 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -636,6 +636,8 @@ export class ListTable extends BaseTable implements ListTableAPI { } ) { const internalProps = this.internalProps; + + this.pluginManager.removeOrAddPlugins(options.plugins); super.updateOption(options, updateConfig); internalProps.frozenColDragHeaderMode = options.dragOrder?.frozenColDragHeaderMode ?? options.frozenColDragHeaderMode; @@ -1158,6 +1160,7 @@ export class ListTable extends BaseTable implements ListTableAPI { this.clearCellStyleCache(); this.internalProps.layoutMap.clearCellRangeMap(); this.internalProps.useOneRowHeightFillAll = false; + // ts-ignore // this.scenegraph.updateHierarchyIcon(col, row);// 添加了updateCells:[{ col, row }] 就不需要单独更新图标了(只更新图标针对有自定义元素的情况 会有更新不到问题)' // const updateCells = [{ col, row }]; // // 如果需要移出的节点超过了当前加载部分最后一行 则转变成更新对应的行 @@ -1302,6 +1305,7 @@ export class ListTable extends BaseTable implements ListTableAPI { filterRules: FilterRules, options: { clearRowHeightCache?: boolean; + onFilterRecordsEnd?: (records: any[]) => any[]; } = { clearRowHeightCache: true } ) { this.scenegraph.clearCells(); @@ -1309,11 +1313,12 @@ export class ListTable extends BaseTable implements ListTableAPI { this.dataSource.updateFilterRulesForSorted(filterRules); sortRecords(this); } else { - this.dataSource.updateFilterRules(filterRules); + this.dataSource.updateFilterRules(filterRules, options?.onFilterRecordsEnd); } this.refreshRowColCount(); this.stateManager.initCheckedState(this.records); this.scenegraph.createSceneGraph(!!!options?.clearRowHeightCache); + this.internalProps.emptyTip?.resetVisible(); this.resize(); } /** 获取过滤后的数据 */ @@ -1356,7 +1361,7 @@ export class ListTable extends BaseTable implements ListTableAPI { return state && state[field]; }); } - return new Array(...this.stateManager.checkedState.values()); + return [...this.stateManager.checkedState.values()]; } /** 获取某个单元格checkbox的状态 */ getCellCheckboxState(col: number, row: number) { @@ -1845,7 +1850,10 @@ export class ListTable extends BaseTable implements ListTableAPI { }); } } - /** 合并单元格 对外接口 。会自动刷新渲染节点 + /** 获取某个单元格checkbox的状态 */ + + /** + * 合并单元格 对外接口 。会自动刷新渲染节点 * 注意:如果之前options有customMergeCell的函数配置,将失效重置为空数组 */ mergeCells(startCol: number, startRow: number, endCol: number, endRow: number) { diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index 24b3e93a85..5447dcf27f 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -503,16 +503,16 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { ? typeof limitMinWidth === 'number' ? limitMinWidth : limitMinWidth - ? 10 - : 0 + ? 10 + : 0 : 10; internalProps.limitMinHeight = limitMinHeight !== null && limitMinHeight !== undefined ? typeof limitMinHeight === 'number' ? limitMinHeight : limitMinHeight - ? 10 - : 0 + ? 10 + : 0 : 10; // 生成scenegraph // this._vDataSet = new DataSet(); @@ -1488,12 +1488,12 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { // : this.defaultColWidth; if (this.isRowHeader(col, 0) || this.isCornerHeader(col, 0)) { return Array.isArray(this.defaultHeaderColWidth) - ? this.defaultHeaderColWidth[col] ?? this.defaultColWidth + ? (this.defaultHeaderColWidth[col] ?? this.defaultColWidth) : this.defaultHeaderColWidth; } else if (this.isRightFrozenColumn(col, this.columnHeaderLevelCount)) { if (this.isPivotTable()) { return Array.isArray(this.defaultHeaderColWidth) - ? this.defaultHeaderColWidth[this.rowHeaderLevelCount - this.rightFrozenColCount] ?? this.defaultColWidth + ? (this.defaultHeaderColWidth[this.rowHeaderLevelCount - this.rightFrozenColCount] ?? this.defaultColWidth) : this.defaultHeaderColWidth; } return this.defaultColWidth; @@ -1504,15 +1504,15 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { getDefaultRowHeight(row: number) { if (this.isColumnHeader(0, row) || this.isCornerHeader(0, row) || this.isSeriesNumberInHeader(0, row)) { return Array.isArray(this.defaultHeaderRowHeight) - ? this.defaultHeaderRowHeight[row] ?? this.internalProps.defaultRowHeight + ? (this.defaultHeaderRowHeight[row] ?? this.internalProps.defaultRowHeight) : this.defaultHeaderRowHeight; } if (this.isBottomFrozenRow(row)) { //底部冻结行默认取用了表头的行高 但针对非表头数据冻结的情况这里可能不妥 return Array.isArray(this.defaultHeaderRowHeight) - ? this.defaultHeaderRowHeight[ + ? (this.defaultHeaderRowHeight[ this.columnHeaderLevelCount > 0 ? this.columnHeaderLevelCount - this.bottomFrozenRowCount : 0 - ] ?? this.internalProps.defaultRowHeight + ] ?? this.internalProps.defaultRowHeight) : this.defaultHeaderRowHeight; } return this.internalProps.defaultRowHeight; @@ -1884,7 +1884,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { * @returns */ getAllRowsHeight(): number { - if (this.internalProps.rowCount <= 0) { + if (!this.internalProps?.rowCount || this.internalProps.rowCount <= 0) { return 0; } const h = this.getRowsHeight(0, this.internalProps.rowCount - 1); @@ -1895,7 +1895,7 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { * @returns */ getAllColsWidth(): number { - if (this.internalProps.colCount <= 0) { + if (!this.internalProps?.colCount || this.internalProps.colCount <= 0) { return 0; } const w = this.getColsWidth(0, this.internalProps.colCount - 1); @@ -2808,16 +2808,16 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { ? typeof limitMinWidth === 'number' ? limitMinWidth : limitMinWidth - ? 10 - : 0 + ? 10 + : 0 : 10; internalProps.limitMinHeight = limitMinHeight !== null && limitMinHeight !== undefined ? typeof limitMinHeight === 'number' ? limitMinHeight : limitMinHeight - ? 10 - : 0 + ? 10 + : 0 : 10; // 生成scenegraph // this._vDataSet = new DataSet(); diff --git a/packages/vtable/src/core/TABLE_EVENT_TYPE.ts b/packages/vtable/src/core/TABLE_EVENT_TYPE.ts index d816a3a268..b105d7b826 100644 --- a/packages/vtable/src/core/TABLE_EVENT_TYPE.ts +++ b/packages/vtable/src/core/TABLE_EVENT_TYPE.ts @@ -247,6 +247,14 @@ export interface TableEvents { * 删除列事件 */ DELETE_COLUMN: 'delete_column'; + /** + * 筛选菜单显示事件 + */ + FILTER_MENU_SHOW: 'filter_menu_show'; + /** + * 筛选菜单隐藏事件 + */ + FILTER_MENU_HIDE: 'filter_menu_hide'; } /** * Table event types @@ -338,5 +346,8 @@ export const TABLE_EVENT_TYPE: TableEvents = { DELETE_RECORD: 'delete_record', UPDATE_RECORD: 'update_record', ADD_COLUMN: 'add_column', - DELETE_COLUMN: 'delete_column' + DELETE_COLUMN: 'delete_column', + + FILTER_MENU_SHOW: 'filter_menu_show', + FILTER_MENU_HIDE: 'filter_menu_hide' } as TableEvents; diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index 84cc8837fa..f79882ede4 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -1225,10 +1225,14 @@ export class DataSource extends EventTarget implements DataSourceAPI { } } - updateFilterRules(filterRules?: FilterRules): void { + updateFilterRules(filterRules?: FilterRules, onFilterRecordsEnd?: (records: any[]) => any[]): void { this.lastFilterRules = this.dataConfig.filterRules; this.dataConfig.filterRules = filterRules; this._source = this.processRecords(this.dataSourceObj?.records ?? this.dataSourceObj); + // 如果配置了筛选回调, 则用户可自定义处理筛选后的数据 + if (onFilterRecordsEnd) { + onFilterRecordsEnd(this._source as any[]); + } this._sourceLength = this._source?.length || 0; // 初始化currentIndexedData 正常未排序。设置其状态 this.currentIndexedData = Array.from({ length: this._sourceLength }, (_, i) => i); diff --git a/packages/vtable/src/event/event.ts b/packages/vtable/src/event/event.ts index 465f962853..13126cca1d 100644 --- a/packages/vtable/src/event/event.ts +++ b/packages/vtable/src/event/event.ts @@ -284,7 +284,7 @@ export class EventManager { eventArgs.event.shiftKey && shiftMultiSelect, (eventArgs.event.ctrlKey || eventArgs.event.metaKey) && ctrlMultiSelect, false, - isSelectMoving ? false : this.table.options.select?.makeSelectCellVisible ?? true + isSelectMoving ? false : (this.table.options.select?.makeSelectCellVisible ?? true) ); return true; diff --git a/packages/vtable/src/plugins/plugin-manager.ts b/packages/vtable/src/plugins/plugin-manager.ts index b1447ee6f3..e984eac6df 100644 --- a/packages/vtable/src/plugins/plugin-manager.ts +++ b/packages/vtable/src/plugins/plugin-manager.ts @@ -6,6 +6,8 @@ export class PluginManager { private plugins: Map = new Map(); private table: BaseTableAPI; + private pluginEventMap: Map = new Map(); + constructor(table: BaseTableAPI, options: BaseTableConstructorOptions) { this.table = table; options.plugins?.map(plugin => { @@ -33,27 +35,26 @@ export class PluginManager { } _bindTableEventForPlugin(plugin: IVTablePlugin) { - plugin.runTime?.forEach(runTime => { - this.table.on(runTime, (...args) => { + plugin.runTime?.forEach((runTime: any) => { + const id = this.table.on(runTime, (...args) => { plugin.run?.(...args, runTime, this.table); }); + this.pluginEventMap.set(plugin.id, [...(this.pluginEventMap.get(plugin.id) || []), id]); }); } - // 更新所有插件 - updatePlugins(plugins?: IVTablePlugin[]): void { + // 移除或添加插件 + removeOrAddPlugins(plugins?: IVTablePlugin[]): void { // 先找到plugins中没有,但this.plugins中有,也就是已经被移除的插件 const removedPlugins = Array.from(this.plugins.values()).filter(plugin => !plugins?.some(p => p.id === plugin.id)); removedPlugins.forEach(plugin => { + this.pluginEventMap.get(plugin.id)?.forEach(id => { + this.table.off(id); + }); this.release(); this.plugins.delete(plugin.id); }); - // 更新插件 - this.plugins.forEach(plugin => { - if (plugin.update) { - plugin.update(); - } - }); + // 添加新插件 const addedPlugins = plugins?.filter(plugin => !this.plugins.has(plugin.id)); addedPlugins?.forEach(plugin => { @@ -61,6 +62,15 @@ export class PluginManager { this._bindTableEventForPlugin(plugin); }); } + + // 更新插件 + updatePlugins(plugins?: IVTablePlugin[]): void { + plugins?.forEach(plugin => { + if (plugin.update) { + plugin.update(); + } + }); + } release() { this.plugins.forEach(plugin => { plugin.release?.(this.table); diff --git a/packages/vtable/src/state/state.ts b/packages/vtable/src/state/state.ts index b16f0b0705..e16c0fa61a 100644 --- a/packages/vtable/src/state/state.ts +++ b/packages/vtable/src/state/state.ts @@ -1187,6 +1187,9 @@ export class StateManager { } } setScrollTop(top: number, event?: FederatedWheelEvent, triggerEvent: boolean = true) { + if (!this.table || !this.table.scenegraph) { + return; + } // 矫正top值范围 const totalHeight = this.table.getAllRowsHeight(); // _disableColumnAndRowSizeRound环境中,可能出现 @@ -1194,10 +1197,10 @@ export class StateManager { // (由于小数在取数时被省略) // 这里加入tolerance,避免出现无用滚动 const sizeTolerance = this.table.options.customConfig?._disableColumnAndRowSizeRound ? 1 : 0; - top = Math.max(0, Math.min(top, totalHeight - this.table.scenegraph.height - sizeTolerance)); + top = Math.max(0, Math.min(top, totalHeight - (this.table.scenegraph?.height ?? 0) - sizeTolerance)); top = Math.ceil(top); const oldVerticalBarPos = this.scroll.verticalBarPos; - const yRatio = top / (totalHeight - this.table.scenegraph.height); + const yRatio = top / (totalHeight - (this.table.scenegraph?.height ?? 0)); if ( (oldVerticalBarPos !== top || this.table.options?.customConfig?.scrollEventAlwaysTrigger === true) && @@ -1224,7 +1227,7 @@ export class StateManager { if (canScroll.some(value => value === false)) { // reset scrollbar pos - const yRatio = this.scroll.verticalBarPos / (totalHeight - this.table.scenegraph.height); + const yRatio = this.scroll.verticalBarPos / (totalHeight - (this.table.scenegraph?.height ?? 0)); this.table.scenegraph.component.updateVerticalScrollBarPos(yRatio); return; } @@ -1264,6 +1267,9 @@ export class StateManager { } } setScrollLeft(left: number, event?: FederatedWheelEvent, triggerEvent: boolean = true) { + if (!this.table || !this.table.scenegraph) { + return; + } const oldScrollLeft = this.table.scrollLeft; // 矫正left值范围 const totalWidth = this.table.getAllColsWidth(); diff --git a/packages/vtable/src/ts-types/events.ts b/packages/vtable/src/ts-types/events.ts index fd1890bd30..eca14ff897 100644 --- a/packages/vtable/src/ts-types/events.ts +++ b/packages/vtable/src/ts-types/events.ts @@ -447,4 +447,7 @@ export interface TableEventHandlersReturnMap { update_record: void; add_column: void; delete_column: void; + + filter_menu_show: { col: number; row: number }; + filter_menu_hide: { col: number; row: number }; }