Browse Source

feat: 完善资料签收清单模板剩余功能

lanjianrong 2 years ago
parent
commit
b906e2ac30

+ 15 - 36
src/app.tsx

@@ -49,37 +49,25 @@ const authHeaderInterceptor = options => {
   return options
 }
 
-const errorHandler = err => {
-  if (err.name === 'BizError') {
-    const response = err.info
-    if (consts.TOKEN_INVALID_CODE.includes(response.code) && window.location.pathname !== consts.loginPath) {
-      notification.error({
-        message: '用户信息过期',
-        description: '请重新登录'
-      })
-      history.replace({
-        pathname: consts.loginPath,
-        search: createSearchParams({
-          redirect: window.location.pathname
-        }).toString()
-      })
-      return
-    }
+const errorHandler = response => {
+  if (consts.TOKEN_INVALID_CODE.includes(response.code) && window.location.pathname !== consts.loginPath) {
     notification.error({
-      message: '请求失败',
-      description: response.msg
+      message: '用户信息过期',
+      description: '请重新登录'
+    })
+    history.replace({
+      pathname: consts.loginPath,
+      search: createSearchParams({
+        redirect: window.location.pathname
+      }).toString()
     })
     return
   }
-  // 那么对于其他的错误, 需要实践中一个个找出来
-  if (err.name === 'RequestError') {
-    switch (err.type) {
-      case 'Timeout': {
-        message.error('系统超时,请联系开发人员')
-      }
-    }
-    return
-  }
+  notification.error({
+    message: '请求失败',
+    description: response.msg
+  })
+  return
 }
 
 const responseInterceptor = response => {
@@ -91,15 +79,6 @@ const responseInterceptor = response => {
 }
 export const request: RequestConfig = {
   errorConfig: {
-    errorThrower: res => {
-      const { data, code, msg } = res
-      if (code !== consts.RET_CODE.SUCCESS) {
-        const error: any = new Error(errorMessage)
-        error.name = 'BizError'
-        error.info = { code, msg, data }
-        throw error // 抛出自制的错误
-      }
-    },
     errorHandler
   },
   baseURL: consts.PREFIX_URL,

+ 170 - 0
src/components/Modal/index.tsx

@@ -0,0 +1,170 @@
+import React, {
+  useRef,
+  useMemo,
+  memo,
+  forwardRef,
+  useCallback,
+  useState,
+  useImperativeHandle,
+  useId
+} from 'react'
+import { Modal, Form } from 'antd'
+import type { ModalProps } from 'antd'
+
+const MyModal = memo(
+  forwardRef((prop: any, ref) => {
+    const [form] = Form.useForm()
+    const [modalChildren, setModalChildren] = useState<React.ReactElement>(null)
+    const [loading, setLoading] = useState(false)
+    const [modalProps, setModalProps] = useState<ModalProps>({
+      visible: false
+    })
+    const typeRef = useRef<string>()
+
+    const onFinish = useCallback(
+      (values: any) => {
+        const response = modalProps.onOk?.(values)
+
+        if (response instanceof Promise) {
+          setLoading(true)
+
+          response.finally(() => {
+            setLoading(false)
+          })
+        }
+      },
+      [form, modalProps]
+    )
+    // ant.design 4.0 Form的onFinish触发回调
+    // const onFinish = useCallback(
+    //   (values: any) => {
+    //     modalProps.onOk?.(values)
+    //   },
+    //   [form, modalProps]
+    // )
+
+    // 关闭当前Modal
+    const onClose = useCallback(() => {
+      setModalProps(source => ({
+        ...source,
+        visible: false
+      }))
+    }, [form])
+
+    // 关闭当前Modal
+    const onOpen = useCallback(() => {
+      setModalProps(source => ({
+        ...source,
+        visible: true
+      }))
+    }, [form])
+
+    useImperativeHandle(
+      ref,
+      () => ({
+        // 注入Modal的子组件
+        injectChildren: element => {
+          setModalChildren(element)
+        },
+        // 注入Modal参数
+        injectModalProps: props => {
+          console.log(props)
+          setModalProps(source => {
+            return {
+              ...source,
+              ...props
+            }
+          })
+        },
+        // 打开Modal
+        open: () => {
+          onOpen()
+        },
+        // 关闭Modal
+        close: () => {
+          if (loading) setLoading(false)
+          onClose()
+        },
+        // 设置表单数据
+        setFieldsValue: (values: any) => {
+          form.setFieldsValue?.(values)
+        },
+        setType: (type: string) => {
+          typeRef.current = type
+        }
+      }),
+      []
+    )
+
+    const handleOk = useCallback(() => {
+      if (typeRef.current === 'form') {
+        form.submit()
+      } else {
+        modalProps.onOk?.(null)
+      }
+    }, [form, modalProps])
+
+    // 这里的Modal是ant.design中的Modal
+    return (
+      <Modal {...modalProps} onCancel={onClose} onOk={handleOk} okButtonProps={{ loading }}>
+        {modalChildren
+          ? React.cloneElement(modalChildren, {
+              onFinish,
+              form,
+              onClose
+            })
+          : null}
+      </Modal>
+    )
+  })
+)
+
+interface modalRefType {
+  open: () => void
+  close: () => void
+  injectChildren: (child: React.ReactElement) => void
+  injectModalProps: (props: ModalProps) => void
+  setFieldsValue: (values: any) => void
+  setType: (type: string) => void
+}
+
+interface OpenArgType extends ModalProps {
+  children?: React.ReactElement
+  type?: 'form' | 'default'
+  initialValues?: {
+    [key: string]: any
+  }
+}
+
+/**
+ * 适用于单弹窗模式
+ */
+const useModal = () => {
+  const modalRef = useRef<modalRefType>()
+  const handle = useMemo(() => {
+    return {
+      open: ({ children, type, initialValues, ...rest }: OpenArgType) => {
+        modalRef.current.setType(type)
+        modalRef.current.injectChildren(children) // 注入子组件
+        modalRef.current.injectModalProps(rest) // 注入Modal的参数
+        modalRef.current.open()
+
+        if (initialValues && type === 'form') {
+          modalRef.current.setFieldsValue?.(initialValues)
+        }
+      },
+      close: () => {
+        modalRef.current.close()
+      }
+    }
+  }, [])
+
+  return [handle, <MyModal ref={modalRef} key={useId()} />] as const
+}
+
+export type ModalAction = {
+  open: (args: OpenArgType) => void
+  close: () => void
+}
+
+export default useModal

+ 250 - 8
src/pages/Business/Inventory/hooks/useRowScript.tsx

@@ -1,22 +1,264 @@
-import { queryProfileTemplateList } from '@/services/api/business'
+import {
+  createTemplateFile,
+  createTemplateFolder,
+  delProfileTemplate,
+  moveTemplate,
+  moveTemplateWithOperation,
+  queryProfileTemplateList,
+  updateProfileTemplate
+} from '@/services/api/business'
 import { useRequest } from '@umijs/max'
 import { useState } from 'react'
+import ProForm, { ProFormCheckbox, ProFormRadio, ProFormText, ProFormTreeSelect } from '@ant-design/pro-form'
 
+import { TemplateMode } from '../'
+import type { ModalAction } from '@/components/Modal'
+import { message, Modal, TreeNodeProps } from 'antd'
+import consts from '@/utils/consts'
 interface IState {
   list?: API.ProfileTemplateItem[]
+  record?: API.ProfileTemplateItem
 }
 
-export function useRowScript() {
-  const [state, setState] = useState<IState>({})
+/** 格式化树表数据 */
+function formatTreeTable(nodes: API.ProfileTemplateItem[]) {
+  return nodes.map((node, idx) => {
+    node.moveable = true
+    if (nodes.length === 1 && idx + 1 === 1) {
+      // 叶子结点只有一个的时候不可移动(包括上下移)
+      node.moveable = false
+    }
+    if (node.children?.length) {
+      node.children = formatTreeTable(node.children)
+    }
+    return node
+  })
+}
 
-  const { refresh } = useRequest(queryProfileTemplateList, {
-    onSuccess: (result: API.ProfileTemplateItem[]) => {
-      setState({ ...state, list: result?.children || [] })
+/**
+ * 格式化新增目录/移动操作所需的TreeNode
+ * @param currentID 不传则为新增目录
+ *  */
+function formatTreeNode(nodes: API.ProfileTemplateItem[], currentID?: string): TreeNodeProps[] {
+  return nodes.map(item => {
+    const newItem: TreeNodeProps = {
+      title: item.name,
+      value: item.ID,
+      disable: false
+    }
+    // 移动操作判断逻辑
+    if (currentID) {
+      if (item.ID === currentID || !item.moveable) {
+        newItem.disable = true
+      }
+      if (item.children?.length) {
+        newItem.children = formatTreeNode(item.children)
+      }
+    } else {
+      if (!item.folder) {
+        newItem.disabled = true
+      }
+      if (item.children?.length) {
+        newItem.children = formatTreeNode(item.children)
+      }
     }
+    return newItem
   })
+}
+
+export function useRowScript(modal: ModalAction) {
+  const [state, setState] = useState<IState>({})
+  const { refresh, loading } = useRequest(queryProfileTemplateList, {
+    onSuccess: (result?: API.ProfileTemplateItem[]) => {
+      setState({ ...state, list: formatTreeTable(result) || [] })
+    }
+  })
+
+  const handleRowClick = (record: API.ProfileTemplateItem) => setState({ ...state, record })
+
+  /** 新建目录 */
+  const addFolder = () => {
+    modal.open({
+      title: '新建目录',
+      okText: '确认',
+      type: 'form',
+      cancelText: '取消',
+      initialValues: { parentID: state.record?.parentID },
+      children: (
+        <ProForm submitter={false} layout="horizontal" labelCol={{ span: 4 }} isKeyPressSubmit>
+          <ProFormTreeSelect
+            name="parentID"
+            label="父节点"
+            fieldProps={{ options: formatTreeNode(state.list) }}
+            rules={[{ required: true, message: '请选择父节点' }]}
+          />
+          <ProFormText name="name" label="目录名称" rules={[{ required: true, message: '请输入目录名称' }]} />
+        </ProForm>
+      ),
+      onOk: async (values: { name: string; parentID: string }) => {
+        const { code = -1 } = await createTemplateFolder(values)
+        if (code === consts.RET_CODE.SUCCESS) {
+          message.success('新建目录成功')
+          modal.close()
+          refresh()
+        }
+      }
+    })
+  }
+
+  /** 编辑目录 */
+  const editFolder = () => {
+    modal.open({
+      title: '编辑目录',
+      okText: '确认',
+      type: 'form',
+      cancelText: '取消',
+      initialValues: { ...state.record },
+      children: (
+        <ProForm submitter={false} layout="horizontal" isKeyPressSubmit>
+          <ProFormText name="name" label="目录名称" rules={[{ required: true, message: '请输入目录名称' }]} />
+          <ProFormText hidden name="ID" />
+        </ProForm>
+      ),
+      onOk: async (values: { ID: string; name: string }) => {
+        const { code = -1 } = await updateProfileTemplate(values)
+        if (code === consts.RET_CODE.SUCCESS) {
+          message.success('编辑成功')
+          modal.close()
+        }
+      }
+    })
+  }
+
+  /** 新建文件 */
+  const addFile = () => {
+    modal.open({
+      title: '新增文件',
+      okText: '确认',
+      type: 'form',
+      cancelText: '取消',
+      initialValues: { parentID: state.record?.ID, enable: 0, required: 1 },
+      children: (
+        <ProForm submitter={false} layout="horizontal" labelCol={{ span: 4 }} isKeyPressSubmit>
+          <ProFormTreeSelect
+            name="parentID"
+            label="父节点"
+            fieldProps={{ options: formatTreeNode(state.list) }}
+            rules={[{ required: true, message: '请选择目录节点' }]}
+          />
+          <ProFormText name="name" label="名称" rules={[{ required: true, message: '请输入目录名称' }]} />
+          <ProFormRadio.Group
+            options={[
+              { label: '启用', value: 1 },
+              { label: '停用', value: 0 }
+            ]}
+            name="enable"
+            label="状态"
+            rules={[{ required: true, message: '请选择' }]}
+          />
+          <ProFormRadio.Group
+            options={[
+              { label: '是', value: 1 },
+              { label: '否', value: 0 }
+            ]}
+            name="required"
+            label="是否必填"
+            rules={[{ required: true, message: '请选择' }]}
+          />
+          <ProFormCheckbox.Group
+            options={[
+              { label: '纸质', value: TemplateMode.PAPER },
+              { label: '上传', value: TemplateMode.UPLOAD }
+            ]}
+            name="mode"
+            label="资料提供"
+            rules={[{ required: true, message: '请选择' }]}
+          />
+        </ProForm>
+      ),
+      onOk: async (values: any) => {
+        const { code = -1 } = await createTemplateFile({
+          ...values,
+          required: !!values.required,
+          enable: !!values.enable
+        })
+        if (code === consts.RET_CODE.SUCCESS) {
+          message.success('新增文件成功')
+          modal.close()
+          refresh()
+        }
+      }
+    })
+  }
+
+  /** 删除 */
+  const deleteFolderOrFile = () => {
+    Modal.confirm({
+      title: '删除',
+      content: '确认删除该行数据?',
+      okText: '确定',
+      cancelText: '取消',
+      onOk: async () => {
+        // 进行删除的接口请求
+        const { code = -1 } = await delProfileTemplate({ ID: state.record?.ID })
+        if (code === consts.RET_CODE.SUCCESS) {
+          message.success('删除成功')
+          refresh()
+        }
+      }
+    })
+  }
+
+  /** 上下移 */
+  const moveWithOperation = async (operation: 'up' | 'down') => {
+    const { code = -1 } = await moveTemplateWithOperation({ ID: state.record?.ID, operation })
+    if (code === consts.RET_CODE.SUCCESS) {
+      message.success('移动成功')
+      refresh()
+    }
+  }
+
+  /** 移动 */
+  const move = async () => {
+    modal.open({
+      title: '移动至',
+      type: 'form',
+      initialValues: { ID: state.record?.ID },
+      children: (
+        <ProForm submitter={false} layout="horizontal" isKeyPressSubmit>
+          <ProFormText name="ID" hidden />
+          <ProFormTreeSelect
+            name="moveID"
+            label="目录名称"
+            fieldProps={{ options: formatTreeNode(state.list, state.record?.ID) }}
+            rules={[{ required: true, message: '请选择目标节点' }]}
+          />
+        </ProForm>
+      ),
+      okText: '确定',
+      cancelText: '取消',
+      onOk: async (values: { ID: string; moveID: string }) => {
+        const { code = -1 } = await moveTemplate(values)
+        if (code === consts.RET_CODE.SUCCESS) {
+          message.success('移动成功')
+          modal.close()
+          refresh()
+        }
+      }
+    })
+  }
 
   return {
-    list: state.list,
-    refresh
+    loading,
+    list: state.list?.[0]?.children || [],
+    record: state.record,
+    refresh,
+    rowClick: handleRowClick,
+    addFolder,
+    editFolder,
+    addFile,
+    move,
+    deleteFolderOrFile,
+    moveWithOperation
   }
 }

+ 123 - 34
src/pages/Business/Inventory/index.tsx

@@ -1,16 +1,38 @@
+import useModal from '@/components/Modal'
+import {
+  ArrowDownOutlined,
+  ArrowUpOutlined,
+  DeleteOutlined,
+  EditOutlined,
+  FileAddOutlined,
+  FolderAddOutlined,
+  SelectOutlined
+} from '@ant-design/icons'
 import { PageContainer } from '@ant-design/pro-layout'
-import { Table } from 'antd'
+import { Button, Table } from 'antd'
 import { ColumnsType } from 'antd/lib/table'
 import { useRowScript } from './hooks/useRowScript'
 
-enum TemplateMode {
+export enum TemplateMode {
   PAPER = 'paper',
-  UPLOAD = 'upload',
-  ALL = 'all'
+  UPLOAD = 'upload'
 }
 
 const Inventory = () => {
-  const { list } = useRowScript()
+  const contentHeight = document.body.clientHeight - 122
+  const [modal, ModalDOM] = useModal()
+  const {
+    loading,
+    record,
+    list,
+    rowClick,
+    move,
+    addFolder,
+    addFile,
+    editFolder,
+    deleteFolderOrFile,
+    moveWithOperation
+  } = useRowScript(modal)
   const columns: ColumnsType<API.ProfileTemplateItem> = [
     {
       title: '序号',
@@ -24,48 +46,115 @@ const Inventory = () => {
     {
       title: '状态',
       dataIndex: 'enable',
-      render: text =>
-        text ? <span className="text-green">启用</span> : <span className="text-red">停用</span>
+      render: (text, record) => {
+        if (record.folder) return null
+        return text ? <span className="text-green">启用</span> : <span className="text-red">停用</span>
+      }
     },
     {
       title: '是否必填',
       dataIndex: 'required',
-      render: text => (text ? '是' : '否')
+      render: (text, record) => {
+        if (record.folder) return null
+        return text ? '是' : '否'
+      }
     },
     {
       title: '资料提供方式',
       dataIndex: 'mode',
-      render: (mode: TemplateMode) => (
-        <div>
-          {[TemplateMode.PAPER, TemplateMode.ALL].includes(mode) && (
-            <span className="border border-hex-91d5ff text-blue bg-hex-e6f7ff rounded-1 px-1 text-sm">
-              纸质
-            </span>
-          )}
-          {[TemplateMode.UPLOAD, TemplateMode.ALL].includes(mode) && (
-            <span className="border border-hex-9fede5 text-hex-13c2c2 bg-hex-e6fffb rounded-1 px-1 ml-2 text-sm">
-              上传
-            </span>
-          )}
-        </div>
-      )
+      render: (mode: TemplateMode, record) => {
+        if (record.folder) return null
+        return (
+          <div>
+            {mode === TemplateMode.PAPER ? (
+              <span className="border border-hex-91d5ff text-blue bg-hex-e6f7ff rounded-1 px-1 text-sm">
+                纸质
+              </span>
+            ) : null}
+            {mode === TemplateMode.UPLOAD ? (
+              <span className="border border-hex-9fede5 text-hex-13c2c2 bg-hex-e6fffb rounded-1 px-1 ml-2 text-sm">
+                上传
+              </span>
+            ) : null}
+          </div>
+        )
+      }
     }
   ]
+
   return (
     <PageContainer title={false}>
-      {list?.length && (
-        <Table
-          rowKey="ID"
-          columns={columns}
-          dataSource={list}
-          pagination={false}
-          size="small"
-          bordered
-          expandable={{ defaultExpandAllRows: true }}
-        />
-      )}
+      <div style={{ height: `${contentHeight}px` }} className="bg-white">
+        <div className="children:mx-1 px-1 py-2">
+          <Button icon={<FolderAddOutlined />} size="small" type="primary" ghost onClick={addFolder}>
+            新建目录
+          </Button>
+          <Button icon={<FileAddOutlined />} size="small" type="primary" ghost onClick={addFile}>
+            新建文件
+          </Button>
+          <Button
+            icon={<EditOutlined />}
+            size="small"
+            type="primary"
+            ghost
+            onClick={editFolder}
+            disabled={!record}>
+            编辑
+          </Button>
+          <Button
+            icon={<DeleteOutlined />}
+            size="small"
+            type="primary"
+            ghost
+            onClick={deleteFolderOrFile}
+            disabled={!record}>
+            删除
+          </Button>
+          <Button
+            icon={<ArrowUpOutlined />}
+            size="small"
+            type="primary"
+            ghost
+            disabled={!record?.moveable || record.position !== 'bottom'}
+            onClick={() => moveWithOperation('up')}>
+            上移
+          </Button>
+          <Button
+            icon={<ArrowDownOutlined />}
+            size="small"
+            type="primary"
+            ghost
+            disabled={!record?.moveable || record.position !== 'top'}
+            onClick={() => moveWithOperation('down')}>
+            下移
+          </Button>
+          <Button
+            icon={<SelectOutlined />}
+            size="small"
+            type="primary"
+            ghost
+            disabled={!record?.moveable}
+            onClick={move}>
+            移动至
+          </Button>
+        </div>
+        {list?.length && (
+          <Table
+            rowKey="ID"
+            loading={loading}
+            columns={columns}
+            dataSource={list}
+            pagination={false}
+            size="small"
+            bordered
+            expandable={{ defaultExpandAllRows: true }}
+            onRow={record => ({ onClick: () => rowClick(record) })}
+            rowClassName={row => (row.ID === record?.ID ? 'ant-table-row-selected' : '')}
+          />
+        )}
+      </div>
+      {ModalDOM}
     </PageContainer>
   )
 }
-
 export default Inventory

+ 1 - 1
src/pages/Schema/Budget/index.tsx

@@ -84,7 +84,7 @@ const Budget: React.FC = () => {
       }
     })
     return (
-      <Form form={normalForm} labelCol={4} wrapperCol={8}>
+      <Form form={normalForm} labelCol={4}>
         <SchemaField schema={currentSchema} />
       </Form>
     )

+ 3 - 1
src/services/api/typings.d.ts

@@ -383,9 +383,11 @@ declare namespace API {
     name?: string
     enable?: boolean
     required?: boolean
-    mode?: 'paper' | 'upload' | 'all'
+    mode?: 'paper' | 'upload'
     sort?: number
     folder?: boolean
+    moveable?: boolean
+    position?: 'top' | 'bottom'
     children?: ProfileTemplateItem[]
   }
 }