diff --git a/src/components/r-form/index.tsx b/src/components/r-form/index.tsx new file mode 100644 index 0000000..114e944 --- /dev/null +++ b/src/components/r-form/index.tsx @@ -0,0 +1,322 @@ +import { useStyle } from './style.ts' +import { Badge, Button, Divider, Form, Popconfirm, Space, Tooltip } from 'antd' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { ModelContext, useSpanModel } from '@/store/r-form/model.ts' +import { ReactNode, useEffect, useState } from 'react' +import { transformAntdTableProColumns } from './utils' +import Action from '@/components/action/Action.tsx' +import { FilterOutlined } from '@ant-design/icons' +import ListPageLayout from '@/layout/ListPageLayout.tsx' +import { Table as ProTable } from '@/components/table' +import { getValueCount, unSetColumnRules } from '@/utils' +import { BetaSchemaForm, ProColumns, ProFormColumnsType } from '@ant-design/pro-components' +import { useApiContext } from '@/context.ts' +import { useDeepCompareEffect } from 'react-use' +import { RFormTypes } from '@/types/r-form/model' + + +export interface RFormProps { + title?: ReactNode + namespace?: string + columns?: ProColumns[] //重写columns +} + +const RForm = ({ namespace, columns: propColumns = [], title }: RFormProps) => { + + const { styles, cx } = useStyle() + const apiCtx = useApiContext() + const { + apiAtom, + deleteModelAtom, + modelAtom, + modelCURDAtom, + modelsAtom, + modelSearchAtom, + saveOrUpdateModelAtom + } = useSpanModel(namespace || apiCtx?.menu?.meta?.name || 'default') as ModelContext + const [ form ] = Form.useForm() + const [ filterForm ] = Form.useForm() + const setApi = useSetAtom(apiAtom) + const [ model, setModel ] = useAtom(modelAtom) + const { mutate: saveOrUpdate, isPending: isSubmitting, isSuccess } = useAtomValue(saveOrUpdateModelAtom) + const [ search, setSearch ] = useAtom(modelSearchAtom) + const { data, isFetching, isLoading, refetch } = useAtomValue(modelsAtom) + const { mutate: deleteModel, isPending: isDeleting } = useAtomValue(deleteModelAtom) + const { data: curdModal, isLoading: curdLoading, refetch: reloadCURDModal } = useAtomValue(modelCURDAtom) + const [ open, setOpen ] = useState(false) + const [ openFilter, setFilterOpen ] = useState(false) + const [ searchKey, setSearchKey ] = useState(search?.key) + const [ columns, setColumns ] = useState([]) + + useDeepCompareEffect(() => { + + const res = transformAntdTableProColumns(curdModal?.columns || [], propColumns) + const _columns = [ { + title: 'ID', + dataIndex: 'id', + hideInTable: true, + hideInSearch: true, + formItemProps: { hidden: true } + } ].concat(res as any).concat([ + { + title: '操作', + dataIndex: 'option', + valueType: 'option', + fixed: 'right', + render: (_, record) => [ + { + form.setFieldsValue(record) + setOpen(true) + }}>{'编辑'}, + { + deleteModel([ record.id ]) + }} + title={'确定要删除吗?'}> + + 删除 + + + ] + } as any + ]) + setColumns(_columns) + }, [ curdModal?.columns, propColumns, deleteModel, form, isDeleting, setOpen, ]) + + useEffect(() => { + if (apiCtx.isApi && apiCtx.api) { + setApi(apiCtx.api) + reloadCURDModal() + } + }, [ apiCtx.isApi, apiCtx.api ]) + + useDeepCompareEffect(() => { + + setSearchKey(search?.key) + + filterForm.setFieldsValue(search) + + }, [ search ]) + + useEffect(() => { + if (isSuccess) { + setOpen(false) + } + }, [ isSuccess ]) + + const formProps = curdModal?.form.layoutType === 'DrawerForm' ? { + layoutType: 'DrawerForm', + drawerProps: { + maskClosable: false, + } + } : { + layoutType: 'ModalForm', + modalProps: { + maskClosable: false, + } + } + + const renderTitle = () => { + if (title) { + return title + } + + if (apiCtx.menu) { + const { menu } = apiCtx + return menu.title + } + return null + } + + const tableTitle = <> + + + + return ( + <> + + + { + setSearch(prev => ({ + ...prev, + title: value + })) + }, + allowClear: true, + onChange: (e) => { + setSearchKey(e.target?.value) + }, + value: searchKey, + placeholder: '输入关键字搜索', + },*/ + actions: [ + + + + + + + ) + }, + + }} + + + onFinish={async (values) => { + //处理,变成数组 + Object.keys(values).forEach(key => { + if (typeof values[key] === 'string' && values[key].includes(',')) { + values[key] = values[key].split(',') + } + }) + + setSearch(values) + + }} + columns={unSetColumnRules(columns.filter(item => !item.hideInSearch) as ProFormColumnsType[])}/> + + + + ) +} + +export default RForm \ No newline at end of file diff --git a/src/components/r-form/style.ts b/src/components/r-form/style.ts new file mode 100644 index 0000000..2524a20 --- /dev/null +++ b/src/components/r-form/style.ts @@ -0,0 +1,13 @@ +import { createStyles } from '@/theme' + +export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any) => { + const prefix = `${prefixCls}-${token?.proPrefix}-x-form-component` + + const container = css` + + ` + + return { + container: cx(prefix, props?.className, container), + } +}) \ No newline at end of file diff --git a/src/components/r-form/utils/index.tsx b/src/components/r-form/utils/index.tsx new file mode 100644 index 0000000..868bbd2 --- /dev/null +++ b/src/components/r-form/utils/index.tsx @@ -0,0 +1,90 @@ +import { RFormTypes } from '@/types/r-form/model' +import { ProColumns } from '@ant-design/pro-components' +import Switch from '@/components/switch' +import { Checkbox, DatePicker, Input, Radio, Select, TreeSelect } from 'antd' +import request from '@/request' +import { convertToBool } from '@/utils' +import { has, get } from 'lodash' +import { mapTree } from '@/utils/tree.ts' + + +const getValueType = (column: RFormTypes.IColumn) => { + + return column.valueType +} + +//根据type返回对应的组件 +const getComponent = (column: RFormTypes.IColumn) => { + const type = getValueType(column) as any + switch (type) { + case 'input': + return Input + case 'select': + return Select + case 'date': + return DatePicker + case 'switch': + return Switch + case 'radio': + return Radio + case 'checkbox': + return Checkbox + case 'textarea': + return Input.TextArea + case 'tree': + case 'treeSelect': + return TreeSelect + default: + return Input + } +} + + +export const transformAntdTableProColumns = (columns: RFormTypes.IColumn[], overwriteColumns?: ProColumns[]) => { + + const overwriteKeys = [] as string[] + + return (columns || []).map(item => { + + + const type = getValueType(item) + + const overwrite = overwriteColumns?.find(i => i.dataIndex === item.dataIndex || item.name) + if (overwrite) { + overwriteKeys.push(item.prop) + } + + return { + ...item, + request: item.dicUrl ? async (params, props) => { + const { fieldProps: { dataFiledNames } } = props + const { value, res: resKey, label, disabled: disabledKey, children } = dataFiledNames || {} + const url = `/${item.dicUrl.replace(/^:/, '/')}` + return request[item.dicMethod || 'get'](url, params).then(res => { + const data = has(res.data, resKey) ? get(res.data, resKey) : res.data + + return mapTree(data || [], (i: any) => { + const disabled = has(i, disabledKey) ? get(i, disabledKey) : ('status' in i ? !convertToBool(i.status) : false) + return { + title: i[label || 'label'], + label: i[label || 'label'], + value: i[value || 'id'], + disabled, + data: i + } + + }, { children }) + }) + } : undefined, + // renderFormItem: (_scheam, config, dom) => { + // + // }, + render: (text: any, _record: any) => { + + return text + }, + ...overwrite + } as ProColumns + }).concat(overwriteColumns?.filter(i => !overwriteKeys.includes(i.dataIndex)) || []) + +} \ No newline at end of file diff --git a/src/components/x-form/utils/index.tsx b/src/components/x-form/utils/index.tsx index 3627864..f62b260 100644 --- a/src/components/x-form/utils/index.tsx +++ b/src/components/x-form/utils/index.tsx @@ -4,6 +4,8 @@ import Switch from '@/components/switch' import { Checkbox, DatePicker, Input, Radio, Select, TreeSelect } from 'antd' import request from '@/request' import { convertToBool, genProTableColumnWidthProps } from '@/utils' +import { has, get } from 'lodash' +import { mapTree } from '@/utils/tree.ts' const getValueType = (column: XFormTypes.IColumn) => { @@ -109,13 +111,13 @@ export const transformAntdTableProColumns = (columns: XFormTypes.IColumn[], over rowProps, request: item.dicUrl ? async (params, props) => { const { fieldProps: { dataFiledNames } } = props - const { value, res: resKey, label } = dataFiledNames || {} + const { value, res: resKey, label, disabled: disabledKey, children } = dataFiledNames || {} const url = `/${item.dicUrl.replace(/^:/, '/')}` return request[item.dicMethod || 'get'](url, params).then(res => { - return (res.data?.[resKey] || res.data || []).map((i: any) => { - // console.log(i) - const disabled = 'disabled' in i ? i.disabled : - ('status' in i ? !convertToBool(i.status) : false) + const data = has(res.data, resKey) ? get(res.data, resKey) : res.data + + return mapTree(data || [], (i: any) => { + const disabled = has(i, disabledKey) ? get(i, disabledKey) : ('status' in i ? !convertToBool(i.status) : false) return { title: i[label || 'label'], label: i[label || 'label'], @@ -123,7 +125,8 @@ export const transformAntdTableProColumns = (columns: XFormTypes.IColumn[], over disabled, data: i } - }) + + }, { children }) }) } : undefined, renderFormItem: (_scheam, config) => { diff --git a/src/pages/r-form/index.tsx b/src/pages/r-form/index.tsx new file mode 100644 index 0000000..8715a53 --- /dev/null +++ b/src/pages/r-form/index.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/react-router' +import RForm from '@/components/r-form' + +const RFormRender = () => { + + return <> + + +} + +type RFormRouteSearch = { + api: string +} + +// @ts-ignore fix route id +export const Route = createFileRoute('x-form')({ + validateSearch: (search: Record): RFormRouteSearch => { + // validate and parse the search params into a typed state + // console.log(search.id) + return { + api: (search.api ?? '') as string + } as RFormRouteSearch + }, +}) + +export default RFormRender \ No newline at end of file diff --git a/src/pages/r-form/style.ts b/src/pages/r-form/style.ts new file mode 100644 index 0000000..052d895 --- /dev/null +++ b/src/pages/r-form/style.ts @@ -0,0 +1,29 @@ + +import { createStyles } from '@/theme' + +export const useStyle = createStyles(({ token, css, cx, prefixCls }, props: any) => { + const prefix = `${prefixCls}-${token?.proPrefix}-r-form-page` + + const container = css` + .ant-table-cell { + .ant-tag { + padding-inline: 3px; + margin-inline-end: 3px; + } + } + + .ant-table-empty { + .ant-table-body { + height: calc(100vh - 350px) + } + } + + .ant-pro-table-highlight { + + } + ` + + return { + container: cx(prefix, props?.className, container), + } +}) \ No newline at end of file diff --git a/src/service/r-form/model.ts b/src/service/r-form/model.ts new file mode 100644 index 0000000..398e71e --- /dev/null +++ b/src/service/r-form/model.ts @@ -0,0 +1,22 @@ +import { createCURD } from '@/service/base.ts' +import { RFormTypes } from '@/types/r-form/model' +import request from '@/request.ts' + +const model = (api: string) => { + return { + ...createCURD(api), + proxy: async (params?: { + path: string, + body: any, + method?: string + }) => { + return request.post(`/x_form/proxy`, { + api: `${api}/ui/curd`, + method: 'post', + ...params + }) + } + } +} + +export { model } \ No newline at end of file diff --git a/src/store/r-form/model.ts b/src/store/r-form/model.ts new file mode 100644 index 0000000..7061480 --- /dev/null +++ b/src/store/r-form/model.ts @@ -0,0 +1,189 @@ +import { Atom, atom } from 'jotai' +import { IApiResult, IPage, IPageResult } from '@/global' +import { + atomWithMutation, + atomWithQuery, + AtomWithQueryResult, + AtomWithMutationResult, + queryClientAtom +} from 'jotai-tanstack-query' +import { message } from 'antd' +import { t } from 'i18next' +import { RFormTypes } from '@/types/r-form/model' +import * as modelServ from '@/service/r-form/model' +import { PrimitiveAtom } from 'jotai/vanilla/atom' + +type SearchParams = IPage & { + key?: string + api: string +} + + +export type ModelContext = { + apiAtom: PrimitiveAtom + modelIdAtom: PrimitiveAtom + modelIdsAtom: PrimitiveAtom + modelAtom: PrimitiveAtom + modelSearchAtom: PrimitiveAtom + modelPageAtom: PrimitiveAtom + modelCURDAtom: Atom> + modelsAtom: Atom>> + saveOrUpdateModelAtom: Atom> + deleteModelAtom: Atom> +} + +export const modelContext = new Map() + +// @ts-ignore fix debug modelContext +window.__MODELCONTEXT__ = modelContext + +export const useSpanModel = (name: string) => { + + if (modelContext.has(name)) { + return modelContext.get(name) + } + + const apiAtom = atom('') + + const modelIdAtom = atom(0) + + const modelIdsAtom = atom([]) + + const modelAtom = atom(undefined as unknown as RFormTypes.IModel) + + const modelSearchAtom = atom({ + key: '', + pageSize: 10, + page: 1, + } as SearchParams) + + const modelPageAtom = atom({ + pageSize: 10, + page: 1, + }) + + const modelCURDAtom = atomWithQuery, any, any, any>((get) => { + + return { + enabled: !!get(apiAtom), + queryKey: [ 'modelCURD', get(apiAtom) ], + queryFn: async ({ queryKey: [ , api ] }) => { + return await modelServ.model(api).proxy() + }, + select: (res) => { + return res.data.page + } + } + }) + + const modelsAtom = atomWithQuery>, any, any, any>((get) => { + + const curd = get(modelCURDAtom) + return { + enabled: curd.isSuccess && !!get(apiAtom), + queryKey: [ 'models', get(modelSearchAtom), get(apiAtom) ], + queryFn: async ({ queryKey: [ , params, api ] }) => { + + if (api.startsWith('http')) { + return await modelServ.model(api).proxy({ + path: '/list', + body: params, + }) + } + return await modelServ.model(api).list(params as SearchParams) + }, + select: res => { + const data = res.data + data.rows = data.rows?.map(row => { + return { + ...row, + //status: convertToBool(row.status) + } + }) + return data + } + } + }) + +//saveOrUpdateAtom + const saveOrUpdateModelAtom = atomWithMutation((get) => { + + return { + mutationKey: [ 'updateModel', get(apiAtom) ], + mutationFn: async (data) => { + const api = get(apiAtom) + if (!api) { + return Promise.reject('api 不能为空') + } + if (data.id === 0) { + if (api.startsWith('http')) { + return await modelServ.model(api).proxy({ + body: data, + path: '/add', + }) + } + return await modelServ.model(api).add(data) + } + if (api.startsWith('http')) { + return await modelServ.model(api).proxy({ + body: data, + path: '/edit', + }) + } + return await modelServ.model(api).update(data) + }, + onSuccess: (res) => { + const isAdd = !!res.data?.id + message.success(t(isAdd ? 'message.saveSuccess' : 'message.editSuccess', '保存成功')) + + //更新列表 + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore fix + get(queryClientAtom).invalidateQueries({ queryKey: [ 'models', get(modelSearchAtom) ] }) + + return res + } + } + }) + + const deleteModelAtom = atomWithMutation((get) => { + + return { + mutationKey: [ 'deleteModel', get(apiAtom) ], + mutationFn: async (ids: number[]) => { + const api = get(apiAtom) + if (api.startsWith('http')) { + return await modelServ.model(api).proxy({ + body: ids ?? get(modelIdsAtom), + path: '/deletes', + }) + } + return await modelServ.model(api).batchDelete(ids ?? get(modelIdsAtom)) + }, + onSuccess: (res) => { + message.success('message.deleteSuccess') + //更新列表 + get(queryClientAtom).invalidateQueries({ queryKey: [ 'models', get(modelSearchAtom) ] }) + return res + } + } + }) + + + const value = { + apiAtom, + modelIdAtom, + modelIdsAtom, + modelAtom, + modelSearchAtom, + modelPageAtom, + modelCURDAtom, + modelsAtom, + saveOrUpdateModelAtom, + deleteModelAtom, + } as ModelContext + + modelContext.set(name, value) + + return value +} \ No newline at end of file diff --git a/src/types/r-form/model.d.ts b/src/types/r-form/model.d.ts new file mode 100644 index 0000000..4cebee0 --- /dev/null +++ b/src/types/r-form/model.d.ts @@ -0,0 +1,25 @@ +import { ProColumns, ProTableProps, } from '@ant-design/pro-components' +import { FormSchema } from '@ant-design/pro-form/es/components/SchemaForm/typing' + +export namespace RFormTypes { + export interface IModel { + id: number; + created_at: string; + updated_at: string; + + [key: string]: any; + } + + export interface IModelCURD { + + page: { + columns: ProColumns[]; + config: any; + table: ProTableProps + form: FormSchema + } + } + + +} + diff --git a/src/utils/tree.ts b/src/utils/tree.ts index 4a0c508..6229474 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -3,107 +3,132 @@ import { FiledNames } from '@/global' type TreeKey = string | number; type TreeNode = { - [key in keyof T]: T[keyof T]; + [key in keyof T]: T[keyof T]; } & { - key: TreeKey; - id?: TreeKey; - children?: TreeNode[]; + key: TreeKey; + id?: TreeKey; + children?: TreeNode[]; }; export function getTreeCheckedStatus(tree: TreeNode[], selectKeys: TreeKey[]): { - checked: TreeKey[], - halfChecked: TreeKey[] + checked: TreeKey[], + halfChecked: TreeKey[] } { - const checked: TreeKey[] = [] - const halfChecked: TreeKey[] = [] - - if (!tree || tree.length === 0) return { checked, halfChecked } - if (!selectKeys || selectKeys.length === 0) return { checked, halfChecked } - - // 辅助函数来递归地检查每个节点 - function checkNode(node: TreeNode, ancestors: TreeKey[]): void { - const key = node.key ?? node.id - const isLeaf = !node.children || node.children.length === 0 - const isSelected = selectKeys.includes(key) - - // 如果是叶节点并且被选中,则直接加入到checked数组 - if (isLeaf && isSelected) { - checked.push(key) - // 标记所有祖先为半选状态,除非它们已经被完全选中 - ancestors.forEach(ancestorKey => { - if (!halfChecked.includes(ancestorKey) && !checked.includes(ancestorKey)) { - halfChecked.push(ancestorKey) - } - }) - return + const checked: TreeKey[] = [] + const halfChecked: TreeKey[] = [] + + if (!tree || tree.length === 0) return { checked, halfChecked } + if (!selectKeys || selectKeys.length === 0) return { checked, halfChecked } + + // 辅助函数来递归地检查每个节点 + function checkNode(node: TreeNode, ancestors: TreeKey[]): void { + const key = node.key ?? node.id + const isLeaf = !node.children || node.children.length === 0 + const isSelected = selectKeys.includes(key) + + // 如果是叶节点并且被选中,则直接加入到checked数组 + if (isLeaf && isSelected) { + checked.push(key) + // 标记所有祖先为半选状态,除非它们已经被完全选中 + ancestors.forEach(ancestorKey => { + if (!halfChecked.includes(ancestorKey) && !checked.includes(ancestorKey)) { + halfChecked.push(ancestorKey) } + }) + return + } - // 非叶节点,递归检查其子节点 - if (node.children) { - const childAncestors = [ ...ancestors, key ] - node.children.forEach(child => checkNode(child, childAncestors)) - - // 检查当前节点的所有子节点是否全部或部分被选中 - const childSelectedCount = node.children.filter(child => checked.includes(child.key ?? child.id)).length - if (childSelectedCount === node.children.length) { - // 如果所有子节点都被选中,将当前节点标为全选 - checked.push(key) - } else if (childSelectedCount > 0) { - // 如果部分子节点被选中,将当前节点标为半选 - halfChecked.push(key) - } - } + // 非叶节点,递归检查其子节点 + if (node.children) { + const childAncestors = [ ...ancestors, key ] + node.children.forEach(child => checkNode(child, childAncestors)) + + // 检查当前节点的所有子节点是否全部或部分被选中 + const childSelectedCount = node.children.filter(child => checked.includes(child.key ?? child.id)).length + if (childSelectedCount === node.children.length) { + // 如果所有子节点都被选中,将当前节点标为全选 + checked.push(key) + } else if (childSelectedCount > 0) { + // 如果部分子节点被选中,将当前节点标为半选 + halfChecked.push(key) + } } + } - // 遍历每一个根节点 - tree.forEach(node => checkNode(node, [])) - return { checked, halfChecked } + // 遍历每一个根节点 + tree.forEach(node => checkNode(node, [])) + return { checked, halfChecked } } export function findValuePath(tree: TreeNode[], targetValue: string | number, filedNames?: FiledNames): (string | number)[] | null { - const f = { - key: filedNames?.key ?? 'key', - title: filedNames?.title ?? 'title', - children: filedNames?.children ?? 'children', - } - - const findPathRecursive = (node: TreeNode, pathSoFar: (string | number)[]): (string | number)[] | null => { - if (node[f.key] === targetValue) { - return [ ...pathSoFar, node[f.key] ] - } + const f = { + key: filedNames?.key ?? 'key', + title: filedNames?.title ?? 'title', + children: filedNames?.children ?? 'children', + } - if (node[f.children]) { - for (const child of node[f.children]) { - const result = findPathRecursive(child, [ ...pathSoFar, node[f.key] ]) - if (result !== null) { - return result - } - } - } - - return null + const findPathRecursive = (node: TreeNode, pathSoFar: (string | number)[]): (string | number)[] | null => { + if (node[f.key] === targetValue) { + return [ ...pathSoFar, node[f.key] ] } - for (const root of tree) { - const result = findPathRecursive(root, []) + if (node[f.children]) { + for (const child of node[f.children]) { + const result = findPathRecursive(child, [ ...pathSoFar, node[f.key] ]) if (result !== null) { - return result + return result } + } + } + + return null + } + + for (const root of tree) { + const result = findPathRecursive(root, []) + if (result !== null) { + return result } + } - return null // 如果未找到目标值,则返回null + return null // 如果未找到目标值,则返回null } //将tree中指定key的string json转为json export function treeStringToJson(tree: TreeNode[], key: string): TreeNode[] { - return tree.map(node => { - const children = node.children ? treeStringToJson(node.children, key) : undefined - return { - ...node, - [key]: node[key] ? JSON.parse(node[key] as string) : undefined, - children, - } - }) + return tree.map(node => { + const children = node.children ? treeStringToJson(node.children, key) : undefined + return { + ...node, + [key]: node[key] ? JSON.parse(node[key] as string) : undefined, + children, + } + }) +} + +//遍历tree, 对每个节点执行fn +export function traverseTree(tree: TreeNode[], fn: (node: TreeNode) => void, filedNames?: FiledNames): void { + const { children = 'children' } = filedNames ?? { children: 'children' } + const did = (node: TreeNode) => { + fn(node) + if (node[children]) { + node[children].forEach(did) + } + } + tree.forEach(did) +} + +//遍历tree, 对每个节点执行fn, 返回新的tree +export function mapTree(tree: TreeNode[], fn: (node: TreeNode) => R, filedNames?: FiledNames): TreeNode[] { + const { children = 'children' } = filedNames ?? { children: 'children' } + const did = (node: TreeNode): TreeNode => { + const newNode = fn(node) + if (node[children]) { + newNode[children] = node[children].map(did) + } + return newNode as TreeNode + } + return tree.map(did) } \ No newline at end of file