diff --git a/frontend/packages/lib-shared/enums/tableEnum.ts b/frontend/packages/lib-shared/enums/tableEnum.ts new file mode 100644 index 000000000..fb9e10dae --- /dev/null +++ b/frontend/packages/lib-shared/enums/tableEnum.ts @@ -0,0 +1,11 @@ +export enum TableKeyEnum { + SYSTEM_USER = 'systemUser', // TODO lmy 没用 可删 +} + +// 具有特殊功能的列 +export enum SpecialColumnEnum { + // 选择框 + SELECTION = 'selection', + // 操作列 + OPERATION = 'operation', +} \ No newline at end of file diff --git a/frontend/packages/lib-shared/method/equal.ts b/frontend/packages/lib-shared/method/equal.ts new file mode 100644 index 000000000..6aff7731a --- /dev/null +++ b/frontend/packages/lib-shared/method/equal.ts @@ -0,0 +1,28 @@ +import { sortBy } from 'lodash-es'; + +/** + * 比较两个一维数组对象是否相等,不考虑顺序, + * @param arr1 数组1 + * @param arr2 数组2 + * @returns boolean + */ +export function isArraysEqualWithOrder(arr1: T[], arr2: T[]): boolean { + if (arr1.length !== arr2.length) { + return false; + } + const sortArr1 = sortBy(arr1, 'dataIndex'); + const sortArr2 = sortBy(arr2, 'dataIndex'); + for (let i = 0; i < sortArr1.length; i++) { + const obj1 = sortArr1[i]; + const obj2 = sortArr2[i]; + + // 逐一比较对象 + if (JSON.stringify(obj1) !== JSON.stringify(obj2)) { + return false; + } + } + + return true; +} + +export default {}; diff --git a/frontend/packages/lib-shared/models/common.ts b/frontend/packages/lib-shared/models/common.ts index df8534586..278458d04 100644 --- a/frontend/packages/lib-shared/models/common.ts +++ b/frontend/packages/lib-shared/models/common.ts @@ -5,3 +5,26 @@ export default interface CommonResponse { messageDetail: string; data: T; } + +// 表格查询 +export interface TableQueryParams { + // 当前页 + current?: number; + // 每页条数 + pageSize?: number; + // 排序仅针对单个字段 + sort?: object; + // 表头筛选 + filter?: object; + // 查询条件 + keyword?: string; + [key: string]: any; +} + +export interface CommonList { + [x: string]: any; + pageSize: number; + total: number; + current: number; + list: T[]; +} diff --git a/frontend/packages/web/package.json b/frontend/packages/web/package.json index d3159f521..f0cfe0af8 100644 --- a/frontend/packages/web/package.json +++ b/frontend/packages/web/package.json @@ -23,7 +23,8 @@ "@vicons/ionicons5": "^0.13.0", "naive-ui": "^2.40.4", "nprogress": "^0.2.0", - "vfonts": "^0.0.3" + "vfonts": "^0.0.3", + "vue-draggable-plus": "^0.6.0" }, "devDependencies": { "@vitejs/plugin-legacy": "^6.0.0", diff --git a/frontend/packages/web/src/App.vue b/frontend/packages/web/src/App.vue index 627c86031..9c4001165 100644 --- a/frontend/packages/web/src/App.vue +++ b/frontend/packages/web/src/App.vue @@ -1,6 +1,8 @@ diff --git a/frontend/packages/web/src/components/pure/crm-table/components/columnSetting.vue b/frontend/packages/web/src/components/pure/crm-table/components/columnSetting.vue new file mode 100644 index 000000000..5eff3929e --- /dev/null +++ b/frontend/packages/web/src/components/pure/crm-table/components/columnSetting.vue @@ -0,0 +1,84 @@ + + + diff --git a/frontend/packages/web/src/components/pure/crm-table/index.vue b/frontend/packages/web/src/components/pure/crm-table/index.vue new file mode 100644 index 000000000..cf2f52439 --- /dev/null +++ b/frontend/packages/web/src/components/pure/crm-table/index.vue @@ -0,0 +1,115 @@ + + + diff --git a/frontend/packages/web/src/components/pure/crm-table/locale/en-US.ts b/frontend/packages/web/src/components/pure/crm-table/locale/en-US.ts new file mode 100644 index 000000000..5b842f021 --- /dev/null +++ b/frontend/packages/web/src/components/pure/crm-table/locale/en-US.ts @@ -0,0 +1,4 @@ +export default { + 'crmTable.columnSetting.tableHeaderDisplaySettings': 'Table header display settings', + 'crmTable.columnSetting.resetDefault': 'Restore default', +}; diff --git a/frontend/packages/web/src/components/pure/crm-table/locale/zh-CN.ts b/frontend/packages/web/src/components/pure/crm-table/locale/zh-CN.ts new file mode 100644 index 000000000..22268c5ad --- /dev/null +++ b/frontend/packages/web/src/components/pure/crm-table/locale/zh-CN.ts @@ -0,0 +1,4 @@ +export default { + 'crmTable.columnSetting.tableHeaderDisplaySettings': '表头显示设置', + 'crmTable.columnSetting.resetDefault': '恢复默认', +}; diff --git a/frontend/packages/web/src/components/pure/crm-table/type.ts b/frontend/packages/web/src/components/pure/crm-table/type.ts new file mode 100644 index 000000000..568de853b --- /dev/null +++ b/frontend/packages/web/src/components/pure/crm-table/type.ts @@ -0,0 +1,33 @@ +import { VNodeChild } from 'vue'; + +import type { TableKeyEnum } from '@lib/shared/enums/tableEnum'; +import type { DataTableColumn, DataTableColumnKey, DataTableProps, DataTableRowData, DataTableRowKey } from 'naive-ui'; + +export type CrmTableDataItem = T & { + updateTime?: string | number | null; + createTime?: string | number | null; + children?: CrmTableDataItem[]; +} & DataTableRowData; + +export type CrmDataTableColumn = DataTableColumn & { + showInTable?: boolean; // 是否展示在表格上 + key?: DataTableColumnKey; // 这一列的 key,不可重复 + title?: string | (() => VNodeChild); +}; + +export interface CrmTableProps extends DataTableProps { + 'columns': CrmDataTableColumn[]; + 'tableKey'?: TableKeyEnum; // 表格key, 用于存储表格列配置,pageSize等 + 'tableRowKey'?: string; // 表格行的key + 'data': CrmTableDataItem[]; + 'showSetting'?: boolean; // 是否显示表格配置 + 'showPagination'?: boolean; // 是否显示分页 + 'onUpdate:checkedRowKeys'?: (key: DataTableRowKey[]) => void; // 覆写类型防止报错 +} + +// 表格存储 +export interface TableStorageConfigItem { + column: CrmDataTableColumn[]; // 列配置 + pageSize?: number; + columnBackup: CrmDataTableColumn[]; // 列配置的备份,用于比较当前定义的列配置是否和备份的列配置相同 +} diff --git a/frontend/packages/web/src/components/pure/crm-table/useTable.ts b/frontend/packages/web/src/components/pure/crm-table/useTable.ts new file mode 100644 index 000000000..74212a4da --- /dev/null +++ b/frontend/packages/web/src/components/pure/crm-table/useTable.ts @@ -0,0 +1,169 @@ +import { UnwrapRef } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import dayjs from 'dayjs'; + +import { useAppStore, useTableStore } from '@/store'; + +import type { CrmTableDataItem, CrmTableProps } from './type'; +import type { CommonList, TableQueryParams } from '@lib/shared/models/common'; +import type { DataTableFilterState, PaginationProps } from 'naive-ui'; + +const tableStore = useTableStore(); +const appStore = useAppStore(); + +export default function useTable( + loadListFunc?: (v?: TableQueryParams | any) => Promise> | CrmTableDataItem>, + props?: Partial> +) { + const defaultProps: CrmTableProps = { + bordered: false, + loading: false, // 加载效果 + data: [], // 表格数据 + columns: [], + tableRowKey: 'id', // 表格行的key + pagination: { + page: 1, + itemCount: 0, + pageSize: appStore.pageSize, + pageSizes: appStore.pageSizes, + showSizePicker: appStore.showSizePicker, + showQuickJumper: appStore.showQuickJumper, + }, // false | PaginationProps; false表示不分页 + ...props, + }; + + const propsRes = ref>(cloneDeep(defaultProps)); + + // 如果表格设置了tableKey,设置缓存的分页大小 + if (propsRes.value.pagination && typeof propsRes.value.pagination === 'object' && propsRes.value.tableKey) { + tableStore.getPageSize(propsRes.value.tableKey).then((res) => { + if (propsRes.value.pagination && res) { + propsRes.value.pagination.pageSize = res; + } + }); + } + + // 加载效果 + function setLoading(status: boolean) { + propsRes.value.loading = status; + } + + // 设置请求参数 + const loadListParams = ref({}); + function setLoadListParams(params?: TableQueryParams) { + loadListParams.value = params || {}; + } + + // 获取分页参数 + async function getPaginationParams() { + const { page, pageSize } = propsRes.value.pagination as PaginationProps; + if (propsRes.value.tableKey) { + const cachedPageSize = await tableStore.getPageSize(propsRes.value.tableKey); + return { current: page, pageSize: cachedPageSize }; + } + return { current: page, pageSize }; + } + + /** + * 分页设置 + * @param page 当前页 + * @param total 总页数 + */ + function setPagination(page: number, total?: number) { + if (propsRes.value.pagination && typeof propsRes.value.pagination === 'object') { + propsRes.value.pagination.page = page; + if (total !== undefined) { + propsRes.value.pagination.itemCount = total; + } + } + } + + const tableQueryParams = ref({}); // 表格请求参数集合 + const keyword = ref(''); + const sortItem = ref>({}); // 排序 + + const filterItem = ref>({}); // 筛选 + + function processRecordItem(item: CrmTableDataItem): CrmTableDataItem { + if (item.updateTime) { + item.updateTime = dayjs(item.updateTime).format('YYYY-MM-DD HH:mm:ss'); + } + if (item.createTime) { + item.createTime = dayjs(item.createTime).format('YYYY-MM-DD HH:mm:ss'); + } + return item; + } + + async function loadList() { + if (!loadListFunc) return; + setLoading(true); + try { + tableQueryParams.value = { + ...(!propsRes.value.pagination ? {} : await getPaginationParams()), + keyword: keyword.value, + sort: sortItem.value, + ...loadListParams.value, + filter: filterItem.value, + }; + const data = await loadListFunc(tableQueryParams.value); + if (!propsRes.value.pagination && Array.isArray(data)) { + propsRes.value.data = data.map((item: CrmTableDataItem) => { + return processRecordItem(item); + }) as unknown as UnwrapRef[]>; + } else { + const tmpArr = data as CommonList>; + propsRes.value.data = tmpArr.list.map((item: CrmTableDataItem) => { + return processRecordItem(item); + }) as unknown as UnwrapRef[]>; + // 设置分页 + setPagination(tmpArr.current, tmpArr.total); + } + } catch (error) { + propsRes.value.data = []; + // eslint-disable-next-line no-console + console.error(error); + throw error; + } finally { + setLoading(false); + } + } + + // 事件触发组 + const propsEvent = ref({ + // 分页触发 + pageChange: async (page: number) => { + setPagination(page); + await loadList(); + }, + // 修改每页显示条数触发 + pageSizeChange: async (pageSize: number) => { + if (propsRes.value.pagination && typeof propsRes.value.pagination === 'object') { + propsRes.value.pagination.pageSize = pageSize; + // 如果表格设置了tableKey,缓存分页大小 + if (propsRes.value.tableKey) { + await tableStore.setPageSize(propsRes.value.tableKey, pageSize); + } + } + loadList(); + }, + // 排序触发 + sorterChange: (sortObj: { [key: string]: string }) => { + sortItem.value = sortObj; + loadList(); + }, + // 筛选触发 + filterChange: (filters: DataTableFilterState) => { + filterItem.value = { ...filters }; + loadList(); + }, + }); + + return { + propsRes, + propsEvent, + setLoading, + setLoadListParams, + loadList, + setPagination, + }; +} diff --git a/frontend/packages/web/src/hooks/useLocalForage.ts b/frontend/packages/web/src/hooks/useLocalForage.ts new file mode 100644 index 000000000..6d250d4b6 --- /dev/null +++ b/frontend/packages/web/src/hooks/useLocalForage.ts @@ -0,0 +1,109 @@ +import localforage from 'localforage'; + +import useAppStore from '@/store/modules/app'; + +export default function useLocalForage() { + const appStore = useAppStore(); + + /** + * 检测并序列化函数 + * @param val 要存储的值 + */ + const serializeValue = (val: any): any => { + if (typeof val === 'function') { + return `function:${val.toString()}`; + } + if (val && typeof val === 'object' && !Array.isArray(val)) { + const newVal = { ...val }; + Object.keys(newVal).forEach((key) => { + newVal[key] = serializeValue(newVal[key]); + }); + return newVal; + } + if (Array.isArray(val)) { + return val.map((item) => serializeValue(item)); + } + return val; + }; + + const deserializeFunction = (funcStr: string) => { + try { + if (!funcStr.trim().startsWith('function') && !funcStr.trim().startsWith('(')) { + funcStr = `function ${funcStr}`; + } + // eslint-disable-next-line no-eval + const func = eval(`(${funcStr})`); + return func; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return null; + } + }; + + /** + * 反序列化值,将特殊格式字符串转换回函数 + * @param val 从存储中读取的值 + */ + const deserializeValue = (val: any): T | null => { + if (typeof val === 'string' && val.startsWith('function:')) { + return deserializeFunction(val.slice(9)) as T; + } + if (val && typeof val === 'object' && !Array.isArray(val)) { + const newVal = { ...val }; + Object.keys(newVal).forEach((key) => { + newVal[key] = deserializeValue(newVal[key]); + }); + return newVal as T; + } + if (Array.isArray(val)) { + return val.map((item) => deserializeValue(item) as T) as T; + } + return val; + }; + + /** + * 读取本地存储的数据 + * @param key 唯一 key + * @param notIsolatedByProject 存储数据时是否不按项目隔离数据 + */ + const getItem = async (key: string, notIsolatedByProject = false): Promise => { + const itemKey = notIsolatedByProject ? key : `${appStore.currentProjectId}-${key}`; + try { + const res = await localforage.getItem(itemKey); + if (!res) { + return null; + } + return deserializeValue(res); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + return null; + } + }; + + /** + * 永久存储数据 + * @param key 唯一 key + * @param val 存储的值 + * @param notIsolatedByProject 是否不按项目隔离数据 + */ + const setItem = async ( + key: string, + val: string | number | boolean | Record, + notIsolatedByProject = false + ) => { + try { + const itemKey = notIsolatedByProject ? key : `${appStore.currentProjectId}-${key}`; + await localforage.setItem(itemKey, serializeValue(val)); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + } + }; + + return { + getItem, + setItem, + }; +} diff --git a/frontend/packages/web/src/hooks/useTableStore.ts b/frontend/packages/web/src/hooks/useTableStore.ts new file mode 100644 index 000000000..51cbefb6b --- /dev/null +++ b/frontend/packages/web/src/hooks/useTableStore.ts @@ -0,0 +1,135 @@ +import { cloneDeep } from 'lodash-es'; + +import type { CrmDataTableColumn, TableStorageConfigItem } from '@/components/pure/crm-table/type'; + +import { useAppStore } from '@/store'; + +import useLocalForage from './useLocalForage'; +import { SpecialColumnEnum, TableKeyEnum } from '@lib/shared/enums/tableEnum'; +import { isArraysEqualWithOrder } from '@lib/shared/method/equal'; + +export default function useTableStore() { + const { getItem, setItem } = useLocalForage(); + const appStore = useAppStore(); + + async function getTableColumnsMap(tableKey: TableKeyEnum): Promise { + const isSystemOrOrgKey = tableKey.startsWith('SYSTEM') || tableKey.startsWith('ORGANIZATION'); + const tableColumnsMap = await getItem(tableKey, isSystemOrOrgKey); + return tableColumnsMap; + } + + async function setTableColumnsMap(tableKey: TableKeyEnum, tableColumnsMap: TableStorageConfigItem) { + const isSystemOrOrgKey = tableKey.startsWith('SYSTEM') || tableKey.startsWith('ORGANIZATION'); + await setItem(tableKey, tableColumnsMap, isSystemOrOrgKey); + } + + function columnsTransform(columns: CrmDataTableColumn[]) { + columns.forEach((item) => { + if (item.showInTable === undefined) { + // 默认在表格中展示 + item.showInTable = true; + } + }); + return columns; + } + + async function initColumn(tableKey: TableKeyEnum, column: CrmDataTableColumn[]) { + try { + const tableColumnsMap = await getTableColumnsMap(tableKey); + if (!tableColumnsMap) { + // 如果没有在indexDB里初始化 + column = columnsTransform(column); + setTableColumnsMap(tableKey, { + column, + columnBackup: cloneDeep(column), + }); + } else { + // 初始化过了,但是可能有新变动,如列的顺序,列的显示隐藏,列的拖拽 + column = columnsTransform(column); + const { columnBackup: oldColumn } = tableColumnsMap; + // 比较页面上定义的 column 和 浏览器备份的column 是否相同 + const isEqual = isArraysEqualWithOrder(oldColumn, column); + if (!isEqual) { + column.forEach((col) => { + const storedCol = tableColumnsMap.column.find((sc) => sc.key === col.key); + if (storedCol) { + col.width = storedCol.width; // 使用上一次拖拽存储的宽度,避免组件里边使用时候初始化到最初的列宽 + } + }); + // 如果不相等,说明有变动将新的column存入indexDB + setTableColumnsMap(tableKey, { + column, + columnBackup: cloneDeep(column), + }); + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + } + } + + // 表头显示设置的列 + async function getCanSetColumns(tableKey: TableKeyEnum) { + const tableColumnsMap = await getTableColumnsMap(tableKey); + if (tableColumnsMap) { + return tableColumnsMap.column.filter( + (item) => item.key !== SpecialColumnEnum.OPERATION && item.type !== SpecialColumnEnum.SELECTION + ); + } + return []; + } + + // 在表格上展示的列 + async function getShowInTableColumns(tableKey: TableKeyEnum) { + const tableColumnsMap = await getTableColumnsMap(tableKey); + if (tableColumnsMap) { + return tableColumnsMap.column.filter((i) => i.showInTable); + } + return []; + } + + async function setColumns(tableKey: TableKeyEnum, columns: CrmDataTableColumn[]) { + try { + const tableColumnsMap = await getTableColumnsMap(tableKey); + if (tableColumnsMap) { + const operationColumn = tableColumnsMap.column.find((i) => i.key === SpecialColumnEnum.OPERATION); + const selectColumn = tableColumnsMap.column.find((i) => i.type === SpecialColumnEnum.SELECTION); + if (selectColumn) columns.unshift(selectColumn); // 加上选择框列 + if (operationColumn) columns.push(operationColumn); // 加上操作列 + tableColumnsMap.column = cloneDeep(columns); + await setTableColumnsMap(tableKey, tableColumnsMap); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('tableStore.setColumns', e); + } + } + + async function getPageSize(tableKey: TableKeyEnum) { + const tableColumnsMap = await getTableColumnsMap(tableKey); + return tableColumnsMap ? tableColumnsMap.pageSize : appStore.pageSize; + } + + async function setPageSize(tableKey: TableKeyEnum, pageSize: number): Promise { + try { + const tableColumnsMap = await getTableColumnsMap(tableKey); + if (tableColumnsMap) { + tableColumnsMap.pageSize = pageSize; + await setTableColumnsMap(tableKey, tableColumnsMap); + } + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + } + } + + return { + initColumn, + getCanSetColumns, + setColumns, + getShowInTableColumns, + setPageSize, + getPageSize, + }; +} diff --git a/frontend/packages/web/src/main.ts b/frontend/packages/web/src/main.ts index 5ef7e25ea..f84ec8bdd 100644 --- a/frontend/packages/web/src/main.ts +++ b/frontend/packages/web/src/main.ts @@ -1,5 +1,4 @@ import { createApp } from 'vue'; -import { createPinia } from 'pinia'; // TODO 国际化接口对接 // import localforage from 'localforage'; @@ -11,11 +10,12 @@ import App from './App.vue'; import { setupI18n } from './locale'; import useLocale from './locale/useLocale'; import router from './router'; +import store from './store'; async function setupApp() { const app = createApp(App); - app.use(createPinia()); + app.use(store); // 注册国际化,需要异步阻塞,确保语言包加载完毕 await setupI18n(app); diff --git a/frontend/packages/web/src/store/index.ts b/frontend/packages/web/src/store/index.ts index 0462911ed..31b3fe605 100644 --- a/frontend/packages/web/src/store/index.ts +++ b/frontend/packages/web/src/store/index.ts @@ -1,7 +1,13 @@ import { createPinia } from 'pinia'; +import useTableStore from '@/hooks/useTableStore'; + +import useAppStore from './modules/app'; import { debouncePlugin } from './plugins'; import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; const pinia = createPinia().use(debouncePlugin).use(piniaPluginPersistedstate); + +export { useAppStore, useTableStore }; + export default pinia; diff --git a/frontend/packages/web/src/store/modules/app/index.ts b/frontend/packages/web/src/store/modules/app/index.ts index b353bab78..a8c827261 100644 --- a/frontend/packages/web/src/store/modules/app/index.ts +++ b/frontend/packages/web/src/store/modules/app/index.ts @@ -1,12 +1,25 @@ import { defineStore } from 'pinia'; +import type { PaginationSizeOption } from 'naive-ui'; + export interface AppState { + currentProjectId: string; menuCollapsed: boolean; + pageSize: number; + showSizePicker: boolean; + showQuickJumper: boolean; + pageSizes: Array; } const useAppStore = defineStore('app', { state: (): AppState => ({ + currentProjectId: '', menuCollapsed: false, + // 分页 + pageSize: 10, + showSizePicker: true, + showQuickJumper: true, + pageSizes: [10, 20, 30, 40, 50], }), getters: { getMenuCollapsed(state: AppState) { @@ -18,6 +31,9 @@ const useAppStore = defineStore('app', { this.menuCollapsed = collapsed; }, }, + persist: { + paths: ['currentProjectId'], + }, }); export default useAppStore; diff --git a/frontend/packages/web/src/views/AboutView.vue b/frontend/packages/web/src/views/AboutView.vue index 537125f6a..60cbcdb4e 100644 --- a/frontend/packages/web/src/views/AboutView.vue +++ b/frontend/packages/web/src/views/AboutView.vue @@ -36,15 +36,18 @@ + +