Bladeren bron

:construction: create

visuddhinanda 1 jaar geleden
bovenliggende
commit
a33d6d1bf7

+ 166 - 0
dashboard-v4/dashboard/src/components/task/Filter.tsx

@@ -0,0 +1,166 @@
+import { Button, Popover, Select, Space, Typography } from "antd";
+import { IFilter } from "./TaskList";
+import { useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import UserSelect from "../template/UserSelect";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormSelect,
+} from "@ant-design/pro-components";
+import { DeleteOutlined, FilterOutlined } from "@ant-design/icons";
+
+const { Text } = Typography;
+
+interface IProps {
+  item: IFilter;
+  sn: number;
+  onRemove?: () => void;
+}
+const FilterItem = ({ item, sn, onRemove }: IProps) => {
+  const intl = useIntl();
+  return (
+    <ProForm.Group>
+      <Text>{sn === 0 ? "当" : "且"}</Text>
+      <ProFormSelect
+        initialValue={item.field}
+        name={`field_${sn}`}
+        style={{ width: 120 }}
+        options={[
+          {
+            value: "executor_id",
+            label: intl.formatMessage({ id: "forms.fields.executor.label" }),
+          },
+          {
+            value: "assignees_id",
+            label: intl.formatMessage({ id: "forms.fields.assignees.label" }),
+          },
+          {
+            value: "participants_id",
+            label: intl.formatMessage({ id: "labels.participants" }),
+          },
+        ]}
+      />
+      <ProFormSelect
+        initialValue={item.operator}
+        name={`operator_${sn}`}
+        style={{ width: 120 }}
+        options={[
+          {
+            value: "includes",
+            label: "包含",
+          },
+          {
+            value: "not-includes",
+            label: "不包含",
+          },
+        ]}
+      />
+      <UserSelect
+        name={"value_" + sn}
+        multiple={true}
+        initialValue={item.value}
+        required={false}
+        hiddenTitle
+      />
+      <Button type="link" icon={<DeleteOutlined />} danger onClick={onRemove} />
+    </ProForm.Group>
+  );
+};
+
+interface IWidget {
+  initValue?: IFilter[];
+  onChange?: (value: IFilter[]) => void;
+}
+const Filter = ({ initValue, onChange }: IWidget) => {
+  const [filterList, setFilterList] = useState(initValue ?? []);
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+  return (
+    <Popover
+      placement="bottomLeft"
+      trigger={"click"}
+      arrowPointAtCenter
+      title={intl.formatMessage({ id: "labels.filter" })}
+      content={
+        <div style={{ width: 750 }}>
+          <ProForm
+            formRef={formRef}
+            submitter={{
+              render(props, dom) {
+                return [
+                  <Button
+                    onClick={() => {
+                      setFilterList((origin) => {
+                        return [
+                          ...origin,
+                          {
+                            field: "executor_id",
+                            operator: "includes",
+                            value: [],
+                          },
+                        ];
+                      });
+                    }}
+                  >
+                    添加条件
+                  </Button>,
+                  ...dom,
+                ];
+              },
+            }}
+            onFinish={async () => {
+              const value = formRef.current?.getFieldsValue();
+              console.log(value);
+              let counter = 0;
+              let newValue: IFilter[] = [];
+              while (counter < Object.keys(value).length) {
+                const field = `field_${counter}`;
+                const field2 = `operator_${counter}`;
+                const field3 = `value_${counter}`;
+                if (value.hasOwnProperty(field)) {
+                  newValue.push({
+                    field: value[field],
+                    operator: value[field2],
+                    value: value[field3],
+                  });
+                }
+                counter++;
+              }
+              console.log(newValue);
+              if (onChange) {
+                onChange(newValue);
+              }
+            }}
+          >
+            {filterList.map((item, id) => {
+              return (
+                <FilterItem
+                  item={item}
+                  key={id}
+                  sn={id}
+                  onRemove={() => {
+                    setFilterList((origin) => {
+                      return origin.filter(
+                        (value, index: number) => index !== id
+                      );
+                    });
+                  }}
+                />
+              );
+            })}
+          </ProForm>
+        </div>
+      }
+    >
+      <Button
+        type={filterList.length === 0 ? "text" : "primary"}
+        icon={<FilterOutlined />}
+      >
+        筛选 {filterList.length}
+      </Button>
+    </Popover>
+  );
+};
+
+export default Filter;

+ 155 - 0
dashboard-v4/dashboard/src/components/task/MyTasks.tsx

@@ -0,0 +1,155 @@
+import { Tabs } from "antd";
+import React, { useRef, useState } from "react";
+import TaskList from "./TaskList";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+
+type TargetKey = React.MouseEvent | React.KeyboardEvent | string;
+
+const TaskRunning = ({ studioName }: { studioName?: string }) => {
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <TaskList
+      studioName={studioName}
+      status={["running", "restarted"]}
+      filters={[
+        { field: "executor_id", operator: "includes", value: [currUser?.id] },
+      ]}
+    />
+  ) : (
+    <>未登录</>
+  );
+};
+const TaskAssignee = ({ studioName }: { studioName?: string }) => {
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <TaskList
+      studioName={studioName}
+      status={["published"]}
+      filters={[
+        { field: "assignees_id", operator: "includes", value: [currUser?.id] },
+      ]}
+    />
+  ) : (
+    <>未登录</>
+  );
+};
+const TaskDone = ({ studioName }: { studioName?: string }) => {
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <TaskList
+      studioName={studioName}
+      status={["done"]}
+      filters={[
+        { field: "executor_id", operator: "includes", value: [currUser?.id] },
+      ]}
+    />
+  ) : (
+    <>未登录</>
+  );
+};
+
+const TaskNew = ({ studioName }: { studioName?: string }) => {
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <TaskList
+      studioName={studioName}
+      filters={[
+        { field: "executor_id", operator: "includes", value: [currUser?.id] },
+      ]}
+    />
+  ) : (
+    <>未登录</>
+  );
+};
+
+interface IWidget {
+  studioName?: string;
+}
+const MyTasks = ({ studioName }: IWidget) => {
+  const currUser = useAppSelector(currentUser);
+
+  console.info("currUser", currUser);
+  const initialItems = [
+    {
+      label: "进行中",
+      closable: false,
+      key: "running",
+      children: <TaskRunning studioName={studioName} />,
+    },
+    {
+      label: "待领取",
+      closable: false,
+      key: "2",
+      children: <TaskAssignee studioName={studioName} />,
+    },
+    {
+      label: "已完成",
+      key: "done",
+      closable: false,
+      children: <TaskDone studioName={studioName} />,
+    },
+  ];
+
+  const [activeKey, setActiveKey] = useState(initialItems[0].key);
+  const [items, setItems] = useState(initialItems);
+  const newTabIndex = useRef(0);
+  const onChange = (newActiveKey: string) => {
+    setActiveKey(newActiveKey);
+  };
+
+  const add = () => {
+    const newActiveKey = `newTab${newTabIndex.current++}`;
+    const newPanes = [...items];
+    newPanes.push({
+      label: "New Tab",
+      key: newActiveKey,
+      closable: true,
+      children: <TaskNew studioName={studioName} />,
+    });
+    setItems(newPanes);
+    setActiveKey(newActiveKey);
+  };
+
+  const remove = (targetKey: TargetKey) => {
+    let newActiveKey = activeKey;
+    let lastIndex = -1;
+    items.forEach((item, i) => {
+      if (item.key === targetKey) {
+        lastIndex = i - 1;
+      }
+    });
+    const newPanes = items.filter((item) => item.key !== targetKey);
+    if (newPanes.length && newActiveKey === targetKey) {
+      if (lastIndex >= 0) {
+        newActiveKey = newPanes[lastIndex].key;
+      } else {
+        newActiveKey = newPanes[0].key;
+      }
+    }
+    setItems(newPanes);
+    setActiveKey(newActiveKey);
+  };
+
+  const onEdit = (
+    targetKey: React.MouseEvent | React.KeyboardEvent | string,
+    action: "add" | "remove"
+  ) => {
+    if (action === "add") {
+      add();
+    } else {
+      remove(targetKey);
+    }
+  };
+  return (
+    <Tabs
+      type="editable-card"
+      onChange={onChange}
+      activeKey={activeKey}
+      onEdit={onEdit}
+      items={items}
+    />
+  );
+};
+
+export default MyTasks;

+ 46 - 0
dashboard-v4/dashboard/src/components/task/Options.tsx

@@ -0,0 +1,46 @@
+import { Button, Dropdown, MenuProps } from "antd";
+import exp from "constants";
+import { useState } from "react";
+
+export interface IMenu {
+  key: string;
+  label: string;
+}
+interface IWidget {
+  items: IMenu[];
+  icon?: React.ReactNode;
+  text?: string;
+  initKey?: string;
+  onChange?: (key: string) => void;
+}
+const Options = ({ items, icon, text, initKey = "1", onChange }: IWidget) => {
+  const [currKey, setCurrKey] = useState(initKey);
+  const currValue = items.find(
+    (item) => item.key === currKey ?? initKey
+  )?.label;
+  const onClick: MenuProps["onClick"] = ({ key }) => {
+    if (onChange) {
+      onChange(key);
+    }
+    setCurrKey(key);
+  };
+  return (
+    <Dropdown
+      menu={{
+        items,
+        onClick,
+        selectable: true,
+        defaultSelectedKeys: [currKey],
+      }}
+      trigger={["click"]}
+      placement="bottomLeft"
+    >
+      <Button type="text" icon={icon}>
+        {text}
+        {currValue}
+      </Button>
+    </Dropdown>
+  );
+};
+
+export default Options;

+ 102 - 0
dashboard-v4/dashboard/src/components/task/PreTask.tsx

@@ -0,0 +1,102 @@
+import { Button, List, Popover, Tag, Typography } from "antd";
+import { ITaskData, ITaskListResponse } from "../api/task";
+import { get } from "../../request";
+import { useEffect, useState } from "react";
+import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
+import { type } from "os";
+import { TRelation } from "./TaskEditButton";
+
+const { Text } = Typography;
+
+interface IProTaskListProps {
+  task?: ITaskData;
+  type: TRelation;
+  onClick?: (data?: ITaskData | null) => void;
+  onClose?: () => void;
+}
+const ProTaskList = ({ task, type, onClick, onClose }: IProTaskListProps) => {
+  const [res, setRes] = useState<ITaskData[]>();
+  useEffect(() => {
+    const url = `/v2/task?view=project&project_id=${task?.project_id}`;
+
+    console.info("api request", url);
+    get<ITaskListResponse>(url).then((json) => {
+      console.info("project api response", json);
+      const res = json.data.rows;
+      setRes(res);
+    });
+  }, [task?.project_id]);
+
+  return (
+    <List
+      header={
+        <div>
+          <div style={{ display: "flex", justifyContent: "space-between" }}>
+            <Text strong>{type === "pre" ? "前置任务" : "后置任务"}</Text>
+            <div>
+              <Button type="link" onClick={onClose}>
+                关闭
+              </Button>
+            </div>
+          </div>
+        </div>
+      }
+      footer={false}
+      dataSource={res}
+      renderItem={(item) => (
+        <List.Item
+          onClick={() => {
+            onClick && onClick(item);
+          }}
+        >
+          {item.title}
+        </List.Item>
+      )}
+    />
+  );
+};
+
+interface IWidget {
+  task?: ITaskData;
+  open?: boolean;
+  type: TRelation;
+  onClick?: (data?: ITaskData | null) => void;
+  onClose?: () => void;
+}
+const PreTask = ({ task, type, open = false, onClick, onClose }: IWidget) => {
+  const preTaskShow = open || task?.pre_task;
+  const nextTaskShow = open || task?.next_task;
+  let tag = <></>;
+  if (preTaskShow && type === "pre") {
+    tag = (
+      <Tag color="warning" icon={<ArrowLeftOutlined />}>
+        {task?.pre_task?.title}
+      </Tag>
+    );
+  } else if (nextTaskShow && type === "next") {
+    tag = (
+      <Tag color="warning" icon={<ArrowRightOutlined />}>
+        {task?.next_task?.title}
+      </Tag>
+    );
+  }
+  return (
+    <Popover
+      trigger="click"
+      open={open}
+      content={
+        <div style={{ width: 500 }}>
+          <ProTaskList
+            type={type}
+            task={task}
+            onClick={onClick}
+            onClose={onClose}
+          />
+        </div>
+      }
+    >
+      {tag}
+    </Popover>
+  );
+};
+export default PreTask;

+ 273 - 0
dashboard-v4/dashboard/src/components/task/Project.tsx

@@ -0,0 +1,273 @@
+import type { ActionType, ProColumns } from "@ant-design/pro-components";
+import { EditableProTable, useRefFunction } from "@ant-design/pro-components";
+import { Button, Form, Space, Typography } from "antd";
+import React, { useEffect, useRef, useState } from "react";
+import { useIntl } from "react-intl";
+
+import ProjectEditDrawer from "./ProjectEditDrawer";
+import {
+  IProjectData,
+  IProjectListResponse,
+  IProjectResponse,
+  IProjectUpdateRequest,
+} from "../api/task";
+
+import { get, post } from "../../request";
+
+const { Text } = Typography;
+function generateUUID() {
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
+    var r = (Math.random() * 16) | 0,
+      v = c === "x" ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+}
+
+interface IWidget {
+  studioName?: string;
+  projectId?: string;
+  onRowClick?: (data: IProjectData) => void;
+  onSelect?: (id: string) => void;
+}
+const Project = ({ studioName, projectId, onRowClick, onSelect }: IWidget) => {
+  const intl = useIntl();
+  const [open, setOpen] = useState(false);
+  const [editId, setEditId] = useState<string>();
+
+  const [title, setTitle] = useState<React.ReactNode>();
+
+  const actionRef = useRef<ActionType>();
+  const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
+  const [dataSource, setDataSource] = useState<readonly IProjectData[]>([]);
+  const [form] = Form.useForm();
+
+  const ProjectTitle = ({ data }: { data?: IProjectData }) => (
+    <Space>
+      <Text strong>{data?.title}</Text>
+      {data?.path?.reverse().map((item, id) => {
+        return (
+          <Text key={id} type="secondary">
+            {" <"}
+            <Button
+              type="text"
+              onClick={() => {
+                if (onSelect) {
+                  onSelect(item.id);
+                }
+              }}
+            >
+              {item.title}
+            </Button>
+          </Text>
+        );
+      })}
+    </Space>
+  );
+
+  const loopDataSourceFilter = (
+    data: readonly IProjectData[],
+    id: React.Key | undefined
+  ): IProjectData[] => {
+    return data
+      .map((item) => {
+        if (item.id !== id) {
+          if (item.children) {
+            const newChildren = loopDataSourceFilter(item.children, id);
+            return {
+              ...item,
+              children: newChildren.length > 0 ? newChildren : undefined,
+            };
+          }
+          return item;
+        }
+        return null;
+      })
+      .filter(Boolean) as IProjectData[];
+  };
+  const removeRow = useRefFunction((record: IProjectData) => {
+    setDataSource(loopDataSourceFilter(dataSource, record.id));
+  });
+
+  const columns: ProColumns<IProjectData>[] = [
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.title.label",
+      }),
+      dataIndex: "title",
+      formItemProps: {
+        rules: [
+          {
+            required: true,
+            message: "此项为必填项",
+          },
+        ],
+      },
+      width: "30%",
+      render: (dom, record, _, action) => {
+        return (
+          <Button
+            type="link"
+            size="small"
+            onClick={() => {
+              if (onSelect) {
+                onSelect(record.id);
+              }
+            }}
+          >
+            {record.title}
+          </Button>
+        );
+      },
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.milestone.label",
+      }),
+      key: "state",
+      dataIndex: "state",
+      readonly: true,
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.status.label",
+      }),
+      key: "state",
+      dataIndex: "state",
+      readonly: true,
+    },
+    {
+      title: "操作",
+      valueType: "option",
+      width: 250,
+      render: (text, record, _, action) => [
+        <a
+          key="editable"
+          onClick={() => {
+            setEditId(record.id);
+            setOpen(true);
+          }}
+        >
+          编辑
+        </a>,
+        <EditableProTable.RecordCreator
+          key="copy"
+          parentKey={record.id}
+          record={{
+            id: generateUUID(),
+            parent_id: record.id,
+          }}
+        >
+          <Button size="small" type="link">
+            插入子节点
+          </Button>
+        </EditableProTable.RecordCreator>,
+        <Button
+          type="link"
+          danger
+          size="small"
+          key="delete"
+          onClick={() => {
+            removeRow(record);
+          }}
+        >
+          删除
+        </Button>,
+      ],
+    },
+  ];
+
+  const getChildren = (
+    record: IProjectData,
+    findIn: IProjectData[]
+  ): IProjectData[] | undefined => {
+    const children = findIn
+      .filter((item) => item.parent?.id === record.id)
+      .map((item) => {
+        return { ...item, children: getChildren(item, findIn) };
+      });
+    console.debug("children", findIn, record, children);
+    if (children.length > 0) {
+      return children;
+    }
+    return undefined;
+  };
+
+  useEffect(() => {
+    actionRef.current?.reload();
+  }, [projectId]);
+  return (
+    <>
+      <Space></Space>
+      <EditableProTable<IProjectData>
+        onRow={(record) => ({
+          onClick: () => {
+            if (onRowClick) {
+              onRowClick(record);
+            }
+          },
+        })}
+        rowKey="id"
+        scroll={{
+          x: 960,
+        }}
+        actionRef={actionRef}
+        headerTitle={title}
+        maxLength={5}
+        search={false}
+        // 关闭默认的新建按钮
+        recordCreatorProps={false}
+        columns={columns}
+        request={async () => {
+          const url = `/v2/project?view=project-tree&project_id=${projectId}`;
+          console.info("api request", url);
+          const res = await get<IProjectListResponse>(url);
+          console.info("project api response", res);
+          const root = res.data.rows
+            .filter((item) => item.id === projectId)
+            .map((item) => {
+              return { ...item, children: getChildren(item, res.data.rows) };
+            });
+          return {
+            data: root,
+            total: res.data.count,
+            success: res.ok,
+          };
+        }}
+        value={dataSource}
+        onChange={(value: readonly IProjectData[]) => {
+          const root = value.find((item) => item.id === projectId);
+          setTitle(ProjectTitle({ data: root }));
+          setDataSource(value);
+        }}
+        editable={{
+          form,
+          editableKeys,
+          onSave: async (key, values) => {
+            const data: IProjectUpdateRequest = {
+              ...values,
+              studio_name: studioName ?? "",
+            };
+            const url = `/v2/project`;
+            console.info("save api request", url, values);
+            const res = await post<IProjectUpdateRequest, IProjectResponse>(
+              url,
+              data
+            );
+            console.info("save api response", res);
+          },
+
+          onChange: setEditableRowKeys,
+          actionRender: (row, config, dom) => [dom.save, dom.cancel],
+        }}
+      />
+      <ProjectEditDrawer
+        studioName={studioName}
+        projectId={editId}
+        openDrawer={open}
+        onClose={() => setOpen(false)}
+      />
+    </>
+  );
+};
+
+export default Project;

+ 83 - 0
dashboard-v4/dashboard/src/components/task/ProjectCreate.tsx

@@ -0,0 +1,83 @@
+import { useIntl } from "react-intl";
+import { message } from "antd";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+
+import { post } from "../../request";
+import { useRef } from "react";
+import {
+  IProjectCreateRequest,
+  IProjectResponse,
+  ITaskCreateRequest,
+  ITaskResponse,
+  TProjectType,
+} from "../api/task";
+
+interface IFormData {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+interface IWidgetCourseCreate {
+  studio?: string;
+  type?: TProjectType;
+  onCreate?: Function;
+}
+const TaskCreate = ({
+  studio = "",
+  type = "normal",
+  onCreate,
+}: IWidgetCourseCreate) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+
+  return (
+    <ProForm<IProjectCreateRequest>
+      formRef={formRef}
+      onFinish={async (values: IProjectCreateRequest) => {
+        console.log(values);
+        values.studio_name = studio;
+        values.type = type;
+        const url = `/v2/project`;
+        console.info("project api request", url, values);
+        const res = await post<IProjectCreateRequest, IProjectResponse>(
+          url,
+          values
+        );
+        console.debug("CourseCreateWidget api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          formRef.current?.resetFields(["title"]);
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          required
+          label={intl.formatMessage({ id: "channel.name" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default TaskCreate;

+ 79 - 0
dashboard-v4/dashboard/src/components/task/ProjectEdit.tsx

@@ -0,0 +1,79 @@
+import {
+  ProForm,
+  ProFormRadio,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+import { Col, Row, Space, message } from "antd";
+import { useState } from "react";
+import { IProjectData, IProjectResponse } from "../api/task";
+import { get } from "../../request";
+import { useIntl } from "react-intl";
+
+type LayoutType = Parameters<typeof ProForm>[0]["layout"];
+const LAYOUT_TYPE_HORIZONTAL = "horizontal";
+
+const waitTime = (time: number = 100) => {
+  return new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(true);
+    }, time);
+  });
+};
+
+interface IWidget {
+  projectId?: string;
+  studioName?: string;
+}
+const ProjectEdit = ({ projectId }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <ProForm<IProjectData>
+      onFinish={async (values) => {
+        await waitTime(2000);
+        console.log(values);
+        message.success("提交成功");
+      }}
+      params={{}}
+      request={async () => {
+        const url = `/v2/project/${projectId}`;
+        console.info("api request", url);
+        const res = await get<IProjectResponse>(url);
+        console.log("api response", res);
+        return res.data;
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          label={intl.formatMessage({
+            id: "forms.fields.title.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="type"
+          label={intl.formatMessage({
+            id: "forms.fields.type.label",
+          })}
+          readonly
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="description"
+          label={intl.formatMessage({
+            id: "forms.fields.description.label",
+          })}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default ProjectEdit;

+ 52 - 0
dashboard-v4/dashboard/src/components/task/ProjectEditDrawer.tsx

@@ -0,0 +1,52 @@
+import { Button, Drawer, Space } from "antd";
+import { useEffect, useState } from "react";
+
+import ProjectEdit from "./ProjectEdit";
+
+interface IWidget {
+  studioName?: string;
+  projectId?: string;
+  openDrawer?: boolean;
+  onClose?: () => void;
+}
+const ProjectEditDrawer = ({
+  studioName,
+  projectId,
+  openDrawer = false,
+  onClose,
+}: IWidget) => {
+  const [open, setOpen] = useState(openDrawer);
+
+  useEffect(() => {
+    setOpen(openDrawer);
+  }, [openDrawer]);
+
+  const onCloseDrawer = () => {
+    setOpen(false);
+    if (onClose) {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <Drawer
+        title={<></>}
+        placement={"right"}
+        width={650}
+        onClose={onCloseDrawer}
+        open={open}
+        destroyOnClose
+        extra={
+          <Space>
+            <Button type="primary">从模版创建任务</Button>
+          </Space>
+        }
+      >
+        <ProjectEdit studioName={studioName} projectId={projectId} />
+      </Drawer>
+    </>
+  );
+};
+
+export default ProjectEditDrawer;

+ 43 - 0
dashboard-v4/dashboard/src/components/task/Task.tsx

@@ -0,0 +1,43 @@
+import { useState } from "react";
+import { ITaskData } from "../api/task";
+import TaskReader from "./TaskReader";
+import TaskEdit from "./TaskEdit";
+import { set } from "lodash";
+
+interface IWidget {
+  taskId?: string;
+  onLoad?: (task: ITaskData) => void;
+  onChange?: (task: ITaskData) => void;
+}
+const Task = ({ taskId, onLoad, onChange }: IWidget) => {
+  const [isEdit, setIsEdit] = useState(false);
+  const [task, setTask] = useState<ITaskData>();
+  return (
+    <div>
+      {isEdit ? (
+        <TaskEdit
+          taskId={taskId}
+          onLoad={(data: ITaskData) => {}}
+          onChange={(data: ITaskData) => {
+            onChange && onChange(data);
+            setTask(data);
+            setIsEdit(false);
+          }}
+        />
+      ) : (
+        <TaskReader
+          taskId={taskId}
+          task={task}
+          onLoad={(data: ITaskData) => setTask(data)}
+          onChange={(data: ITaskData) => {
+            onChange && onChange(data);
+            setTask(data);
+          }}
+          onEdit={() => setIsEdit(true)}
+        />
+      )}
+    </div>
+  );
+};
+
+export default Task;

+ 68 - 0
dashboard-v4/dashboard/src/components/task/TaskCreate.tsx

@@ -0,0 +1,68 @@
+import { useIntl } from "react-intl";
+import { message } from "antd";
+import {
+  ProForm,
+  ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+
+import { post } from "../../request";
+import { useRef } from "react";
+import { ITaskCreateRequest, ITaskResponse } from "../api/task";
+
+interface IFormData {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+interface IWidgetCourseCreate {
+  studio?: string;
+  onCreate?: Function;
+}
+const TaskCreate = ({ studio = "", onCreate }: IWidgetCourseCreate) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance>();
+
+  return (
+    <ProForm<IFormData>
+      formRef={formRef}
+      onFinish={async (values: IFormData) => {
+        console.log(values);
+        values.studio = studio;
+        const url = `/v2/task`;
+        console.info("task api request", url, values);
+        const res = await post<ITaskCreateRequest, ITaskResponse>(url, values);
+        console.debug("CourseCreateWidget api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          formRef.current?.resetFields(["title"]);
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          required
+          label={intl.formatMessage({ id: "channel.name" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default TaskCreate;

+ 95 - 0
dashboard-v4/dashboard/src/components/task/TaskEdit.tsx

@@ -0,0 +1,95 @@
+import {
+  ProForm,
+  ProFormText,
+  ProFormTextArea,
+  RequestOptionsType,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { useState } from "react";
+import { ITaskData, ITaskResponse, ITaskUpdateRequest } from "../api/task";
+import { get, patch, post } from "../../request";
+import { useIntl } from "react-intl";
+import UserSelect from "../template/UserSelect";
+import User from "../auth/User";
+
+interface IWidget {
+  taskId?: string;
+  onLoad?: (data: ITaskData) => void;
+  onChange?: (data: ITaskData) => void;
+}
+const TaskEdit = ({ taskId, onLoad, onChange }: IWidget) => {
+  const intl = useIntl();
+  const [assignees, setAssignees] = useState<RequestOptionsType[]>();
+
+  return (
+    <ProForm<ITaskData>
+      onFinish={async (values) => {
+        const url = `/v2/task/${taskId}`;
+        const data: ITaskUpdateRequest = { ...values, studio_name: "" };
+        console.info("task save api request", url, data);
+        const res = await patch<ITaskUpdateRequest, ITaskResponse>(url, data);
+        if (res.ok) {
+          onChange && onChange(res.data);
+          message.success("提交成功");
+        } else {
+          message.error(res.message);
+        }
+      }}
+      params={{}}
+      request={async () => {
+        const url = `/v2/task/${taskId}`;
+        console.info("api request", url);
+        const res = await get<ITaskResponse>(url);
+        console.log("api response", res);
+        const assigneesOptions = res.data.assignees?.map((item, id) => {
+          return { label: <User {...item} />, value: item.id };
+        });
+        console.log("assigneesOptions", assigneesOptions);
+        setAssignees(assigneesOptions);
+        if (onLoad) {
+          onLoad(res.data);
+        }
+        return res.data;
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          label={intl.formatMessage({
+            id: "forms.fields.title.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="type"
+          label={intl.formatMessage({
+            id: "forms.fields.type.label",
+          })}
+          readonly
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="description"
+          label={intl.formatMessage({
+            id: "forms.fields.description.label",
+          })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <UserSelect
+          name="assignees_id"
+          multiple={true}
+          required={false}
+          options={assignees}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default TaskEdit;

+ 166 - 0
dashboard-v4/dashboard/src/components/task/TaskEditButton.tsx

@@ -0,0 +1,166 @@
+import { Button, Dropdown, Space, message } from "antd";
+import {
+  CheckOutlined,
+  ArrowLeftOutlined,
+  CodeSandboxOutlined,
+  DeleteOutlined,
+  FieldTimeOutlined,
+  EditOutlined,
+  ArrowRightOutlined,
+} from "@ant-design/icons";
+
+import {
+  ITaskData,
+  ITaskResponse,
+  ITaskUpdateRequest,
+  TTaskStatus,
+} from "../api/task";
+import { patch } from "../../request";
+import { useIntl } from "react-intl";
+
+export type TRelation = "pre" | "next";
+interface IWidget {
+  task?: ITaskData;
+  studioName?: string;
+  onChange?: (task: ITaskData) => void;
+  onEdit?: () => void;
+  onPreTask?: (type: TRelation) => void;
+}
+const TaskEditButton = ({ task, onChange, onEdit, onPreTask }: IWidget) => {
+  const intl = useIntl();
+
+  const setValue = (setting: ITaskUpdateRequest) => {
+    const url = `/v2/task/${setting.id}`;
+
+    patch<ITaskUpdateRequest, ITaskResponse>(url, setting).then((json) => {
+      if (json.ok) {
+        message.success("Success");
+        onChange && onChange(json.data);
+      } else {
+        message.error(json.message);
+      }
+    });
+  };
+
+  let newStatus: TTaskStatus = "pending";
+  let buttonText = "发布";
+  switch (task?.status) {
+    case "pending":
+      newStatus = "published";
+      buttonText = "发布";
+      break;
+    case "published":
+      newStatus = "running";
+      buttonText = "领取";
+
+      break;
+    case "running":
+      newStatus = "done";
+      buttonText = "完成任务";
+
+      break;
+    case "done":
+      newStatus = "restarted";
+      buttonText = "重做";
+
+      break;
+    case "restarted":
+      newStatus = "done";
+      buttonText = "完成任务";
+      break;
+    default:
+      break;
+  }
+
+  return (
+    <Space>
+      <Dropdown.Button
+        key={1}
+        type="link"
+        trigger={["click", "contextMenu"]}
+        menu={{
+          items: [
+            {
+              key: "edit",
+              label: intl.formatMessage({ id: "buttons.edit" }),
+              icon: <EditOutlined />,
+            },
+            {
+              key: "milestone",
+              label: task?.is_milestone ? "取消里程碑" : "设为里程碑",
+              icon: <CodeSandboxOutlined />,
+            },
+            {
+              key: "pre-task",
+              label: "设置前置任务",
+              icon: <ArrowLeftOutlined />,
+            },
+            {
+              key: "next-task",
+              label: "设置后置任务",
+              icon: <ArrowRightOutlined />,
+            },
+            {
+              type: "divider",
+            },
+            {
+              label: "历史记录",
+              key: "timeline",
+              icon: <FieldTimeOutlined />,
+            },
+            {
+              label: "删除",
+              key: "delete",
+              icon: <DeleteOutlined />,
+              danger: true,
+            },
+          ],
+          onClick: (e) => {
+            switch (e.key) {
+              case "edit":
+                onEdit && onEdit();
+                break;
+              case "milestone":
+                if (task) {
+                  if (task.id) {
+                    setValue({
+                      id: task.id,
+                      is_milestone: !task.is_milestone,
+                      studio_name: task.owner?.realName ?? "",
+                    });
+                  }
+                }
+                break;
+              case "pre-task":
+                onPreTask && onPreTask("pre");
+                break;
+              case "next-task":
+                onPreTask && onPreTask("next");
+                break;
+              default:
+                break;
+            }
+          },
+        }}
+      >
+        <Button
+          type="primary"
+          icon={<CheckOutlined />}
+          onClick={() => {
+            if (task?.id) {
+              setValue({
+                id: task.id,
+                status: newStatus,
+                studio_name: "",
+              });
+            }
+          }}
+        >
+          {buttonText}
+        </Button>
+      </Dropdown.Button>
+    </Space>
+  );
+};
+
+export default TaskEditButton;

+ 64 - 0
dashboard-v4/dashboard/src/components/task/TaskEditDrawer.tsx

@@ -0,0 +1,64 @@
+import { Button, Drawer, Space, Tag } from "antd";
+import { useEffect, useState } from "react";
+
+import { ITaskData } from "../api/task";
+import TaskEditButton from "./TaskEditButton";
+import Task from "./Task";
+import { useIntl } from "react-intl";
+import { fullUrl } from "../../utils";
+
+interface IWidget {
+  taskId?: string;
+  openDrawer?: boolean;
+  onClose?: () => void;
+  onChange?: (data: ITaskData) => void;
+}
+const TaskEditDrawer = ({
+  taskId,
+  openDrawer = false,
+  onClose,
+  onChange,
+}: IWidget) => {
+  const [open, setOpen] = useState(openDrawer);
+  const intl = useIntl();
+
+  useEffect(() => {
+    setOpen(openDrawer);
+  }, [openDrawer]);
+
+  const onCloseDrawer = () => {
+    setOpen(false);
+    if (onClose) {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <Drawer
+        title={""}
+        placement={"right"}
+        width={1000}
+        onClose={onCloseDrawer}
+        open={open}
+        destroyOnClose={true}
+        extra={
+          <Button
+            type="link"
+            onClick={() => {
+              window.open(fullUrl(`/article/task/${taskId}`), "_blank");
+            }}
+          >
+            {intl.formatMessage({
+              id: "buttons.open.in.new.tab",
+            })}
+          </Button>
+        }
+      >
+        <Task taskId={taskId} onChange={onChange} />
+      </Drawer>
+    </>
+  );
+};
+
+export default TaskEditDrawer;

+ 603 - 0
dashboard-v4/dashboard/src/components/task/TaskList.tsx

@@ -0,0 +1,603 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import { Avatar, Button, Form, Space, Typography } from "antd";
+import type { ActionType, ProColumns } from "@ant-design/pro-components";
+import { EditableProTable, useRefFunction } from "@ant-design/pro-components";
+
+import {
+  IProject,
+  ITaskData,
+  ITaskListResponse,
+  ITaskResponse,
+  ITaskUpdateRequest,
+  TTaskStatus,
+} from "../api/task";
+import { get, post } from "../../request";
+import TaskEditDrawer from "./TaskEditDrawer";
+import User, { IUser } from "../auth/User";
+import { GroupIcon } from "../../assets/icon";
+import Options, { IMenu } from "./Options";
+import Filter from "./Filter";
+import { Milestone, Status } from "./TaskReader";
+
+const { Text } = Typography;
+function generateUUID() {
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
+    var r = (Math.random() * 16) | 0,
+      v = c === "x" ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+}
+
+export const Executors = ({
+  data,
+  all,
+}: {
+  data: ITaskData;
+  all: readonly ITaskData[];
+}) => {
+  const children = all.filter((value) => value.parent_id === data.id);
+  let executors: IUser[] = data.executor ? [data.executor] : [];
+  children.forEach((task) => {
+    executors = executors.concat(task.executor ?? []);
+  });
+  return (
+    <Avatar.Group>
+      {executors.map((item, id) => {
+        return <User {...item} key={id} showName={executors.length === 1} />;
+      })}
+    </Avatar.Group>
+  );
+};
+export const Assignees = ({ data }: { data: ITaskData }) => {
+  return (
+    <Avatar.Group>
+      {data.assignees?.map((item, id) => {
+        return <User {...item} key={id} showName={false} />;
+      })}
+    </Avatar.Group>
+  );
+};
+export interface IFilter {
+  field:
+    | "executor_id"
+    | "owner_id"
+    | "finished_at"
+    | "assignees_id"
+    | "participants_id"
+    | "sign_up";
+  operator:
+    | "includes"
+    | "not-includes"
+    | "equals"
+    | "not-equals"
+    | ">="
+    | "<="
+    | ">"
+    | "<"
+    | null;
+  value: string | string[] | null;
+}
+
+interface IParams {
+  status?: string;
+  orderby?: string;
+  direction?: string;
+}
+interface IWidget {
+  studioName?: string;
+  projectId?: string;
+  editable?: boolean;
+  filters?: IFilter[];
+  status?: TTaskStatus[];
+  sortBy?: "order" | "created_at" | "updated_at" | "started_at" | "finished_at";
+  groupBy?: "executor_id" | "owner_id" | "status" | "project_id";
+  onLoad?: (data: ITaskData[]) => void;
+  onChange?: (data: ITaskData) => void;
+}
+const TaskList = ({
+  studioName,
+  projectId,
+  editable = false,
+  status,
+  sortBy = "order",
+  groupBy,
+  filters,
+  onLoad,
+  onChange,
+}: IWidget) => {
+  const intl = useIntl();
+  const [open, setOpen] = useState(false);
+  const [title, setTitle] = useState<React.ReactNode>();
+
+  const actionRef = useRef<ActionType>();
+  const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
+  const [dataSource, setDataSource] = useState<readonly ITaskData[]>([]);
+  const [rawData, setRawData] = useState<readonly ITaskData[]>([]);
+  const [form] = Form.useForm();
+  const [selectedTask, setSelectedTask] = useState<string>();
+
+  const [currFilter, setCurrFilter] = useState(filters);
+  const loopDataSourceFilter = (
+    data: readonly ITaskData[],
+    id: React.Key | undefined
+  ): ITaskData[] => {
+    return data
+      .map((item) => {
+        if (item.id !== id) {
+          if (item.children) {
+            const newChildren = loopDataSourceFilter(item.children, id);
+            return {
+              ...item,
+              children: newChildren.length > 0 ? newChildren : undefined,
+            };
+          }
+          return item;
+        }
+        return null;
+      })
+      .filter(Boolean) as ITaskData[];
+  };
+  const removeRow = useRefFunction((record: ITaskData) => {
+    setDataSource(loopDataSourceFilter(dataSource, record.id));
+  });
+
+  const columns: ProColumns<ITaskData>[] = [
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.title.label",
+      }),
+      dataIndex: "title",
+      search: false,
+      formItemProps: {
+        rules: [
+          {
+            required: true,
+            message: "此项为必填项",
+          },
+        ],
+      },
+      width: "30%",
+      render(dom, entity, index, action, schema) {
+        return (
+          <Space>
+            <Button
+              type="link"
+              onClick={() => {
+                setSelectedTask(entity.id);
+                setOpen(true);
+              }}
+            >
+              {entity.title}
+            </Button>
+            {entity.type === "group" ? (
+              <Text type="secondary">{entity.order}</Text>
+            ) : (
+              <></>
+            )}
+            <Status task={entity} />
+            <Milestone task={entity} />
+            {entity.project ? (
+              <Text type="secondary">
+                {"< "}
+                {entity.project?.title}
+              </Text>
+            ) : (
+              <></>
+            )}
+          </Space>
+        );
+      },
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.executor.label",
+      }),
+      key: "executor",
+      dataIndex: "executor",
+      search: false,
+      readonly: true,
+      render(dom, entity, index, action, schema) {
+        return <Executors data={entity} all={rawData} />;
+      },
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.assignees.label",
+      }),
+      key: "assignees",
+      dataIndex: "assignees",
+      search: false,
+      readonly: true,
+      render(dom, entity, index, action, schema) {
+        return <Assignees data={entity} />;
+      },
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.started-at.label",
+      }),
+      key: "state",
+      dataIndex: "started_at",
+      readonly: true,
+      valueType: "date",
+      sorter: true,
+      search: false,
+    },
+    {
+      title: intl.formatMessage({
+        id: "forms.fields.finished-at.label",
+      }),
+      key: "state",
+      dataIndex: "finished_at",
+      readonly: true,
+      valueType: "date",
+      search: false,
+    },
+    {
+      title: "状态",
+      hideInTable: true,
+      dataIndex: "status",
+      valueType: "select",
+      initialValue: status ? status.join("_") : "all",
+      valueEnum: {
+        all: { text: "全部任务" },
+        done: { text: "已完成" },
+        running_restarted: { text: "未完成" },
+        published: { text: "未开始" },
+        pending: { text: "未发布" },
+      },
+    },
+    {
+      title: "排序",
+      hideInTable: true,
+      dataIndex: "orderby",
+      valueType: "select",
+      initialValue: sortBy,
+      valueEnum: {
+        order: { text: "拖拽排序" },
+        started_at: { text: "开始时间" },
+        created_at: { text: "创建时间" },
+        updated_at: { text: "更新时间" },
+        finished_at: { text: "完成时间" },
+      },
+    },
+    {
+      title: "顺序",
+      hideInTable: true,
+      dataIndex: "direction",
+      valueType: "select",
+      initialValue: "asc",
+      valueEnum: {
+        desc: { text: "降序" },
+        asc: { text: "升序" },
+      },
+    },
+    editable
+      ? {
+          title: "操作",
+          valueType: "option",
+          width: 250,
+          search: false,
+          render: (text, record, _, action) => [
+            <EditableProTable.RecordCreator
+              key="copy"
+              parentKey={record.id}
+              record={{
+                id: generateUUID(),
+                parent_id: record.id,
+              }}
+            >
+              <Button size="small" type="link">
+                插入子节点
+              </Button>
+            </EditableProTable.RecordCreator>,
+            <Button
+              type="link"
+              danger
+              size="small"
+              key="delete"
+              onClick={() => {
+                removeRow(record);
+              }}
+            >
+              删除
+            </Button>,
+          ],
+        }
+      : { search: false },
+  ];
+
+  const getChildren = (
+    record: ITaskData,
+    findIn: ITaskData[]
+  ): ITaskData[] | undefined => {
+    const children = findIn
+      .filter((item) => item.parent_id === record.id)
+      .map((item) => {
+        return { ...item, children: getChildren(item, findIn) };
+      });
+    console.debug("children", findIn, record, children);
+    if (children.length > 0) {
+      return children;
+    }
+    return undefined;
+  };
+
+  useEffect(() => {
+    actionRef.current?.reload();
+  }, [projectId]);
+
+  const groupItems: IMenu[] = [
+    {
+      key: "none",
+      label: "无分组",
+    },
+    {
+      key: "project",
+      label: "任务组",
+    },
+    {
+      key: "title",
+      label: "任务名称",
+    },
+    {
+      key: "status",
+      label: "状态",
+    },
+    {
+      key: "creator",
+      label: "创建人",
+    },
+    {
+      key: "executor",
+      label: "执行人",
+    },
+    {
+      key: "started_at",
+      label: "开始时间",
+    },
+  ];
+
+  return (
+    <>
+      <EditableProTable<ITaskData, IParams>
+        rowKey="id"
+        scroll={{
+          x: 960,
+        }}
+        search={{
+          filterType: "light",
+        }}
+        options={{
+          search: true,
+        }}
+        actionRef={actionRef}
+        headerTitle={title}
+        // 关闭默认的新建按钮
+        recordCreatorProps={
+          editable
+            ? {
+                record: () => ({
+                  id: generateUUID(),
+                  title: "新建任务",
+                  is_milestone: false,
+                }),
+              }
+            : false
+        }
+        columns={columns}
+        request={async (params = {}, sorter, filter) => {
+          let url = `/v2/task?a=a`;
+          if (projectId) {
+            url += `&view=project&project_id=${projectId}`;
+          } else {
+            url += `&view=all`;
+          }
+          if (currFilter) {
+            url += `&`;
+            url += currFilter
+              .map((item) => {
+                return item.field + "_" + item.operator + "=" + item.value;
+              })
+              .join("&");
+          }
+
+          url += params.status
+            ? `&status=${params.status.replaceAll("_", ",")}`
+            : "";
+          url += params.orderby ? `&order=${params.orderby}` : "";
+          url += params.direction ? `&dir=${params.direction}` : "";
+
+          console.info("api request", url);
+          const res = await get<ITaskListResponse>(url);
+          console.info("project api response", res);
+          setRawData(res.data.rows);
+          onLoad && onLoad(res.data.rows);
+          const root = res.data.rows
+            .filter((item) => item.parent_id === null)
+            .map((item) => {
+              return { ...item, children: getChildren(item, res.data.rows) };
+            });
+          return {
+            data: root,
+            total: res.data.count,
+            success: res.ok,
+          };
+        }}
+        value={dataSource}
+        onChange={(value: readonly ITaskData[]) => {
+          const root = value.find((item) => item.id === projectId);
+          setDataSource(value);
+        }}
+        editable={{
+          form,
+          editableKeys,
+          onSave: async (key, values) => {
+            const data: ITaskUpdateRequest = {
+              ...values,
+              studio_name: studioName ?? "",
+              project_id: projectId,
+            };
+            const url = `/v2/task`;
+            console.info("task save api request", url, values);
+            const res = await post<ITaskUpdateRequest, ITaskResponse>(
+              url,
+              data
+            );
+            onChange && onChange(res.data);
+            console.info("task save api response", res);
+          },
+
+          onChange: setEditableRowKeys,
+          actionRender: (row, config, dom) => [dom.save, dom.cancel],
+        }}
+        toolBarRender={() => [
+          <Options
+            items={groupItems}
+            initKey="none"
+            icon={<GroupIcon />}
+            onChange={(key: string) => {
+              switch (key) {
+                case "status":
+                  let statuses = new Map<string, number>();
+                  rawData.forEach((task) => {
+                    if (task.status) {
+                      if (statuses.has(task.status)) {
+                        statuses.set(
+                          task.status,
+                          statuses.get(task.status)! + 1
+                        );
+                      } else {
+                        statuses.set(task.status, 1);
+                      }
+                    }
+                  });
+                  let group: ITaskData[] = [];
+                  statuses.forEach((value, key) => {
+                    group.push({
+                      id: key,
+                      title: intl.formatMessage({
+                        id: `labels.task.status.${key}`,
+                      }),
+                      order: value,
+                      type: "group",
+                      is_milestone: false,
+                    });
+                  });
+                  const newGroup = group.map((item, id) => {
+                    return {
+                      ...item,
+                      children: rawData.filter(
+                        (task) => task.status === item.id
+                      ),
+                    };
+                  });
+                  setDataSource(newGroup);
+                  break;
+                case "project":
+                  let projectsId = new Map<string, number>();
+                  let projects = new Map<string, IProject>();
+                  rawData.forEach((task) => {
+                    if (task.project_id && task.project) {
+                      if (projectsId.has(task.project_id)) {
+                        projectsId.set(
+                          task.project_id,
+                          projectsId.get(task.project_id)! + 1
+                        );
+                      } else {
+                        projectsId.set(task.project_id, 1);
+                        projects.set(task.project_id, task.project);
+                      }
+                    }
+                  });
+                  let projectList: ITaskData[] = [];
+                  projectsId.forEach((value, key) => {
+                    const project = projects.get(key)!;
+                    projectList.push({
+                      id: project.id,
+                      title: `${project.title}`,
+                      type: "group",
+                      order: value,
+                      is_milestone: false,
+                    });
+                  });
+                  const newProject = projectList.map((item, id) => {
+                    return {
+                      ...item,
+                      children: rawData.filter(
+                        (task) => task.project_id === item.id
+                      ),
+                    };
+                  });
+                  setDataSource(newProject);
+                  break;
+                case "title":
+                  let titles = new Map<string, number>();
+                  rawData.forEach((task) => {
+                    if (task.title) {
+                      if (titles.has(task.title)) {
+                        titles.set(task.title, titles.get(task.title)! + 1);
+                      } else {
+                        titles.set(task.title, 1);
+                      }
+                    }
+                  });
+                  let titleGroups: ITaskData[] = [];
+                  titles.forEach((value, key) => {
+                    titleGroups.push({
+                      id: key,
+                      title: key,
+                      order: value,
+                      type: "group",
+                      is_milestone: false,
+                    });
+                  });
+                  const newTitleGroup = titleGroups.map((item, id) => {
+                    return {
+                      ...item,
+                      children: rawData.filter(
+                        (task) => task.title === item.title
+                      ),
+                    };
+                  });
+                  setDataSource(newTitleGroup);
+                  break;
+                default:
+                  break;
+              }
+            }}
+          />,
+          <Filter
+            initValue={filters}
+            onChange={(data) => {
+              setCurrFilter(data);
+              actionRef.current?.reload();
+            }}
+          />,
+        ]}
+      />
+      <TaskEditDrawer
+        taskId={selectedTask}
+        openDrawer={open}
+        onClose={() => setOpen(false)}
+        onChange={(data: ITaskData) => {
+          console.debug("task change", data);
+          setDataSource((origin) => {
+            const update = (item: ITaskData): ITaskData => {
+              item.children = item.children?.map(update);
+              if (item.id === data.id) {
+                return { ...data, children: item.children };
+              }
+              return item;
+            };
+            return origin.map(update);
+          });
+          onChange && onChange(data);
+        }}
+      />
+    </>
+  );
+};
+
+export default TaskList;

+ 24 - 0
dashboard-v4/dashboard/src/components/task/TaskLoader.tsx

@@ -0,0 +1,24 @@
+import { useEffect } from "react";
+import { get } from "../../request";
+import { ITaskData, ITaskListResponse } from "../api/task";
+
+interface IWidget {
+  projectId?: string;
+  onLoad?: (data: ITaskData[]) => void;
+}
+const TaskLoader = ({ projectId, onLoad }: IWidget) => {
+  useEffect(() => {
+    let url = `/v2/task?a=a`;
+    if (projectId) {
+      url += `&view=project&project_id=${projectId}`;
+    }
+    console.info("api request", url);
+    get<ITaskListResponse>(url).then((json) => {
+      console.debug("api response", json);
+      onLoad && onLoad(json.data.rows);
+    });
+  }, [projectId]);
+  return <></>;
+};
+
+export default TaskLoader;

+ 351 - 0
dashboard-v4/dashboard/src/components/task/TaskProjects.tsx

@@ -0,0 +1,351 @@
+import { ActionType, ProTable } from "@ant-design/pro-components";
+import { FormattedMessage, useIntl } from "react-intl";
+import { Link } from "react-router-dom";
+import {
+  Alert,
+  Badge,
+  Button,
+  message,
+  Modal,
+  Popover,
+  Typography,
+} from "antd";
+import { Dropdown } from "antd";
+import {
+  ExclamationCircleOutlined,
+  DeleteOutlined,
+  PlusOutlined,
+} from "@ant-design/icons";
+
+import { delete_, get } from "../../request";
+import { TChannelType } from "../api/Channel";
+import { PublicityValueEnum } from "../studio/table";
+import { IDeleteResponse } from "../api/Article";
+import { useEffect, useRef, useState } from "react";
+import StudioName, { IStudio } from "../auth/Studio";
+
+import { getSorterUrl } from "../../utils";
+import { TransferOutLinedIcon } from "../../assets/icon";
+import { IProjectData, IProjectListResponse } from "../api/task";
+import ProjectCreate from "./ProjectCreate";
+
+const { Text } = Typography;
+
+export const channelTypeFilter = {
+  all: {
+    text: <FormattedMessage id="channel.type.all.title" />,
+    status: "Default",
+  },
+  translation: {
+    text: <FormattedMessage id="channel.type.translation.label" />,
+    status: "Success",
+  },
+  nissaya: {
+    text: <FormattedMessage id="channel.type.nissaya.label" />,
+    status: "Processing",
+  },
+  commentary: {
+    text: <FormattedMessage id="channel.type.commentary.label" />,
+    status: "Default",
+  },
+  original: {
+    text: <FormattedMessage id="channel.type.original.label" />,
+    status: "Default",
+  },
+};
+
+export interface IResNumberResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    my: number;
+    collaboration: number;
+  };
+}
+
+export const renderBadge = (count: number, active = false) => {
+  return (
+    <Badge
+      count={count}
+      style={{
+        marginBlockStart: -2,
+        marginInlineStart: 4,
+        color: active ? "#1890FF" : "#999",
+        backgroundColor: active ? "#E6F7FF" : "#eee",
+      }}
+    />
+  );
+};
+
+interface IWidget {
+  studioName?: string;
+  type?: string;
+  disableChannels?: string[];
+  channelType?: TChannelType;
+  onSelect?: Function;
+}
+
+const ProjectListWidget = ({
+  studioName,
+  disableChannels,
+  channelType,
+  type,
+  onSelect,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("all");
+  const [myNumber, setMyNumber] = useState<number>(0);
+  const [collaborationNumber, setCollaborationNumber] = useState<number>(0);
+  const [openCreate, setOpenCreate] = useState(false);
+  useEffect(() => {
+    ref.current?.reload();
+  }, [disableChannels]);
+
+  const showDeleteConfirm = (id: string, title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        const url = `/v2/channel/${id}`;
+        console.log("delete api request", url);
+        return delete_<IDeleteResponse>(url)
+          .then((json) => {
+            console.info("api response", json);
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const ref = useRef<ActionType>();
+
+  return (
+    <>
+      {channelType ? (
+        <Alert
+          message={`仅显示版本类型${channelType}`}
+          type="success"
+          closable
+        />
+      ) : undefined}
+      <ProTable<IProjectData>
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.title.label",
+            }),
+            dataIndex: "title",
+            width: 250,
+            key: "title",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            render(dom, entity, index, action, schema) {
+              return (
+                <Link to={`/studio/${studioName}/task/project/${entity.id}`}>
+                  {entity.title}
+                </Link>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.executors.label",
+            }),
+            dataIndex: "executors",
+            key: "executors",
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.milestone.label",
+            }),
+            dataIndex: "milestone",
+            key: "milestone",
+            width: 80,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.status.label",
+            }),
+            dataIndex: "status",
+            key: "status",
+            width: 80,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: PublicityValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.updated-at.label",
+            }),
+            key: "updated_at",
+            width: 100,
+            search: false,
+            dataIndex: "updated_at",
+            valueType: "date",
+            sorter: true,
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 100,
+            valueType: "option",
+            render: (text, row, index, action) => {
+              return [
+                <Dropdown.Button
+                  key={index}
+                  type="link"
+                  trigger={["click", "contextMenu"]}
+                  menu={{
+                    items: [
+                      {
+                        key: "transfer",
+                        label: intl.formatMessage({
+                          id: "columns.studio.transfer.title",
+                        }),
+                        icon: <TransferOutLinedIcon />,
+                      },
+                      {
+                        key: "remove",
+                        label: intl.formatMessage({
+                          id: "buttons.delete",
+                        }),
+                        icon: <DeleteOutlined />,
+                        danger: true,
+                      },
+                    ],
+                    onClick: (e) => {
+                      switch (e.key) {
+                        case "remove":
+                          showDeleteConfirm(row.id, row.title);
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                >
+                  <Link to={`/studio/${studioName}/channel/${row.id}/setting`}>
+                    {intl.formatMessage({
+                      id: "buttons.setting",
+                    })}
+                  </Link>
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/v2/project?view=studio&type=${activeKey}`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+
+          url += params.keyword ? "&keyword=" + params.keyword : "";
+          url += channelType ? "&type=" + channelType : "";
+          url += getSorterUrl(sorter);
+          console.log("project list api request", url);
+          const res = await get<IProjectListResponse>(url);
+          console.info("project list api response", res);
+          return {
+            total: res.data.count,
+            succcess: res.ok,
+            data: res.data.rows,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <Popover
+            content={
+              <ProjectCreate
+                studio={studioName}
+                type={activeKey === "workflow" ? "workflow" : "normal"}
+                onCreate={() => {
+                  setOpenCreate(false);
+                  ref.current?.reload();
+                }}
+              />
+            }
+            placement="bottomRight"
+            trigger="click"
+            open={openCreate}
+            onOpenChange={(open: boolean) => {
+              setOpenCreate(open);
+            }}
+          >
+            <Button key="button" icon={<PlusOutlined />} type="primary">
+              {intl.formatMessage({ id: "buttons.create" })}
+            </Button>
+          </Popover>,
+        ]}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: "all",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.all" })}
+                    {renderBadge(myNumber, activeKey === "all")}
+                  </span>
+                ),
+              },
+              {
+                key: "workflow",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.workflow" })}
+                    {renderBadge(collaborationNumber, activeKey === "workflow")}
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              console.log("show course", key);
+              setActiveKey(key);
+              ref.current?.reload();
+            },
+          },
+        }}
+      />
+    </>
+  );
+};
+
+export default ProjectListWidget;

+ 140 - 0
dashboard-v4/dashboard/src/components/task/TaskReader.tsx

@@ -0,0 +1,140 @@
+import { useEffect, useState } from "react";
+
+import { Divider, Space, Tag, Typography, message } from "antd";
+import { CodeSandboxOutlined } from "@ant-design/icons";
+
+import { ITaskData, ITaskResponse, ITaskUpdateRequest } from "../api/task";
+import { get, patch } from "../../request";
+import MdView from "../template/MdView";
+import User from "../auth/User";
+import TimeShow from "../general/TimeShow";
+import TaskEditButton, { TRelation } from "./TaskEditButton";
+import PreTask from "./PreTask";
+
+const { Text, Title } = Typography;
+
+export const Milestone = ({ task }: { task?: ITaskData }) => {
+  return task?.is_milestone ? (
+    <Tag icon={<CodeSandboxOutlined />} color="error">
+      里程碑
+    </Tag>
+  ) : null;
+};
+
+export const Status = ({ task }: { task?: ITaskData }) => {
+  return task?.status === "pending" ? (
+    <Tag color="default">未发布</Tag>
+  ) : task?.status === "published" ? (
+    <Tag color="warning">待领取</Tag>
+  ) : task?.status === "running" ? (
+    <Tag color="processing">进行中</Tag>
+  ) : task?.status === "done" ? (
+    <Tag color="success">已完成</Tag>
+  ) : task?.status === "restarted" ? (
+    <Tag color="error">已重启</Tag>
+  ) : null;
+};
+
+interface IWidget {
+  taskId?: string;
+  task?: ITaskData;
+  onLoad?: (data: ITaskData) => void;
+  onChange?: (data: ITaskData) => void;
+  onEdit?: () => void;
+}
+const TaskReader = ({ taskId, task, onLoad, onChange, onEdit }: IWidget) => {
+  const [openPreTask, setOpenPreTask] = useState(false);
+  const [openNextTask, setOpenNextTask] = useState(false);
+  useEffect(() => {
+    const url = `/v2/task/${taskId}`;
+    console.info("api request", url);
+    get<ITaskResponse>(url).then((json) => {
+      if (json.ok) {
+        onLoad && onLoad(json.data);
+      }
+    });
+  }, [taskId]);
+
+  const updatePreTask = (type: TRelation, data?: ITaskData | null) => {
+    if (!taskId || !data) {
+      return;
+    }
+    let setting: ITaskUpdateRequest = {
+      id: taskId,
+      studio_name: "",
+    };
+    if (type === "pre") {
+      setting.pre_task_id = data.id;
+    } else if (type === "next") {
+      setting.next_task_id = data.id;
+    }
+
+    const url = `/v2/task/${setting.id}`;
+    console.info("api request", url, setting);
+    patch<ITaskUpdateRequest, ITaskResponse>(url, setting).then((json) => {
+      console.info("api response", json);
+      if (json.ok) {
+        message.success("Success");
+        onChange && onChange(json.data);
+      } else {
+        message.error(json.message);
+      }
+    });
+  };
+  return (
+    <div>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <Space>
+          <Status task={task} />
+          <Milestone task={task} />
+          <PreTask
+            task={task}
+            open={openPreTask}
+            type="pre"
+            onClick={(data) => {
+              updatePreTask("pre", data);
+              setOpenPreTask(false);
+            }}
+            onClose={() => setOpenPreTask(false)}
+          />
+          <PreTask
+            task={task}
+            open={openNextTask}
+            type="next"
+            onClick={(data) => {
+              updatePreTask("next", data);
+              setOpenNextTask(false);
+            }}
+            onClose={() => setOpenNextTask(false)}
+          />
+        </Space>
+        <div>
+          <TaskEditButton
+            task={task}
+            onChange={(task: ITaskData) => {
+              onChange && onChange(task);
+            }}
+            onEdit={onEdit}
+            onPreTask={(type: TRelation) => {
+              if (type === "pre") {
+                setOpenPreTask(true);
+              } else if (type === "next") {
+                setOpenNextTask(true);
+              }
+            }}
+          />
+        </div>
+      </div>
+      <Title>{task?.title}</Title>
+      <div>
+        <Space>
+          <User {...task?.editor} />
+          <TimeShow updatedAt={task?.updated_at} />
+        </Space>
+      </div>
+      <Divider />
+      <MdView html={task?.html} />
+    </div>
+  );
+};
+export default TaskReader;

+ 67 - 0
dashboard-v4/dashboard/src/components/task/TaskRelation.tsx

@@ -0,0 +1,67 @@
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import {  ITaskData, ITaskListResponse } from "../api/task";
+
+import "../article/article.css";
+
+import Mermaid from "../general/Mermaid";
+
+interface IWidget {
+  projectId?: string;
+  tasks?:ITaskData[];
+}
+const TaskRelation = ({ tasks }: IWidget) => {
+
+  let mermaidText = "flowchart LR\n";
+
+  //节点样式
+  const color = [
+    { status: "pending", fill: "white" },
+    { status: "published", fill: "orange" },
+    { status: "running", fill: "green" },
+    { status: "done", fill: "blue" },
+    { status: "restarted", fill: "red" },
+    { status: "closed", fill: "yellow" },
+    { status: "canceled", fill: "gray" },
+    { status: "expired", fill: "brown" },
+  ];
+
+  color.forEach((value) => {
+    mermaidText += `classDef ${value.status} fill:${value.fill},stroke:#333,stroke-width:2px;\n`;
+  });
+
+  tasks?.forEach((task: ITaskData, index: number, array: ITaskData[]) => {
+    //输出节点
+    mermaidText += `${task.id}[${task.title}]:::${task.status};\n`;
+
+    //输出带有子任务的节点
+    const children = array.filter(
+      (value: ITaskData, index: number, array: ITaskData[]) => {
+        return value.parent_id === task.id;
+      }
+    );
+    if (children.length > 0) {
+      mermaidText += `subgraph ${task.id} ["${task.title}"]\n`;
+      mermaidText += `${children.map((task) => task.id).join(`;\n`)}`;
+      mermaidText += ";\nend\n";
+    }
+
+    //关系线
+    if (task.pre_task) {
+      mermaidText += `${task.pre_task.id} --> ${task.id};\n`;
+    }
+    if (task.next_task) {
+      mermaidText += `${task.id} --> ${task.next_task.id};\n`;
+    }
+  });
+
+  console.debug(mermaidText);
+
+  return (
+    <div>
+      <Mermaid text={mermaidText} />
+    </div>
+  );
+};
+
+export default TaskRelation;

+ 141 - 0
dashboard-v4/dashboard/src/components/task/TaskTable.tsx

@@ -0,0 +1,141 @@
+import { useEffect, useState } from "react";
+
+import { IProject, ITaskData } from "../api/task";
+import "../article/article.css";
+import { Status } from "./TaskReader";
+import User from "../auth/User";
+import { Assignees } from "./TaskList";
+
+interface ITaskHeading {
+  id: string;
+  title: string;
+  children: number;
+}
+
+interface IWidget {
+  tasks?: ITaskData[];
+}
+const TaskTable = ({ tasks }: IWidget) => {
+  const [tasksTitle, setTasksTitle] = useState<ITaskHeading[][]>();
+  const [projects, setProjects] = useState<IProject[]>();
+
+  useEffect(() => {
+    let projectsId = new Map<string, number>();
+    let projectMap = new Map<string, IProject>();
+    tasks?.forEach((task) => {
+      if (task.project_id && task.project) {
+        if (projectsId.has(task.project_id)) {
+          projectsId.set(task.project_id, projectsId.get(task.project_id)! + 1);
+        } else {
+          projectsId.set(task.project_id, 1);
+          projectMap.set(task.project_id, task.project);
+        }
+      }
+    });
+
+    setProjects(Array.from(projectMap.values()));
+
+    //列表头
+    let titles1: ITaskHeading[] = [];
+    let titles2: ITaskHeading[] = [];
+    let titles3: ITaskHeading[] = [];
+    tasks
+      ?.filter((value: ITaskData) => !value.parent_id)
+      .forEach((task) => {
+        const children = tasks
+          ?.filter((value1) => value1.parent_id === task.id)
+          .map((task1) => {
+            const child: ITaskHeading = {
+              id: task1.id,
+              title: task1.title ?? "",
+              children: 0,
+            };
+            return child;
+          });
+        titles2 = [...titles2, ...children];
+        titles1.push({
+          title: task.title ?? "",
+          id: task.id,
+          children: children.length,
+        });
+        if (children.length === 0) {
+          titles3.push({
+            title: task.title ?? "",
+            id: task.id,
+            children: 0,
+          });
+        } else {
+          titles3 = [...titles3, ...children];
+        }
+      });
+    const heading = [titles1, titles2, titles3];
+    console.log("heading", heading);
+    setTasksTitle(heading);
+  }, [tasks]);
+
+  return (
+    <div className="pcd_article">
+      <table>
+        <thead>
+          {tasksTitle?.map((row, level) => {
+            return level < 2 ? (
+              <tr>
+                {level === 0 ? <th rowSpan={2}>project</th> : undefined}
+                {row.map((task, index) => {
+                  return (
+                    <th
+                      key={index}
+                      colSpan={task.children === 0 ? undefined : task.children}
+                      rowSpan={task.children === 0 ? 2 : undefined}
+                    >
+                      {task.title}
+                    </th>
+                  );
+                })}
+              </tr>
+            ) : (
+              <></>
+            );
+          })}
+        </thead>
+        <tbody>
+          {projects?.map((row, index) => (
+            <tr key={index}>
+              <td>{row.title}</td>
+              {tasksTitle && tasksTitle.length >= 3 ? (
+                tasksTitle[2].map((task, id) => {
+                  const taskData = tasks?.find(
+                    (value: ITaskData) =>
+                      value.title === task.title && value.project_id === row.id
+                  );
+                  return (
+                    <td key={id}>
+                      <div>
+                        <div>
+                          {taskData?.executor ? (
+                            <User {...taskData.executor} />
+                          ) : taskData?.assignees ? (
+                            <Assignees data={taskData} />
+                          ) : (
+                            <></>
+                          )}
+                        </div>
+                        <div>
+                          <Status task={taskData} />
+                        </div>
+                      </div>
+                    </td>
+                  );
+                })
+              ) : (
+                <></>
+              )}
+            </tr>
+          ))}
+        </tbody>
+      </table>
+    </div>
+  );
+};
+
+export default TaskTable;