Просмотр исходного кода

feat[pc-components]: DageEditable

chenlei 9 месяцев назад
Родитель
Сommit
be43a257a2

+ 1 - 1
packages/backend-cli/template/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@dage/backend-template",
-  "version": "1.0.13",
+  "version": "1.0.14",
   "private": true,
   "dependencies": {
     "@ant-design/icons": "^5.1.4",

+ 112 - 86
packages/backend-cli/template/src/pages/Layout/components/Menu/index.tsx

@@ -1,86 +1,112 @@
-import { Menu, MenuProps } from "antd";
-import { FC, memo, useCallback, useMemo } from "react";
-import { useLocation, useNavigate } from "react-router-dom";
-import { DageRouteItem } from "@/router";
-import { findRouteByPath } from "../../utils";
-
-export interface LayoutMenuProps extends Omit<MenuProps, "mode" | "items"> {
-  /** 菜单内容 */
-  menuData: DageRouteItem[];
-  /**
-   * 最大可展示层级
-   * @default 2
-   */
-  maxShowLevel?: number;
-}
-
-export const LayoutMenu: FC<LayoutMenuProps> = memo(
-  ({ menuData, maxShowLevel = 2, ...rest }) => {
-    const location = useLocation();
-    const navigate = useNavigate();
-    const defaultOpenKeys = useMemo(() => {
-      let currentPath = "";
-      const stack: string[] = [];
-      const paths = location.pathname.split("/").filter((path) => path !== "");
-      for (let i = 0; i < paths.length; i++) {
-        currentPath += `/${paths[i]}`;
-        const item = findRouteByPath(menuData, currentPath);
-        if (item) {
-          stack.push(item.path);
-        }
-      }
-      return stack;
-    }, [menuData, location]);
-
-    const renderMenuItems = useCallback(
-      (list: DageRouteItem[], level = 1): any[] => {
-        const stack: DageRouteItem[] = [];
-
-        list.forEach((item) => {
-          let child: DageRouteItem[] = [];
-          const { title, path, icon, children, hide } = item;
-          const params = {
-            key: path,
-            icon: icon,
-            label: title,
-          };
-
-          if (hide) return null;
-
-          if (level <= maxShowLevel - 1 && children) {
-            child = renderMenuItems(children, level + 1);
-          }
-
-          if (child.length) {
-            // @ts-ignore
-            params.children = child;
-          }
-
-          // @ts-ignore
-          stack.push(params);
-        });
-
-        return stack;
-      },
-      [maxShowLevel]
-    );
-
-    const handleMenu = useCallback(
-      (item: any) => {
-        navigate(item.key);
-      },
-      [navigate]
-    );
-
-    return menuData.length ? (
-      <Menu
-        mode="inline"
-        items={renderMenuItems(menuData)}
-        selectedKeys={location.pathname.split("/").map((i) => `/${i}`)}
-        defaultOpenKeys={defaultOpenKeys}
-        onClick={handleMenu}
-        {...rest}
-      />
-    ) : null;
-  }
-);
+import { Menu, MenuProps } from "antd";
+import { FC, memo, useCallback, useMemo } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+import { DageRouteItem } from "@/router";
+import { findRouteByPath, findVisibleRouteParent } from "../../utils";
+
+export interface LayoutMenuProps extends Omit<MenuProps, "mode" | "items"> {
+  /** 菜单内容 */
+  menuData: DageRouteItem[];
+  /**
+   * 最大可展示层级
+   * @default 2
+   */
+  maxShowLevel?: number;
+}
+
+const findSelectedKey = (
+  menuData: DageRouteItem[],
+  item: DageRouteItem
+): string | null => {
+  if (!item?.hide) {
+    return item.path;
+  } else {
+    const parent = findVisibleRouteParent(menuData, item.path);
+
+    return parent ? findSelectedKey(menuData, parent) : null;
+  }
+};
+
+export const LayoutMenu: FC<LayoutMenuProps> = memo(
+  ({ menuData, maxShowLevel = 2, ...rest }) => {
+    const location = useLocation();
+    const navigate = useNavigate();
+    const defaultOpenKeys = useMemo(() => {
+      let currentPath = "";
+      const stack: string[] = [];
+      const paths = location.pathname.split("/").filter((path) => path !== "");
+      for (let i = 0; i < paths.length; i++) {
+        currentPath += `/${paths[i]}`;
+        const item = findRouteByPath(menuData, currentPath);
+        if (item) {
+          stack.push(item.path);
+        }
+      }
+      return stack;
+    }, [menuData, location]);
+
+    const selectedKeys = useMemo(() => {
+      const stack = [];
+      const item = findRouteByPath(menuData, location.pathname);
+
+      if (item) {
+        const target = findSelectedKey(menuData, item);
+        target && stack.push(target);
+      }
+
+      return stack;
+    }, [menuData, location]);
+
+    const renderMenuItems = useCallback(
+      (list: DageRouteItem[], level = 1): any[] => {
+        const stack: DageRouteItem[] = [];
+
+        list.forEach((item) => {
+          let child: DageRouteItem[] = [];
+          const { title, path, icon, children, hide } = item;
+          const params = {
+            key: path,
+            icon: icon,
+            label: title,
+          };
+
+          if (hide) return null;
+
+          if (level <= maxShowLevel - 1 && children) {
+            child = renderMenuItems(children, level + 1);
+          }
+
+          if (child.length) {
+            // @ts-ignore
+            params.children = child;
+          }
+
+          // @ts-ignore
+          stack.push(params);
+        });
+
+        return stack;
+      },
+      [maxShowLevel]
+    );
+
+    const handleMenu = useCallback(
+      (item: any) => {
+        navigate(item.key);
+      },
+      [navigate]
+    );
+
+    return menuData.length ? (
+      <Menu
+        key={menuData.length}
+        mode="inline"
+        items={renderMenuItems(menuData)}
+        selectedKeys={selectedKeys}
+        defaultOpenKeys={defaultOpenKeys}
+        onClick={handleMenu}
+        {...rest}
+      />
+    ) : null;
+  }
+);

+ 71 - 44
packages/backend-cli/template/src/pages/Layout/utils.ts

@@ -1,44 +1,71 @@
-import { DageRouteItem } from "@/router";
-
-export const getParentMenuItem = (
-  menuItems: DageRouteItem[],
-  path: string
-): null | DageRouteItem => {
-  for (const menuItem of menuItems) {
-    if (path.startsWith(menuItem.path || "")) {
-      return menuItem;
-    }
-    if (menuItem.children) {
-      const parentMenuItem = getParentMenuItem(menuItem.children, path);
-      if (parentMenuItem) {
-        return parentMenuItem;
-      }
-    }
-  }
-  return null;
-};
-
-export const findRouteByPath = (
-  routes: DageRouteItem[],
-  path: string
-): DageRouteItem | null => {
-  const temp = path.split("/");
-
-  for (const route of routes) {
-    if (route.path.replace(/\/:[^/]+/g, "") === path) {
-      return route;
-    }
-    if (
-      // 首层没必要向下遍历
-      temp.length !== 2 &&
-      route.path.indexOf(temp[1]) > -1 &&
-      route.children
-    ) {
-      const subRoute = findRouteByPath(route.children, path);
-      if (subRoute) {
-        return subRoute;
-      }
-    }
-  }
-  return null;
-};
+import { DageRouteItem, DEFAULT_MENU, DEFAULT_ADMIN_MENU } from "@/router";
+
+const routes = [...DEFAULT_MENU, ...DEFAULT_ADMIN_MENU];
+
+export const findRouteParent = (
+  route: DageRouteItem | null,
+  list: DageRouteItem[]
+): DageRouteItem | null => {
+  if (!route) return null;
+
+  for (const r of list) {
+    if (r.children && r.children.includes(route)) {
+      if (r.hide) {
+        return findRouteParent(r, list);
+      }
+      return r;
+    }
+    if (r.children) {
+      const parent = findRouteParent(route, r.children);
+      if (parent) return parent;
+    }
+  }
+  return null;
+};
+
+export const findVisibleRouteParent = (
+  list: DageRouteItem[],
+  targetPath: string
+): DageRouteItem | null => {
+  let result: DageRouteItem | null = null;
+
+  function search(list: DageRouteItem[], path: string): DageRouteItem | null {
+    for (const route of list) {
+      if (route.path === path) {
+        return route;
+      }
+      if (route.children) {
+        const found = search(route.children, path);
+        if (found) {
+          return found;
+        }
+      }
+    }
+    return null;
+  }
+
+  const targetNode = search(list, targetPath);
+  if (targetNode) {
+    result = findRouteParent(targetNode, list);
+  }
+
+  return result;
+};
+
+export const findRouteByPath = (
+  list = routes,
+  path: string
+): DageRouteItem | null => {
+  for (const route of list) {
+    if (route.path === path) {
+      return route;
+    }
+    if (route.children) {
+      const subRoute = findRouteByPath(route.children, path);
+      if (subRoute) {
+        return subRoute;
+      }
+    }
+  }
+  return null;
+};

+ 22 - 22
packages/backend-cli/template/src/router/index.tsx

@@ -1,22 +1,22 @@
-import { UserOutlined, SettingOutlined } from "@ant-design/icons";
-import React from "react";
-import { DageRouteItem } from "./types";
-
-export const DEFAULT_MENU: DageRouteItem[] = [];
-
-export const DEFAULT_ADMIN_MENU: DageRouteItem[] = [
-  {
-    path: "/user",
-    title: "用户管理",
-    icon: <UserOutlined />,
-    Component: React.lazy(() => import("../pages/User")),
-  },
-  {
-    path: "/log",
-    title: "操作日志",
-    icon: <SettingOutlined />,
-    Component: React.lazy(() => import("../pages/Log")),
-  },
-];
-
-export * from "./types";
+import { UserOutlined, SettingOutlined } from "@ant-design/icons";
+import React from "react";
+import { DageRouteItem } from "./types";
+
+export const DEFAULT_MENU: DageRouteItem[] = [];
+
+export const DEFAULT_ADMIN_MENU: DageRouteItem[] = [
+  {
+    path: "/user",
+    title: "用户管理",
+    icon: <UserOutlined />,
+    Component: React.lazy(() => import("../pages/User")),
+  },
+  {
+    path: "/log",
+    title: "操作日志",
+    icon: <SettingOutlined />,
+    Component: React.lazy(() => import("../pages/Log")),
+  },
+];
+
+export * from "./types";

+ 1 - 0
packages/docs/.umirc.ts

@@ -68,6 +68,7 @@ export default defineConfig({
           "/components/FileCheckbox",
           "/components/Map",
           "/components/Editor",
+          "/components/EditTable",
         ],
       },
       {

+ 67 - 0
packages/docs/docs/components/EditTable/custom-input-demo.tsx

@@ -0,0 +1,67 @@
+import React, { FC, useState } from "react";
+import { DageDefaultColumnTypes, DageEditable } from "@dage/pc-components";
+import { Input, InputNumber } from "antd";
+
+interface Item {
+  key: string | number;
+  name: string;
+  age: number;
+  address: string;
+}
+
+export const DageEditableDemo: FC = () => {
+  const [dataSource, setDataSource] = useState<Item[]>([
+    {
+      key: "0",
+      name: "Edward King 0",
+      age: 32,
+      address: "London, Park Lane no. 0",
+    },
+    {
+      key: "1",
+      name: "Edward King 1",
+      age: 22,
+      address: "London, Park Lane no. 1",
+    },
+  ]);
+
+  const defaultColumns: DageDefaultColumnTypes<Item>[] = [
+    {
+      title: "name",
+      dataIndex: "name",
+      width: "30%",
+      editable: true,
+      Input: <Input placeholder="请输入内容,最多5字" maxLength={5} />,
+    },
+    {
+      title: "age",
+      dataIndex: "age",
+      editable: true,
+      Input: <InputNumber placeholder="请输入" />,
+    },
+    {
+      title: "address",
+      dataIndex: "address",
+    },
+  ];
+
+  const handleSave = (row: Item) => {
+    const newData = [...dataSource];
+    const index = newData.findIndex((item) => row.key === item.key);
+    const item = newData[index];
+    newData.splice(index, 1, {
+      ...item,
+      ...row,
+    });
+    setDataSource(newData);
+  };
+
+  return (
+    <DageEditable
+      bordered
+      dataSource={dataSource}
+      defaultColumns={defaultColumns}
+      handleSave={handleSave}
+    />
+  );
+};

+ 116 - 0
packages/docs/docs/components/EditTable/demo.tsx

@@ -0,0 +1,116 @@
+import React, { FC, useRef, useState } from "react";
+import { DageDefaultColumnTypes, DageEditable } from "@dage/pc-components";
+import { Button, Form, Popconfirm, message } from "antd";
+
+interface Item {
+  key: string | number;
+  name: string;
+  age: string;
+  address: string;
+}
+
+export const DageEditableDemo: FC = () => {
+  const [dataSource, setDataSource] = useState<Item[]>([
+    {
+      key: "0",
+      name: "Edward King 0",
+      age: "32",
+      address: "London, Park Lane no. 0",
+    },
+    {
+      key: "1",
+      name: "Edward King 1",
+      age: "32",
+      address: "London, Park Lane no. 1",
+    },
+  ]);
+
+  const [count, setCount] = useState(2);
+
+  const handleDelete = (key: React.Key) => {
+    const newData = dataSource.filter((item) => item.key !== key);
+    setDataSource(newData);
+  };
+
+  const defaultColumns: DageDefaultColumnTypes<Item>[] = [
+    {
+      title: "name",
+      dataIndex: "name",
+      width: "30%",
+      editable: true,
+    },
+    {
+      title: "age",
+      dataIndex: "age",
+      width: "30%",
+      editable: true,
+    },
+    {
+      title: "address",
+      dataIndex: "address",
+    },
+    {
+      title: "operation",
+      dataIndex: "operation",
+      render: (_, record) =>
+        dataSource.length >= 1 ? (
+          <Popconfirm
+            title="Sure to delete?"
+            onConfirm={() => handleDelete(record.key)}
+          >
+            <a>Delete</a>
+          </Popconfirm>
+        ) : null,
+    },
+  ];
+
+  const handleAdd = () => {
+    const newData: Item = {
+      key: count,
+      name: "",
+      age: "",
+      address: `London, Park Lane no. ${count}`,
+    };
+    setDataSource([...dataSource, newData]);
+    setCount(count + 1);
+  };
+
+  const handleSave = (row: Item) => {
+    const newData = [...dataSource];
+    const index = newData.findIndex((item) => row.key === item.key);
+    const item = newData[index];
+    newData.splice(index, 1, {
+      ...item,
+      ...row,
+    });
+    setDataSource(newData);
+  };
+
+  const handleSubmit = async () => {
+    message.success("校验通过");
+  };
+
+  return (
+    <Form onFinish={handleSubmit}>
+      <Form.Item label="表格">
+        <Button type="primary" style={{ marginBottom: 16 }} onClick={handleAdd}>
+          Add a row
+        </Button>
+
+        <DageEditable
+          bordered
+          dataSource={dataSource}
+          defaultColumns={defaultColumns}
+          handleSave={handleSave}
+        />
+      </Form.Item>
+      <Form.Item>
+        <div style={{ textAlign: "right" }}>
+          <Button type="primary" htmlType="submit">
+            提交
+          </Button>
+        </div>
+      </Form.Item>
+    </Form>
+  );
+};

+ 29 - 0
packages/docs/docs/components/EditTable/index.md

@@ -0,0 +1,29 @@
+## DageEditable 可编辑单元格
+
+### 基本用法
+
+```tsx
+import React from "react";
+import { DageEditableDemo } from "./demo";
+
+export default () => {
+  return <DageEditableDemo />;
+};
+```
+
+### 自定义输入框
+
+`defaultColumns` 中支持通过 `Input` 来传入 `ReactElement`,组件会为其注入参数 `ref`、`onPressEnter`、`onBlur`。
+
+```tsx
+import React from "react";
+import { DageEditableDemo } from "./custom-input-demo";
+
+export default () => {
+  return <DageEditableDemo />;
+};
+```
+
+## API
+
+<API hideTitle exports='["DageEditable"]' src='@dage/pc-components/index.d.ts'></API>

+ 6 - 0
packages/docs/docs/log/PC-COMPONENTS_CHANGELOG.md

@@ -1,5 +1,11 @@
 # @dage/pc-components
 
+## 1.3.3
+
+### Patch Changes
+
+- fix: upload 格式校验通配符不通过
+
 ## 1.3.2
 
 ### Patch Changes

+ 82 - 0
packages/pc-components/src/components/DageEditable/EditableCell.tsx

@@ -0,0 +1,82 @@
+import {
+  PropsWithChildren,
+  cloneElement,
+  useContext,
+  useEffect,
+  useRef,
+  useState,
+} from "react";
+import { Form, InputRef } from "antd";
+import { DageEditableCellProps } from "./types";
+import { DageEditableContext } from "./context";
+import { EditableCellValueWrap } from "./style";
+
+export const DageEditableCell = <T extends Record<string, any>>({
+  title,
+  editable,
+  children,
+  dataIndex,
+  record,
+  Input,
+  handleSave,
+  ...restProps
+}: PropsWithChildren<DageEditableCellProps<T>>) => {
+  // 防止首次触发校验
+  const firstRender = useRef(true);
+  const [editing, setEditing] = useState(
+    Boolean(editable && Array.isArray(children) && !children[1])
+  );
+  const inputRef = useRef<InputRef>(null);
+  const form = useContext(DageEditableContext)!;
+
+  useEffect(() => {
+    if (firstRender.current) {
+      firstRender.current = false;
+      return;
+    }
+
+    if (editing) {
+      inputRef.current?.focus();
+    }
+  }, [editing]);
+
+  const toggleEdit = () => {
+    setEditing(!editing);
+    form.setFieldsValue({ [dataIndex]: record[dataIndex] });
+  };
+
+  const save = async () => {
+    const values = await form.validateFields();
+
+    toggleEdit();
+    handleSave({ ...record, ...values });
+  };
+
+  let childNode = children;
+
+  if (editable) {
+    childNode = editing ? (
+      <Form.Item
+        style={{ margin: 0 }}
+        // @ts-ignore
+        name={dataIndex}
+        rules={[{ required: true, message: `请填写${title}` }]}
+      >
+        {cloneElement(Input, {
+          ref: inputRef,
+          onPressEnter: save,
+          onBlur: save,
+        })}
+      </Form.Item>
+    ) : (
+      <EditableCellValueWrap
+        style={{ paddingInlineEnd: 24 }}
+        onClick={toggleEdit}
+      >
+        {children}
+      </EditableCellValueWrap>
+    );
+  }
+
+  return <td {...restProps}>{childNode}</td>;
+};

+ 19 - 0
packages/pc-components/src/components/DageEditable/EditableRow.tsx

@@ -0,0 +1,19 @@
+import { Form } from "antd";
+import { FC } from "react";
+import { DageEditableContext } from "./context";
+import { DageEditableRowProps } from "./types";
+
+export const DageEditableRow: FC<DageEditableRowProps> = ({
+  index,
+  ...props
+}) => {
+  const [form] = Form.useForm();
+
+  return (
+    <Form form={form} component={false}>
+      <DageEditableContext.Provider value={form}>
+        <tr {...props} />
+      </DageEditableContext.Provider>
+    </Form>
+  );
+};

+ 6 - 0
packages/pc-components/src/components/DageEditable/context.ts

@@ -0,0 +1,6 @@
+import { FormInstance } from "antd";
+import { createContext } from "react";
+
+export const DageEditableContext = createContext<FormInstance<any> | null>(
+  null
+);

+ 49 - 0
packages/pc-components/src/components/DageEditable/index.tsx

@@ -0,0 +1,49 @@
+import { Input, Table } from "antd";
+import { DageEditableRow } from "./EditableRow";
+import { DageEditableCell } from "./EditableCell";
+import { Editable } from "./style";
+import { DageColumnTypes, DageEditableProps } from "./types";
+
+export const DageEditable = <T extends Record<string, any>>({
+  defaultColumns,
+  handleSave,
+  ...rest
+}: DageEditableProps<T>) => {
+  const components = {
+    body: {
+      row: DageEditableRow,
+      cell: DageEditableCell,
+    },
+  };
+
+  const columns = defaultColumns.map((col) => {
+    if (!col.editable) {
+      return col;
+    }
+    return {
+      ...col,
+      onCell: (record: T) => ({
+        record,
+        editable: col.editable,
+        dataIndex: col.dataIndex,
+        title: col.title,
+        Input: col.Input || <Input />,
+        handleSave,
+      }),
+    };
+  });
+
+  return (
+    <Editable>
+      <Table
+        {...rest}
+        columns={columns as DageColumnTypes<T>}
+        rowClassName={() => "dage-editable-row"}
+        components={components}
+      />
+    </Editable>
+  );
+};
+
+export * from "./context";
+export * from "./types";

+ 18 - 0
packages/pc-components/src/components/DageEditable/style.ts

@@ -0,0 +1,18 @@
+import styled from "styled-components";
+
+export const Editable = styled.div.attrs({
+  className: "dage-editable",
+})`
+  .dage-editable-row:hover .editable-cell-value-wrap {
+    padding: 4px 11px;
+    border: 1px solid #d9d9d9;
+    border-radius: 2px;
+  }
+`;
+
+export const EditableCellValueWrap = styled.div.attrs({
+  className: "editable-cell-value-wrap",
+})`
+  padding: 5px 12px;
+  cursor: pointer;
+`;

+ 38 - 0
packages/pc-components/src/components/DageEditable/types.ts

@@ -0,0 +1,38 @@
+import { TableProps } from "antd";
+import { ReactElement } from "react";
+
+export interface DageEditableProps<T>
+  extends Omit<TableProps<T>, "components" | "rowClassName" | "columns"> {
+  defaultColumns: DageDefaultColumnTypes<T>[];
+  handleSave: (row: T) => void;
+}
+
+export interface DageEditableRowProps {
+  index: number;
+}
+
+export type DageCustomColumnTypes = {
+  dataIndex: string;
+  /**
+   * 是否可以编辑
+   */
+  editable?: boolean;
+  /**
+   * 输入框
+   * @default Input
+   */
+  Input?: ReactElement;
+};
+
+export interface DageEditableCellProps<T>
+  extends Omit<DageCustomColumnTypes, "Input"> {
+  title: React.ReactNode;
+  record: T;
+  Input: ReactElement;
+  handleSave: (record: T) => void;
+}
+
+export type DageColumnTypes<T> = Exclude<TableProps<T>["columns"], undefined>;
+
+export type DageDefaultColumnTypes<T> = DageColumnTypes<T>[number] &
+  DageCustomColumnTypes;

+ 1 - 0
packages/pc-components/src/components/index.ts

@@ -6,3 +6,4 @@ export * from "./DageEditor";
 export * from "./DageLoading";
 export * from "./DageCheckboxGroup";
 export * from "./DageTreeActions";
+export * from "./DageEditable";