visuddhinanda 1 месяц назад
Родитель
Сommit
565eeec635
88 измененных файлов с 7438 добавлено и 311 удалено
  1. 5 0
      dashboard-v6/src/App.tsx
  2. 17 188
      dashboard-v6/src/Router.tsx
  3. 18 0
      dashboard-v6/src/api/Comment.ts
  4. 80 0
      dashboard-v6/src/components/ai-model/AiAssistantSelect.tsx
  5. 69 0
      dashboard-v6/src/components/ai-model/AiModelCreate.tsx
  6. 114 0
      dashboard-v6/src/components/ai-model/AiModelEdit.tsx
  7. 147 0
      dashboard-v6/src/components/ai-model/AiModelList.tsx
  8. 154 0
      dashboard-v6/src/components/ai-model/AiModelLogList.tsx
  9. 78 0
      dashboard-v6/src/components/ai-model/AiTranslate.tsx
  10. 145 0
      dashboard-v6/src/components/ai-model/ModelSelector.tsx
  11. 72 0
      dashboard-v6/src/components/article/ChapterToc.tsx
  12. 7 2
      dashboard-v6/src/components/auth/SignInAvatar.tsx
  13. 1 0
      dashboard-v6/src/components/discussion/Discussion.tsx
  14. 1 21
      dashboard-v6/src/components/discussion/DiscussionCreate.tsx
  15. 4 3
      dashboard-v6/src/components/discussion/DiscussionEdit.tsx
  16. 11 22
      dashboard-v6/src/components/discussion/DiscussionItem.tsx
  17. 10 13
      dashboard-v6/src/components/discussion/DiscussionList.tsx
  18. 15 9
      dashboard-v6/src/components/discussion/DiscussionListCard.tsx
  19. 16 14
      dashboard-v6/src/components/discussion/DiscussionShow.tsx
  20. 11 10
      dashboard-v6/src/components/discussion/DiscussionTopicChildren.tsx
  21. 4 6
      dashboard-v6/src/components/discussion/QaBox.tsx
  22. 10 9
      dashboard-v6/src/components/discussion/QaList.tsx
  23. 59 0
      dashboard-v6/src/components/discussion/hooks/useDiscussion.tsx
  24. 37 0
      dashboard-v6/src/components/like/EditableAvatarGroup.tsx
  25. 136 0
      dashboard-v6/src/components/like/Like.tsx
  26. 67 0
      dashboard-v6/src/components/like/LikeAvatar.tsx
  27. 75 0
      dashboard-v6/src/components/like/WatchAdd.tsx
  28. 45 0
      dashboard-v6/src/components/like/WatchList.tsx
  29. 24 11
      dashboard-v6/src/components/navigation/MainMenu.tsx
  30. 9 0
      dashboard-v6/src/components/setting/SettingModal.tsx
  31. 88 0
      dashboard-v6/src/components/task/Assignees.tsx
  32. 70 0
      dashboard-v6/src/components/task/Category.tsx
  33. 114 0
      dashboard-v6/src/components/task/Description.tsx
  34. 26 0
      dashboard-v6/src/components/task/Executors.tsx
  35. 204 0
      dashboard-v6/src/components/task/Filter.tsx
  36. 155 0
      dashboard-v6/src/components/task/MyTasks.tsx
  37. 52 0
      dashboard-v6/src/components/task/Options.tsx
  38. 32 0
      dashboard-v6/src/components/task/PlanDate.tsx
  39. 139 0
      dashboard-v6/src/components/task/PreTask.tsx
  40. 307 0
      dashboard-v6/src/components/task/Project.tsx
  41. 112 0
      dashboard-v6/src/components/task/ProjectClone.tsx
  42. 75 0
      dashboard-v6/src/components/task/ProjectCreate.tsx
  43. 82 0
      dashboard-v6/src/components/task/ProjectEdit.tsx
  44. 47 0
      dashboard-v6/src/components/task/ProjectEditDrawer.tsx
  45. 221 0
      dashboard-v6/src/components/task/ProjectList.tsx
  46. 301 0
      dashboard-v6/src/components/task/ProjectTable.tsx
  47. 79 0
      dashboard-v6/src/components/task/ProjectTask.tsx
  48. 23 0
      dashboard-v6/src/components/task/ProjectWithTasks.tsx
  49. 24 0
      dashboard-v6/src/components/task/Task.tsx
  50. 496 0
      dashboard-v6/src/components/task/TaskBuilderChapter.tsx
  51. 1 1
      dashboard-v6/src/components/task/TaskBuilderChapterModal.tsx
  52. 364 0
      dashboard-v6/src/components/task/TaskBuilderProjects.tsx
  53. 223 0
      dashboard-v6/src/components/task/TaskBuilderProp.tsx
  54. 100 0
      dashboard-v6/src/components/task/TaskEdit.tsx
  55. 116 0
      dashboard-v6/src/components/task/TaskEditButton.tsx
  56. 78 0
      dashboard-v6/src/components/task/TaskEditDrawer.tsx
  57. 84 0
      dashboard-v6/src/components/task/TaskFlowchart.tsx
  58. 648 0
      dashboard-v6/src/components/task/TaskList.tsx
  59. 106 0
      dashboard-v6/src/components/task/TaskListAdd.tsx
  60. 22 0
      dashboard-v6/src/components/task/TaskLoader.tsx
  61. 60 0
      dashboard-v6/src/components/task/TaskLog.tsx
  62. 223 0
      dashboard-v6/src/components/task/TaskReader.tsx
  63. 42 0
      dashboard-v6/src/components/task/TaskRelation.tsx
  64. 79 0
      dashboard-v6/src/components/task/TaskStatus.tsx
  65. 218 0
      dashboard-v6/src/components/task/TaskStatusButton.tsx
  66. 152 0
      dashboard-v6/src/components/task/TaskTable.tsx
  67. 52 0
      dashboard-v6/src/components/task/TaskTableCell.tsx
  68. 56 0
      dashboard-v6/src/components/task/TaskTitle.tsx
  69. 163 0
      dashboard-v6/src/components/task/Workflow.tsx
  70. 49 0
      dashboard-v6/src/components/task/utils.ts
  71. 8 2
      dashboard-v6/src/layouts/workspace/index.tsx
  72. 14 0
      dashboard-v6/src/pages/dashboard/settings/ai-model/edit.tsx
  73. 11 0
      dashboard-v6/src/pages/dashboard/settings/ai-model/index.tsx
  74. 11 0
      dashboard-v6/src/pages/dashboard/settings/ai-model/log.tsx
  75. 27 0
      dashboard-v6/src/pages/workspace/task/hall.tsx
  76. 27 0
      dashboard-v6/src/pages/workspace/task/pending.tsx
  77. 15 0
      dashboard-v6/src/pages/workspace/task/project-edit.tsx
  78. 26 0
      dashboard-v6/src/pages/workspace/task/project.tsx
  79. 16 0
      dashboard-v6/src/pages/workspace/task/projects.tsx
  80. 11 0
      dashboard-v6/src/pages/workspace/task/tasks.tsx
  81. 11 0
      dashboard-v6/src/pages/workspace/task/workflow.tsx
  82. 52 0
      dashboard-v6/src/routes/anthologyRoutes.ts
  83. 32 0
      dashboard-v6/src/routes/articleRoutes.ts
  84. 47 0
      dashboard-v6/src/routes/channelRoutes.ts
  85. 49 0
      dashboard-v6/src/routes/settingsRoutes.ts
  86. 62 0
      dashboard-v6/src/routes/taskRouters.ts
  87. 41 0
      dashboard-v6/src/routes/termRoutes.ts
  88. 54 0
      dashboard-v6/src/routes/tipitakaRoutes.ts

+ 5 - 0
dashboard-v6/src/App.tsx

@@ -42,6 +42,11 @@ const ThemedApp = () => {
           colorPrimary: "#1677ff",
           borderRadius: 6,
         },
+        components: {
+          Menu: {
+            collapsedWidth: 40,
+          },
+        },
       }}
     >
       <AntdApp>

+ 17 - 188
dashboard-v6/src/Router.tsx

@@ -1,63 +1,33 @@
+// src/Router.tsx
 import { lazy } from "react";
 import { createBrowserRouter } from "react-router";
 import { RouterProvider } from "react-router/dom";
-import { channelLoader } from "./api/channel";
 import { testRoutes } from "./routes/testRoutes";
 import { buildRouteConfig } from "./routes/buildRoutes";
-import { anthologyLoader, articleLoader } from "./api/article";
-import { termLoader } from "./api/Term";
+import taskRoutes from "./routes/taskRouters";
+import settingsRoutes from "./routes/settingsRoutes";
+import anthologyRoutes from "./routes/anthologyRoutes";
+import articleRoutes from "./routes/articleRoutes";
+import tipitakaRoutes from "./routes/tipitakaRoutes";
+import channelRoutes from "./routes/channelRoutes";
+import termRoutes from "./routes/termRoutes";
 
 const RootLayout = lazy(() => import("./layouts/Root"));
 const AnonymousLayout = lazy(() => import("./layouts/anonymous"));
 const DashboardLayout = lazy(() => import("./layouts/dashboard"));
 const WorkspaceLayout = lazy(() => import("./layouts/workspace"));
+const TestLayout = lazy(() => import("./layouts/test"));
 
 const UsersSignIn = lazy(() => import("./pages/users/sign-in"));
 const UsersSignUp = lazy(() => import("./pages/users/sign-up"));
 const UsersForgotPassword = lazy(() => import("./pages/users/forgot-password"));
 const UsersResetPassword = lazy(() => import("./pages/users/reset-password"));
 const DashboardIndex = lazy(() => import("./pages/dashboard/index"));
+
 const Home = lazy(() => import("./pages/home"));
-const WorkspaceChannel = lazy(() => import("./pages/workspace/channel/list"));
-const WorkspaceChannelShow = lazy(
-  () => import("./pages/workspace/channel/show")
-);
-const WorkspaceChannelSetting = lazy(
-  () => import("./pages/workspace/channel/setting")
-);
-const WorkspaceTipitaka = lazy(
-  () => import("./pages/workspace/tipitaka/bypath")
-);
-const WorkspaceTipitakaChapter = lazy(
-  () => import("./pages/workspace/tipitaka/chapter")
-);
 const WorkspaceHome = lazy(() => import("./pages/workspace/home"));
 const WorkspaceChat = lazy(() => import("./pages/workspace/chat"));
 
-const WorkspaceTerm = lazy(() => import("./pages/workspace/term/list"));
-const WorkspaceTermShow = lazy(() => import("./pages/workspace/term/show"));
-const WorkspaceTermEdit = lazy(() => import("./pages/workspace/term/edit"));
-
-// 文集
-const WorkspaceAnthologyList = lazy(
-  () => import("./pages/workspace/anthology")
-);
-const WorkspaceAnthologyShow = lazy(
-  () => import("./pages/workspace/anthology/show")
-);
-const WorkspaceAnthologyEdit = lazy(
-  () => import("./pages/workspace/anthology/edit")
-);
-
-// 文章
-const WorkspaceArticleList = lazy(() => import("./pages/workspace/article"));
-const WorkspaceArticleShow = lazy(
-  () => import("./pages/workspace/article/show")
-);
-
-// ↓ 新增:TestLayout
-const TestLayout = lazy(() => import("./layouts/test"));
-
 const router = createBrowserRouter(
   [
     {
@@ -107,152 +77,13 @@ const router = createBrowserRouter(
               Component: WorkspaceChat,
               handle: { id: "workspace.ai", crumb: "ai" },
             },
-            {
-              path: "anthology",
-              handle: {
-                id: "workspace.anthology",
-                crumb: "anthology",
-              },
-              children: [
-                {
-                  index: true,
-                  Component: WorkspaceAnthologyList,
-                },
-                {
-                  path: ":anthologyId",
-                  loader: anthologyLoader,
-                  handle: {
-                    crumb: (match: { data: { title: string } }) =>
-                      match.data.title,
-                  },
-                  children: [
-                    { index: true, Component: WorkspaceAnthologyShow },
-                    {
-                      path: "edit",
-                      handle: {
-                        crumb: "edit",
-                      },
-                      Component: WorkspaceAnthologyEdit,
-                    },
-                    {
-                      path: ":articleId",
-                      Component: WorkspaceArticleShow,
-                    },
-                  ],
-                },
-              ],
-            },
-            {
-              path: "article",
-              handle: {
-                id: "workspace.article",
-                crumb: "article",
-              },
-              children: [
-                {
-                  index: true,
-                  Component: WorkspaceArticleList,
-                },
-                {
-                  path: ":articleId",
-                  Component: WorkspaceArticleShow,
-                  loader: articleLoader,
-                  handle: {
-                    crumb: (match: { data: { title: string } }) =>
-                      match.data.title,
-                  },
-                },
-              ],
-            },
-            {
-              path: "tipitaka",
-              handle: { id: "workspace.tipitaka", crumb: "tipitaka" },
-              children: [
-                {
-                  path: "lib",
-                  Component: WorkspaceTipitaka,
-                  children: [
-                    {
-                      path: ":root",
-                      Component: WorkspaceTipitaka,
-                      children: [
-                        {
-                          path: ":path",
-                          Component: WorkspaceTipitaka,
-                          children: [
-                            {
-                              path: ":tag",
-                              Component: WorkspaceTipitaka,
-                            },
-                          ],
-                        },
-                      ],
-                    },
-                  ],
-                },
-                {
-                  path: "chapter",
-                  children: [
-                    {
-                      path: ":id",
-                      Component: WorkspaceTipitakaChapter,
-                    },
-                  ],
-                },
-              ],
-            },
-            {
-              path: "channel",
-              handle: { id: "workspace.channel", crumb: "channel" },
-              children: [
-                {
-                  index: true,
-                  Component: WorkspaceChannel,
-                },
-                {
-                  path: ":channelId",
-                  loader: channelLoader,
-                  handle: {
-                    crumb: (match: { data: { name: string } }) =>
-                      match.data.name,
-                  },
-                  children: [
-                    {
-                      index: true,
-                      Component: WorkspaceChannelShow,
-                    },
-                    {
-                      path: "setting",
-                      Component: WorkspaceChannelSetting,
-                      handle: { crumb: "setting" },
-                    },
-                  ],
-                },
-              ],
-            },
-            {
-              path: "term",
-              handle: { id: "workspace.term", crumb: "term" },
-              children: [
-                { index: true, Component: WorkspaceTerm },
-                {
-                  path: ":id",
-                  loader: termLoader,
-                  handle: {
-                    crumb: (match: { data: { word: string } }) =>
-                      match.data.word,
-                  },
-                  children: [
-                    { index: true, Component: WorkspaceTermShow },
-                    {
-                      path: "edit",
-                      handle: { crumb: "edit" },
-                      Component: WorkspaceTermEdit,
-                    },
-                  ],
-                },
-              ],
-            },
+            ...taskRoutes,
+            ...settingsRoutes,
+            ...anthologyRoutes,
+            ...articleRoutes,
+            ...tipitakaRoutes,
+            ...channelRoutes,
+            ...termRoutes,
           ],
         },
 
@@ -261,9 +92,7 @@ const router = createBrowserRouter(
           path: "test",
           Component: TestLayout,
           children: [
-            // index: 访问 /test 时显示欢迎页(由 TestLayout 内部处理)
             { index: true },
-            // 自动将 testRoutes 转换为路由配置
             ...buildRouteConfig(testRoutes),
           ],
         },

+ 18 - 0
dashboard-v6/src/api/Comment.ts

@@ -1,7 +1,10 @@
+// src/api/Comment.ts
+
 import type { IUser } from "./Auth";
 import type { TContentType } from "./article";
 import type { TDiscussionType, TResType } from "./discussion";
 import type { ITagMapData } from "./Tag";
+import { get } from "../request";
 
 export interface ICommentRequest {
   id?: string;
@@ -85,3 +88,18 @@ export interface IDiscussionCountResponse {
   message: string;
   data: { discussions: IDiscussionCountData[]; tags: ITagMapData[] };
 }
+
+export interface IFetchCommentListParams {
+  limit?: number;
+  offset?: number;
+  status?: "active" | "close";
+}
+
+export async function fetchCommentList(
+  taskId: string,
+  params: IFetchCommentListParams = {}
+): Promise<ICommentListResponse> {
+  const { limit = 5, offset = 0, status = "active" } = params;
+  const url = `/v2/discussion?type=discussion&res_type=task&view=question&id=${taskId}&limit=${limit}&offset=${offset}&status=${status}`;
+  return get<ICommentListResponse>(url);
+}

+ 80 - 0
dashboard-v6/src/components/ai-model/AiAssistantSelect.tsx

@@ -0,0 +1,80 @@
+import {
+  ProFormSelect,
+  type RequestOptionsType,
+} from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+
+import { get } from "../../request";
+import type { IUserListResponse } from "../../api/Auth";
+
+interface IWidget {
+  name?: string;
+  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
+  multiple?: boolean;
+  hidden?: boolean;
+  hiddenTitle?: boolean;
+  required?: boolean;
+  initialValue?: string | string[] | null;
+  options?: RequestOptionsType[];
+}
+const UserSelectWidget = ({
+  name = "user",
+  multiple = false,
+  width = "md",
+  hidden = false,
+  hiddenTitle = false,
+  required = true,
+  options = [],
+  initialValue,
+}: IWidget) => {
+  const intl = useIntl();
+  console.log("UserSelect options", options);
+  return (
+    <ProFormSelect
+      name={name}
+      label={
+        hiddenTitle
+          ? undefined
+          : intl.formatMessage({ id: "labels.ai-assistant" })
+      }
+      hidden={hidden}
+      width={width}
+      initialValue={initialValue}
+      showSearch
+      debounceTime={300}
+      fieldProps={{
+        mode: multiple ? "tags" : undefined,
+      }}
+      request={async ({ keyWords }) => {
+        console.log("keyWord", keyWords);
+
+        if (typeof keyWords === "string") {
+          const url = `/api/v2/ai-assistant?keyword=${keyWords}`;
+          console.info("ai assistant api request", url);
+          const json = await get<IUserListResponse>(url);
+          console.info("ai assistant api response ", json);
+          const userList: RequestOptionsType[] = json.data.rows.map((item) => {
+            return {
+              value: item.id,
+              label: `${item.nickName}`,
+            };
+          });
+          console.log("json", userList);
+          return userList;
+        } else {
+          const defaultOptions: RequestOptionsType[] = options.map((item) => {
+            return { label: item.label, value: item.value?.toString() };
+          });
+          return defaultOptions;
+        }
+      }}
+      rules={[
+        {
+          required: required,
+        },
+      ]}
+    />
+  );
+};
+
+export default UserSelectWidget;

+ 69 - 0
dashboard-v6/src/components/ai-model/AiModelCreate.tsx

@@ -0,0 +1,69 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { post } from "../../request";
+import { useRef } from "react";
+import type { IAiModelRequest, IAiModelResponse } from "../../api/ai";
+
+interface IWidget {
+  studioName?: string;
+  onCreate?: () => void;
+}
+const AiModelCreate = ({ studioName, onCreate }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <ProForm<IAiModelRequest>
+      formRef={formRef}
+      onFinish={async (values: IAiModelRequest) => {
+        if (typeof studioName === "undefined") {
+          return;
+        }
+        const url = `/api/v2/ai-model`;
+        console.info("api request", url, values);
+        const res = await post<IAiModelRequest, IAiModelResponse>(url, values);
+        console.info("api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+            formRef.current?.resetFields();
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          required
+          label={intl.formatMessage({ id: "channel.name" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+        <ProFormText
+          width="md"
+          name="studio_name"
+          initialValue={studioName}
+          hidden
+          required
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default AiModelCreate;

+ 114 - 0
dashboard-v6/src/components/ai-model/AiModelEdit.tsx

@@ -0,0 +1,114 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { get, put } from "../../request";
+import { useRef } from "react";
+import type { IAiModelRequest, IAiModelResponse } from "../../api/ai";
+import Publicity from "../studio/Publicity";
+
+interface IWidget {
+  studioName?: string;
+  modelId?: string;
+  onChange?: () => void;
+}
+const AiModelEdit = ({ studioName, modelId, onChange }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <ProForm<IAiModelRequest>
+      formRef={formRef}
+      onFinish={async (values: IAiModelRequest) => {
+        if (typeof studioName === "undefined") {
+          return;
+        }
+        const url = `/api/v2/ai-model/${modelId}`;
+        console.info("api request", url, values);
+        const res = await put<IAiModelRequest, IAiModelResponse>(url, values);
+        console.info("api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          onChange?.();
+        } else {
+          message.error(res.message);
+        }
+      }}
+      request={async () => {
+        const url = `/api/v2/ai-model/${modelId}`;
+        console.info("api request", url);
+        const res = await get<IAiModelResponse>(url);
+        console.info("api response", res);
+        return res.data;
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          required
+          label={intl.formatMessage({ id: "forms.fields.name.label" })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "channel.create.message.noname",
+              }),
+            },
+          ]}
+        />
+        <ProFormText
+          width="md"
+          name="studio_name"
+          initialValue={studioName}
+          hidden
+          required
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="url"
+          label={intl.formatMessage({ id: "forms.fields.url.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="model"
+          label={intl.formatMessage({ id: "forms.fields.model.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="key"
+          label={intl.formatMessage({ id: "forms.fields.key.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <Publicity name="privacy" disable={["public_no_list", "blocked"]} />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="description"
+          label={intl.formatMessage({ id: "forms.fields.description.label" })}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="system_prompt"
+          label={"system_prompt"}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default AiModelEdit;

+ 147 - 0
dashboard-v6/src/components/ai-model/AiModelList.tsx

@@ -0,0 +1,147 @@
+import { Link } from "react-router";
+import { useIntl } from "react-intl";
+import { Button, Popover, Tag, Space } from "antd";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { PlusOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+
+import { useRef, useState } from "react";
+
+import { getSorterUrl } from "../../utils";
+import type { IAiModel, IAiModelListResponse } from "../../api/ai";
+import AiModelCreate from "./AiModelCreate";
+import PublicityIcon from "../studio/PublicityIcon";
+import ShareModal from "../share/ShareModal";
+
+import User from "../auth/User";
+import { EResType } from "../share/utils";
+
+interface IWidget {
+  studioName?: string;
+}
+const AiModelList = ({ studioName }: IWidget) => {
+  const intl = useIntl(); //i18n
+
+  const [openCreate, setOpenCreate] = useState(false);
+
+  const ref = useRef<ActionType | null>(null);
+
+  return (
+    <>
+      <ProList<IAiModel>
+        actionRef={ref}
+        onRow={() => ({
+          onClick: () => {},
+        })}
+        metas={{
+          title: {
+            dataIndex: "name",
+            render(_dom, entity) {
+              return (
+                <Space>
+                  <PublicityIcon value={entity.privacy} />
+                  <Link to={`/workspace/settings/ai-model/${entity.uid}/edit`}>
+                    {entity.name}
+                  </Link>
+                </Space>
+              );
+            },
+          },
+          description: {
+            dataIndex: "url",
+          },
+          subTitle: {
+            render(_dom, entity) {
+              return <Tag>{entity.model}</Tag>;
+            },
+          },
+          content: {
+            render(_dom, entity) {
+              return entity.description;
+            },
+          },
+          avatar: {
+            render(_dom, entity) {
+              return <User {...entity.user} showName={false} />;
+            },
+          },
+          actions: {
+            render(_dom, entity) {
+              return (
+                <Space>
+                  <Link to={`/workspace/settings/ai-model/${entity.uid}/log`}>
+                    logs
+                  </Link>
+                  <ShareModal
+                    trigger={
+                      <Button type="link" size="small">
+                        {intl.formatMessage({
+                          id: "buttons.share",
+                        })}
+                      </Button>
+                    }
+                    resId={entity.uid}
+                    resType={EResType.modal}
+                  />
+                </Space>
+              );
+            },
+          },
+        }}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/api/v2/ai-model?view=studio&name=${studioName}`;
+          const offset = ((params.current ?? 1) - 1) * (params.pageSize ?? 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+          url += getSorterUrl(sorter);
+
+          console.info("api request", url);
+          const res = await get<IAiModelListResponse>(url);
+          console.info("api response", res);
+          return {
+            total: res.data.total,
+            succcess: res.ok,
+            data: res.data.rows,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <Popover
+            content={
+              <AiModelCreate
+                studioName={studioName}
+                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>,
+        ]}
+      />
+    </>
+  );
+};
+
+export default AiModelList;

+ 154 - 0
dashboard-v6/src/components/ai-model/AiModelLogList.tsx

@@ -0,0 +1,154 @@
+import { ProList } from "@ant-design/pro-components";
+import { Space, Tabs, Tag, Typography } from "antd";
+import type { Key } from "react";
+import { useState } from "react";
+
+import { CheckOutlined, WarningOutlined } from "@ant-design/icons";
+
+import type { IAiModelLogData, IAiModelLogListResponse } from "../../api/ai";
+import { get } from "../../request";
+import dayjs from "dayjs";
+
+const { Text } = Typography;
+
+interface IWidget {
+  modelId?: string;
+}
+const AiModelLogList = ({ modelId }: IWidget) => {
+  const [expandedRowKeys, setExpandedRowKeys] = useState<readonly Key[]>([]);
+
+  return (
+    <ProList<IAiModelLogData>
+      rowKey="title"
+      headerTitle="logs"
+      expandable={{ expandedRowKeys, onExpandedRowsChange: setExpandedRowKeys }}
+      metas={{
+        title: {},
+        subTitle: {
+          render: (_dom, entity) => {
+            return (
+              <Space size={0}>
+                <Tag color="blue">{entity.status}</Tag>
+              </Space>
+            );
+          },
+        },
+        description: {
+          render: (_dom, entity) => {
+            const jsonView = (text?: string | null) => {
+              return (
+                <div>
+                  <pre>
+                    {text ? JSON.stringify(JSON.parse(text), null, 2) : ""}
+                  </pre>
+                </div>
+              );
+            };
+            const info = (headers: string, data: string) => {
+              return (
+                <div>
+                  <Text strong>Headers</Text>
+                  <div
+                    style={{
+                      backgroundColor: "rgb(246, 248, 250)",
+                      border: "1px solid gray",
+                      padding: 6,
+                    }}
+                  >
+                    {jsonView(headers)}
+                  </div>
+                  <Text strong>Payload</Text>
+                  <div
+                    style={{
+                      backgroundColor: "rgb(246, 248, 250)",
+                      border: "1px solid gray",
+                      padding: 6,
+                    }}
+                  >
+                    {jsonView(data)}
+                  </div>
+                </div>
+              );
+            };
+            return (
+              <>
+                <Tabs
+                  items={[
+                    {
+                      label: "request",
+                      key: "request",
+                      children: (
+                        <div>
+                          {info(entity.request_headers, entity.request_data)}
+                        </div>
+                      ),
+                    },
+                    {
+                      label: "response",
+                      key: "response",
+                      children: (
+                        <div>
+                          {info(
+                            entity.response_headers ?? "",
+                            entity.response_data ?? ""
+                          )}
+                        </div>
+                      ),
+                    },
+                  ]}
+                />
+              </>
+            );
+          },
+        },
+        avatar: {
+          render(_dom, entity) {
+            return (
+              <>
+                {entity.success ? (
+                  <CheckOutlined style={{ color: "green" }} />
+                ) : (
+                  <WarningOutlined color="error" />
+                )}
+              </>
+            );
+          },
+        },
+        actions: {
+          render: (_dom, entity) => {
+            const date = dayjs(entity.created_at).toLocaleString();
+            return <Text type="secondary">{date}</Text>;
+          },
+        },
+      }}
+      bordered
+      pagination={{
+        showQuickJumper: true,
+        showSizeChanger: true,
+        pageSize: 20,
+      }}
+      search={false}
+      options={{
+        search: true,
+      }}
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let url = `/api/v2/model-log?view=model&id=${modelId}`;
+        const offset = ((params.current ?? 1) - 1) * (params.pageSize ?? 20);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        url += params.keyword ? "&search=" + params.keyword : "";
+
+        console.info("ai model log api request", url);
+        const res = await get<IAiModelLogListResponse>(url);
+        console.info("ai model log api response", res);
+        return {
+          total: res.data.total,
+          succcess: res.ok,
+          data: res.data.rows,
+        };
+      }}
+    />
+  );
+};
+
+export default AiModelLogList;

+ 78 - 0
dashboard-v6/src/components/ai-model/AiTranslate.tsx

@@ -0,0 +1,78 @@
+import { Button, Typography } from "antd";
+import { useEffect, useState } from "react";
+import { LoadingOutlined } from "@ant-design/icons";
+
+import Marked from "../general/Marked";
+import { get } from "../../request";
+import type { IAiTranslateResponse } from "../../api/ai";
+
+const { Text } = Typography;
+
+interface IAiTranslateWidget {
+  origin?: string;
+  paragraph?: string;
+  autoLoad?: boolean;
+  trigger?: boolean;
+}
+
+const AiTranslate = ({
+  paragraph,
+  autoLoad = false,
+  trigger = false,
+}: IAiTranslateWidget) => {
+  const [loading, setLoading] = useState(false);
+  const [translation, setTranslation] = useState<string>();
+  const [error, setError] = useState<string>();
+  const url = "/api/v2/ai-translate";
+
+  useEffect(() => {
+    if (typeof paragraph === "undefined") {
+      return;
+    }
+    if (!autoLoad) {
+      return;
+    }
+    //onTranslatePara();
+  }, [paragraph, autoLoad]);
+
+  const onTranslatePara = () => {
+    const _url = `${url}/${paragraph}`;
+    console.info("api request", _url);
+    setLoading(true);
+    get<IAiTranslateResponse>(_url)
+      .then((json) => {
+        console.debug("api response", json);
+        if (json.ok) {
+          setTranslation(json.data.choices[0].message.content);
+        } else {
+          setError(json.message);
+        }
+      })
+      .finally(() => setLoading(false));
+  };
+
+  if (translation) {
+    return <Marked text={translation} />;
+  } else if (loading) {
+    return <LoadingOutlined />;
+  } else if (error) {
+    return (
+      <div>
+        <Text type="danger">{error}</Text>
+        <Button type="link" onClick={() => onTranslatePara()}>
+          再试一次
+        </Button>
+      </div>
+    );
+  } else if (trigger) {
+    return (
+      <Button type="link" onClick={() => onTranslatePara()}>
+        AI 翻译
+      </Button>
+    );
+  } else {
+    return <></>;
+  }
+};
+
+export default AiTranslate;

+ 145 - 0
dashboard-v6/src/components/ai-model/ModelSelector.tsx

@@ -0,0 +1,145 @@
+import { Button, Dropdown, Typography, Tag } from "antd";
+import {
+  DownOutlined,
+  ReloadOutlined,
+  GlobalOutlined,
+} from "@ant-design/icons";
+import type { MenuProps } from "antd";
+
+const { Text } = Typography;
+
+const ModelSelector = () => {
+  const modelItems: MenuProps["items"] = [
+    {
+      key: "auto",
+      label: (
+        <div className="py-2">
+          <div className="font-medium text-gray-900">Auto</div>
+        </div>
+      ),
+    },
+    {
+      key: "gpt-4o",
+      label: (
+        <div className="py-2">
+          <div className="font-medium text-gray-900">
+            GPT-4o<Tag>翻译</Tag>
+          </div>
+          <Text type="secondary">适用于大多数任务</Text>
+        </div>
+      ),
+    },
+    {
+      key: "o4-mini",
+      label: (
+        <div className="py-2">
+          <div className="font-medium text-gray-900">o4-mini</div>
+          <Text type="secondary">快速进行高级推理</Text>
+        </div>
+      ),
+    },
+    {
+      key: "gpt-4.1-mini",
+      label: (
+        <div className="py-2">
+          <div className="font-medium text-gray-900">GPT-4.1-mini</div>
+          <Text type="secondary">适合处理日常任务</Text>
+        </div>
+      ),
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "refresh",
+      label: (
+        <div className="py-2 flex items-center space-x-2 text-gray-700">
+          <ReloadOutlined />
+          <span>重试</span>
+          <div className="ml-auto">
+            <Text type="secondary">GPT-4o</Text>
+          </div>
+        </div>
+      ),
+    },
+    {
+      key: "search",
+      label: (
+        <div className="py-2 flex items-center space-x-2 text-gray-700">
+          <GlobalOutlined />
+          <span>搜索网页</span>
+        </div>
+      ),
+    },
+  ];
+
+  const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
+    if (key === "refresh") {
+      console.log("重试操作");
+      return;
+    }
+    if (key === "search") {
+      console.log("搜索网页");
+      return;
+    }
+  };
+
+  return (
+    <div className="bg-gray-50 min-h-screen">
+      <div className="max-w-md mx-auto">
+        <Dropdown
+          menu={{
+            items: modelItems,
+            onClick: handleMenuClick,
+            className: "w-64",
+          }}
+          trigger={["click"]}
+          placement="bottomLeft"
+          overlayClassName="model-selector-dropdown"
+        >
+          <Button
+            className="flex items-center justify-between w-48 h-12 px-4 border border-gray-300 rounded-lg bg-white hover:bg-gray-50 shadow-sm"
+            type="text"
+          >
+            <div className="flex items-center space-x-2">
+              <span className="font-medium text-gray-900 model_name">
+                {"AI"}
+              </span>
+              <DownOutlined className="text-gray-500 text-sm" />
+            </div>
+          </Button>
+        </Dropdown>
+      </div>
+
+      <style>{`
+        .model-selector-dropdown .ant-dropdown-menu {
+          padding: 8px;
+          border-radius: 12px;
+          box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
+          border: 1px solid #e5e7eb;
+        }
+
+        .model-selector-dropdown .ant-dropdown-menu-item {
+          padding: 0;
+          margin: 2px 0;
+          border-radius: 8px;
+        }
+
+        .model-selector-dropdown .ant-dropdown-menu-item:hover {
+          background-color: #f3f4f6;
+        }
+
+        .model-selector-dropdown .ant-dropdown-menu-item-selected {
+          background-color: #eff6ff;
+        }
+
+        .model-selector-dropdown .ant-dropdown-menu-divider {
+          margin: 8px 0;
+          background-color: #e5e7eb;
+        }
+      `}</style>
+    </div>
+  );
+};
+
+export default ModelSelector;

+ 72 - 0
dashboard-v6/src/components/article/ChapterToc.tsx

@@ -0,0 +1,72 @@
+import { useState, useEffect } from "react";
+
+import { get } from "../../request";
+
+import { Skeleton } from "antd";
+import type { Key } from "antd/lib/table/interface";
+import type { IChapterToc, IChapterTocListResponse } from "../../api/pali-text";
+import type { ListNodeData } from "./components/EditableTree";
+import TocTree from "./components/TocTree";
+
+interface IWidget {
+  book?: number;
+  para?: number;
+  maxLevel?: number;
+  onSelect?: (selectedKeys: Key[]) => void;
+  onData?: (data: IChapterToc[]) => void;
+}
+const ChapterTocWidget = ({
+  book,
+  para,
+  maxLevel = 8,
+  onSelect,
+  onData,
+}: IWidget) => {
+  const [tocList, setTocList] = useState<ListNodeData[]>([]);
+  const [loading, setLoading] = useState(true);
+  useEffect(() => {
+    const url = `/v2/chapter?view=toc&book=${book}&para=${para}`;
+    setLoading(true);
+    console.info("api request", url);
+    get<IChapterTocListResponse>(url)
+      .then((json) => {
+        console.info("api response", json);
+        const chapters = json.data.rows.filter(
+          (value) => value.level <= maxLevel
+        );
+        onData?.(chapters);
+        const toc = chapters.map((item, id) => {
+          return {
+            key: `${item.book}-${item.paragraph}`,
+            title: item.text,
+            level: item.level,
+          };
+        });
+        setTocList(toc);
+        if (chapters.length > 0) {
+          const path: string[] = [];
+          for (let index = chapters.length - 1; index >= 0; index--) {
+            const element = chapters[index];
+            if (element.book === book && para && element.paragraph <= para) {
+              path.push(`${element.book}-${element.paragraph}`);
+              break;
+            }
+          }
+        }
+      })
+      .finally(() => setLoading(false));
+  }, [book, maxLevel, para]);
+
+  return loading ? (
+    <Skeleton active />
+  ) : (
+    <TocTree
+      treeData={tocList}
+      onSelect={(selectedKeys: Key[]) => {
+        onSelect?.(selectedKeys);
+      }}
+    />
+  );
+};
+
+export default ChapterTocWidget;

+ 7 - 2
dashboard-v6/src/components/auth/SignInAvatar.tsx

@@ -30,9 +30,14 @@ const { Title, Paragraph, Text } = Typography;
 interface IWidget {
   placement?: TooltipPlacement;
   style?: React.CSSProperties;
+  hideName?: boolean;
 }
 
-const SignInAvatar = ({ style, placement = "bottomRight" }: IWidget) => {
+const SignInAvatar = ({
+  style,
+  placement = "bottomRight",
+  hideName = false,
+}: IWidget) => {
   const intl = useIntl();
   const navigate = useNavigate();
   const [settingOpen, setSettingOpen] = useState(false);
@@ -160,7 +165,7 @@ const SignInAvatar = ({ style, placement = "bottomRight" }: IWidget) => {
             >
               {user.nickName?.slice(0, 2)}
             </Avatar>
-            {user.nickName}
+            {!hideName && user.nickName}
           </span>
         </Popover>
         <SettingModal

+ 1 - 0
dashboard-v6/src/components/discussion/Discussion.tsx

@@ -50,6 +50,7 @@ const DiscussionWidget = ({
     }
   }, [showTopicId]);
 
+  
   useEffect(() => {
     setChildrenDrawer(false);
   }, [resId]);

+ 1 - 21
dashboard-v6/src/components/discussion/DiscussionCreate.tsx

@@ -164,27 +164,7 @@ const DiscussionCreateWidget = ({
                     id: "forms.fields.content.label",
                   })}
                   tooltip="可以直接粘贴屏幕截图"
-                >
-                  <ReactQuill
-                    theme="snow"
-                    style={{ height: 180 }}
-                    modules={{
-                      toolbar: [
-                        ["bold", "italic", "underline", "strike"],
-                        ["blockquote", "code-block"],
-                        [{ header: 1 }, { header: 2 }],
-                        [{ list: "ordered" }, { list: "bullet" }],
-                        [{ indent: "-1" }, { indent: "+1" }],
-                        [{ size: ["small", false, "large", "huge"] }],
-                        [{ header: [1, 2, 3, 4, 5, 6, false] }],
-                        ["link", "image", "video"],
-                        [{ color: [] }, { background: [] }],
-                        [{ font: [] }],
-                        [{ align: [] }],
-                      ],
-                    }}
-                  />
-                </Form.Item>
+                ></Form.Item>
               ) : contentType === "markdown" ? (
                 <Form.Item
                   name="content"

+ 4 - 3
dashboard-v6/src/components/discussion/DiscussionEdit.tsx

@@ -12,8 +12,8 @@ import type { IComment } from "../../api/discussion";
 
 interface IWidget {
   data: IComment;
-  onUpdated?: Function;
-  onClose?: Function;
+  onUpdated?: (value: IComment) => void;
+  onClose?: () => void;
 }
 const DiscussionEditWidget = ({ data, onUpdated, onClose }: IWidget) => {
   const intl = useIntl();
@@ -60,7 +60,7 @@ const DiscussionEditWidget = ({ data, onUpdated, onClose }: IWidget) => {
               if (json.ok) {
                 console.log(intl.formatMessage({ id: "flashes.success" }));
                 if (typeof onUpdated !== "undefined") {
-                  const newData = {
+                  const newData: IComment = {
                     id: json.data.id, //id未提供为新建
                     resId: json.data.res_id,
                     resType: json.data.res_type,
@@ -72,6 +72,7 @@ const DiscussionEditWidget = ({ data, onUpdated, onClose }: IWidget) => {
                     childrenCount: json.data.children_count,
                     createdAt: json.data.created_at,
                     updatedAt: json.data.updated_at,
+                    type: json.data.type,
                   };
                   onUpdated(newData);
                 }

+ 11 - 22
dashboard-v6/src/components/discussion/DiscussionItem.tsx

@@ -1,30 +1,29 @@
 import { Avatar } from "antd";
 import { useEffect, useState } from "react";
-import type { IUser } from "../auth/User";
+
 import DiscussionShow from "./DiscussionShow";
 import DiscussionEdit from "./DiscussionEdit";
-import type { TResType } from "./DiscussionListCard";
-import type { TDiscussionType } from "./Discussion";
+import type { IComment, TDiscussionType } from "../../api/discussion";
 
 interface IWidget {
   data: IComment;
   isFocus?: boolean;
   hideTitle?: boolean;
-  onSelect?: Function;
-  onCreated?: Function;
-  onDelete?: Function;
-  onReply?: Function;
-  onClose?: Function;
-  onConvert?: Function;
+  onSelect?: (
+    e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+    value: IComment
+  ) => void;
+  onCreated?: (e: IComment) => void;
+  onDelete?: () => void;
+  onClose?: (close: boolean) => void;
+  onConvert?: (value: TDiscussionType) => void;
 }
 const DiscussionItemWidget = ({
   data,
   isFocus = false,
   hideTitle = false,
   onSelect,
-  onCreated,
   onDelete,
-  onReply,
   onClose,
   onConvert,
 }: IWidget) => {
@@ -55,11 +54,6 @@ const DiscussionItemWidget = ({
               setCurrData(e);
               setEdit(false);
             }}
-            onCreated={(e: IComment) => {
-              if (typeof onCreated !== "undefined") {
-                onCreated(e);
-              }
-            }}
             onClose={() => setEdit(false)}
           />
         ) : (
@@ -74,16 +68,11 @@ const DiscussionItemWidget = ({
                 onSelect(e, currData);
               }
             }}
-            onDelete={(_id: string) => {
+            onDelete={() => {
               if (typeof onDelete !== "undefined") {
                 onDelete();
               }
             }}
-            onReply={() => {
-              if (typeof onReply !== "undefined") {
-                onReply(currData);
-              }
-            }}
             onClose={(value: boolean) => {
               if (typeof onClose !== "undefined") {
                 onClose(value);

+ 10 - 13
dashboard-v6/src/components/discussion/DiscussionList.tsx

@@ -1,19 +1,21 @@
 import { List } from "antd";
 
-import DiscussionItem, { type IComment } from "./DiscussionItem";
+import DiscussionItem from "./DiscussionItem";
+import type { IComment } from "../../api/discussion";
 
 interface IWidget {
   data: IComment[];
-  onSelect?: Function;
-  onDelete?: Function;
-  onReply?: Function;
-  onClose?: Function;
+  onSelect?: (
+    e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+    data: IComment
+  ) => void;
+  onDelete?: (id: string) => void;
+  onClose?: (item: IComment) => void;
 }
 const DiscussionListWidget = ({
   data,
   onSelect,
   onDelete,
-  onReply,
   onClose,
 }: IWidget) => {
   return (
@@ -39,13 +41,8 @@ const DiscussionListWidget = ({
               }
             }}
             onDelete={() => {
-              if (typeof onDelete !== "undefined") {
-                onDelete(item.id);
-              }
-            }}
-            onReply={() => {
-              if (typeof onReply !== "undefined") {
-                onReply(item);
+              if (item.id) {
+                onDelete?.(item.id);
               }
             }}
             onClose={() => {

+ 15 - 9
dashboard-v6/src/components/discussion/DiscussionListCard.tsx

@@ -4,21 +4,22 @@ import { LinkOutlined } from "@ant-design/icons";
 
 import { get } from "../../request";
 import type { ICommentListResponse } from "../../api/Comment";
-import type { IComment } from "./DiscussionItem";
+
 import type { IAnswerCount } from "./DiscussionDrawer";
 import { type ActionType, ProList } from "@ant-design/pro-components";
-import { renderBadge } from "../channel/ChannelTable";
 import DiscussionCreate from "./DiscussionCreate";
 import User from "../auth/User";
 import type { IArticleListResponse } from "../../api/article";
 import { useAppSelector } from "../../hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
 import { CommentOutlinedIcon, TemplateOutlinedIcon } from "../../assets/icon";
-import type { ISentenceResponse } from "../../api/Corpus";
-import type { TDiscussionType } from "./Discussion";
+
 import { courseInfo } from "../../reducers/current-course";
 import { courseUser } from "../../reducers/course-user";
 import TimeShow from "../general/TimeShow";
+import type { IComment, TDiscussionType, TResType } from "../../api/discussion";
+import type { ISentenceResponse } from "../../api/sentence";
+import StatusBadge from "../general/StatusBadge";
 
 const { Paragraph } = Typography;
 
@@ -47,7 +48,6 @@ const DiscussionListCardWidget = ({
   type = "discussion",
   pageSize = 10,
   onItemCountChange,
-  ___onReply,
   onReady,
 }: IWidget) => {
   const ref = useRef<ActionType | null>(null);
@@ -247,7 +247,7 @@ const DiscussionListCardWidget = ({
                   (value) =>
                     items.findIndex((old) => old.tplId === value.uid) === -1
                 )
-                .map((item, _index) => {
+                .map((item) => {
                   return {
                     tplId: item.uid,
                     resId: resId,
@@ -301,7 +301,10 @@ const DiscussionListCardWidget = ({
                 label: (
                   <span>
                     active
-                    {renderBadge(activeNumber, activeKey === "active")}
+                    <StatusBadge
+                      count={activeNumber}
+                      active={activeKey === "active"}
+                    />
                   </span>
                 ),
               },
@@ -310,7 +313,10 @@ const DiscussionListCardWidget = ({
                 label: (
                   <span>
                     close
-                    {renderBadge(closeNumber, activeKey === "close")}
+                    <StatusBadge
+                      count={closeNumber}
+                      active={activeKey === "close"}
+                    />
                   </span>
                 ),
               },
@@ -329,7 +335,7 @@ const DiscussionListCardWidget = ({
           resId={resId}
           resType={resType}
           type={type}
-          onCreated={(_e: IComment) => {
+          onCreated={() => {
             if (typeof onItemCountChange !== "undefined") {
               onItemCountChange(count + 1);
             }

+ 16 - 14
dashboard-v6/src/components/discussion/DiscussionShow.tsx

@@ -23,7 +23,6 @@ import {
 } from "@ant-design/icons";
 import type { MenuProps } from "antd";
 
-import type { IComment } from "./DiscussionItem";
 import TimeShow from "../general/TimeShow";
 import Marked from "../general/Marked";
 import { delete_, put } from "../../request";
@@ -31,21 +30,23 @@ import type { IDeleteResponse } from "../../api/article";
 import { fullUrl } from "../../utils";
 import type { ICommentRequest, ICommentResponse } from "../../api/Comment";
 import { useState } from "react";
-import MdView from "../template/MdView";
-import type { TDiscussionType } from "./Discussion";
-import { discussionCountUpgrade } from "./DiscussionCount";
+import type { IComment, TDiscussionType } from "../../api/discussion";
+import { discussionCountUpgrade } from "./utils";
+import MdView from "../general/MdView";
 
 const { Text } = Typography;
 
 interface IWidget {
   data: IComment;
   hideTitle?: boolean;
-  onEdit?: Function;
-  onSelect?: Function;
-  onDelete?: Function;
-  onReply?: Function;
-  onClose?: Function;
-  onConvert?: Function;
+  onEdit?: () => void;
+  onSelect?: (
+    e: React.MouseEvent<HTMLElement, MouseEvent>,
+    value: IComment
+  ) => void;
+  onDelete?: (id: string) => void;
+  onClose?: (value: boolean) => void;
+  onConvert?: (type: TDiscussionType) => void;
 }
 const DiscussionShowWidget = ({
   data,
@@ -53,7 +54,6 @@ const DiscussionShowWidget = ({
   onEdit,
   onSelect,
   onDelete,
-  ___onReply,
   onClose,
   onConvert,
 }: IWidget) => {
@@ -144,7 +144,7 @@ const DiscussionShowWidget = ({
   const onClick: MenuProps["onClick"] = (e) => {
     console.log("click ", e);
     switch (e.key) {
-      case "copy-link":
+      case "copy-link": {
         let url = `/discussion/topic/`;
         if (data.id) {
           if (data.parent) {
@@ -160,12 +160,14 @@ const DiscussionShowWidget = ({
           message.success("链接地址已经拷贝到剪贴板");
         });
         break;
-      case "copy-tpl":
+      }
+      case "copy-tpl": {
         const tpl = `{{qa|id=${data.id}|style=collapse}}`;
         navigator.clipboard.writeText(tpl).then(() => {
           notification.success({ message: "链接地址已经拷贝到剪贴板" });
         });
         break;
+      }
       case "edit":
         if (typeof onEdit !== "undefined") {
           onEdit();
@@ -281,7 +283,7 @@ const DiscussionShowWidget = ({
             strong
             onClick={(e) => {
               if (typeof onSelect !== "undefined") {
-                onSelect(e);
+                onSelect(e, data);
               }
             }}
           >

+ 11 - 10
dashboard-v6/src/components/discussion/DiscussionTopicChildren.tsx

@@ -1,18 +1,19 @@
 import { List, message, Skeleton } from "antd";
-import { IconType } from "antd/lib/notification";
+
 import { useEffect, useState } from "react";
 import { useIntl } from "react-intl";
 
 import { get } from "../../request";
 import type { ICommentListResponse } from "../../api/Comment";
+
+import DiscussionCreate from "./DiscussionCreate";
+import type { IComment, TResType } from "../../api/discussion";
 import type {
   ISentHistoryData,
   ISentHistoryListResponse,
-} from "../corpus/SentHistory";
-import SentHistoryGroup from "../corpus/SentHistoryGroup";
-import DiscussionCreate from "./DiscussionCreate";
-import DiscussionItem, { type IComment } from "./DiscussionItem";
-import type { TResType } from "./DiscussionListCard";
+} from "../../api/sentence-history";
+import DiscussionItem from "./DiscussionItem";
+import SentHistoryGroup from "../sentence-history.tsx/SentHistoryGroup";
 
 interface IItem {
   type: "comment" | "sent";
@@ -29,8 +30,8 @@ interface IWidget {
   topicId?: string;
   focus?: string;
   hideReply?: boolean;
-  onItemCountChange?: Function;
-  onTopicCreate?: Function;
+  onItemCountChange?: (total: number, parentId?: string | null) => void;
+  onTopicCreate?: (value: IComment) => void;
 }
 const DiscussionTopicChildrenWidget = ({
   topic,
@@ -96,7 +97,7 @@ const DiscussionTopicChildrenWidget = ({
     let currSent: ISentHistoryData[] = [];
     let currOldSent: string | undefined;
     let sentBegin = false;
-    mixItems.forEach((value, _index, _array) => {
+    mixItems.forEach((value) => {
       if (value.type === "comment") {
         if (sentBegin) {
           sentBegin = false;
@@ -247,7 +248,7 @@ const DiscussionTopicChildrenWidget = ({
               onItemCountChange(data.length + 1, value.parent);
             }
           }}
-          onTopicCreated={(value: IconType) => {
+          onTopicCreated={(value) => {
             if (typeof onTopicCreate !== "undefined") {
               onTopicCreate(value);
             }

+ 4 - 6
dashboard-v6/src/components/discussion/QaBox.tsx

@@ -2,12 +2,11 @@ import { useEffect, useState } from "react";
 import { ArrowLeftOutlined } from "@ant-design/icons";
 
 import DiscussionTopic from "./DiscussionTopic";
-import type { TResType } from "./DiscussionListCard"
-import type { IComment } from "./DiscussionItem"
 
 import { Button, Space, Typography } from "antd";
-import type { TDiscussionType } from "./Discussion"
+
 import QaList from "./QaList";
+import type { IComment, TResType } from "../../api/discussion";
 
 const { Text } = Typography;
 
@@ -16,7 +15,7 @@ interface IWidget {
   resType?: TResType;
   showTopicId?: string;
   focus?: string;
-  onTopicReady?: Function;
+  onTopicReady?: (value: IComment) => void;
 }
 
 const DiscussionWidget = ({
@@ -81,7 +80,7 @@ const DiscussionWidget = ({
             onTopicDelete={() => {
               setChildrenDrawer(false);
             }}
-            onConvert={(_value: TDiscussionType) => {
+            onConvert={() => {
               setChildrenDrawer(false);
             }}
           />
@@ -94,7 +93,6 @@ const DiscussionWidget = ({
             _e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
             comment: IComment
           ) => showChildrenDrawer(comment)}
-          onReply={(comment: IComment) => showChildrenDrawer(comment)}
         />
       )}
     </>

+ 10 - 9
dashboard-v6/src/components/discussion/QaList.tsx

@@ -1,16 +1,19 @@
 import { useEffect, useState } from "react";
-import type { TResType } from "./DiscussionListCard";
+
 import { get } from "../../request";
 import type { ICommentListResponse } from "../../api/Comment";
-import DiscussionItem, { type IComment } from "./DiscussionItem";
+import DiscussionItem from "./DiscussionItem";
+import type { IComment, TResType } from "../../api/discussion";
 
 interface IWidget {
   resId?: string;
   resType?: TResType;
-  onSelect?: Function;
-  onReply?: Function;
+  onSelect?: (
+    e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+    value: IComment
+  ) => void;
 }
-const QaListWidget = ({ resId, resType, onSelect, _____onReply }: IWidget) => {
+const QaListWidget = ({ resId, resType, onSelect }: IWidget) => {
   const [data, setData] = useState<IComment[]>();
 
   useEffect(() => {
@@ -23,7 +26,7 @@ const QaListWidget = ({ resId, resType, onSelect, _____onReply }: IWidget) => {
     get<ICommentListResponse>(url).then((json) => {
       if (json.ok) {
         console.debug("discussion api response", json);
-        const items: IComment[] = json.data.rows.map((item, _id) => {
+        const items: IComment[] = json.data.rows.map((item) => {
           return {
             id: item.id,
             resId: item.res_id,
@@ -60,9 +63,7 @@ const QaListWidget = ({ resId, resType, onSelect, _____onReply }: IWidget) => {
                   e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
                   value: IComment
                 ) => {
-                  if (typeof onSelect !== "undefined") {
-                    onSelect(e, value);
-                  }
+                  onSelect?.(e, value);
                 }}
               />
               <div

+ 59 - 0
dashboard-v6/src/components/discussion/hooks/useDiscussion.tsx

@@ -0,0 +1,59 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+import {
+  fetchCommentList,
+  type ICommentListResponse,
+  type IFetchCommentListParams,
+} from "../../../api/Comment";
+
+interface IUseTaskLogReturn {
+  data: ICommentListResponse["data"] | null;
+  loading: boolean;
+  refresh: () => void;
+}
+
+export const useDiscussion = (
+  taskId?: string,
+  params: IFetchCommentListParams = {}
+): IUseTaskLogReturn => {
+  const [data, setData] = useState<ICommentListResponse["data"] | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [tick, setTick] = useState(0);
+
+  const paramsKey = JSON.stringify(params);
+  const paramsRef = useRef<IFetchCommentListParams>(params);
+  useEffect(() => {
+    paramsRef.current = params;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [paramsKey]);
+
+  const refresh = useCallback(() => setTick((t) => t + 1), []);
+
+  useEffect(() => {
+    if (!taskId) return;
+
+    let active = true;
+
+    const fetchData = async () => {
+      setLoading(true);
+      try {
+        const res = await fetchCommentList(taskId, paramsRef.current);
+        if (!active) return;
+        if (res.ok) {
+          setData(res.data);
+        }
+      } catch (e) {
+        console.error("tasklog fetch", e);
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [taskId, paramsKey, tick]);
+
+  return { data, loading, refresh };
+};

+ 37 - 0
dashboard-v6/src/components/like/EditableAvatarGroup.tsx

@@ -0,0 +1,37 @@
+import User from "../auth/User";
+import { Popover, Space } from "antd";
+import WatchList from "./WatchList";
+import { type IDataType, WatchAddButton } from "./WatchAdd";
+import type { IUser } from "../../api/Auth";
+
+interface IWidget {
+  users?: IUser[];
+  onFinish?: ((formData: IDataType) => Promise<boolean | void>) | undefined;
+  onDelete?: ((user: IUser) => Promise<boolean | void>) | undefined;
+}
+const EditableAvatarGroup = ({ users, onFinish, onDelete }: IWidget) => {
+  return (
+    <Space>
+      <Popover
+        trigger={"click"}
+        content={<WatchList data={users} onDelete={onDelete} />}
+      >
+        <div>
+          {users?.map((item, id) => {
+            return (
+              <span
+                key={id}
+                style={{ display: "inline-block", marginRight: -8 }}
+              >
+                <User {...item} showName={false} hidePopover />
+              </span>
+            );
+          })}
+        </div>
+      </Popover>
+      <WatchAddButton data={users} onFinish={onFinish} onDelete={onDelete} />
+    </Space>
+  );
+};
+
+export default EditableAvatarGroup;

+ 136 - 0
dashboard-v6/src/components/like/Like.tsx

@@ -0,0 +1,136 @@
+import { useEffect, useState } from "react";
+import { Button, Space, Tooltip } from "antd";
+import {
+  LikeOutlined,
+  LikeFilled,
+  StarOutlined,
+  StarFilled,
+  EyeOutlined,
+  EyeFilled,
+} from "@ant-design/icons";
+
+import { delete_, get, post } from "../../request";
+import type {
+  ILikeCount,
+  ILikeCountListResponse,
+  ILikeCountResponse,
+  ILikeRequest,
+  TLikeType,
+} from "../../api/like";
+
+interface IWidget {
+  resId?: string;
+  resType?: string;
+}
+const Like = ({ resId, resType }: IWidget) => {
+  const [like, setLike] = useState<ILikeCount>();
+  const [favorite, setFavorite] = useState<ILikeCount>();
+  const [watch, setWatch] = useState<ILikeCount>();
+
+  useEffect(() => {
+    if (!resId) {
+      return;
+    }
+    const url = `/api/v2/like?view=count&target_id=${resId}`;
+    console.info("api request", url);
+    get<ILikeCountListResponse>(url).then((json) => {
+      console.info("api response", json);
+      if (json.ok) {
+        setLike(json.data.find((value) => value.type === "like"));
+        setFavorite(json.data.find((value) => value.type === "favorite"));
+        setWatch(json.data.find((value) => value.type === "watch"));
+      }
+    });
+  }, [resId]);
+
+  const setStatus = (data: ILikeCount) => {
+    switch (data.type) {
+      case "like":
+        setLike(data);
+        break;
+      case "favorite":
+        setFavorite(data);
+        break;
+      case "watch":
+        setWatch(data);
+        break;
+    }
+  };
+  const add = (type: TLikeType) => {
+    if (!resId || !resType) {
+      return;
+    }
+    const url = `/api/v2/like`;
+    post<ILikeRequest, ILikeCountResponse>(url, {
+      type: type,
+      target_id: resId,
+      target_type: resType,
+    }).then((json) => {
+      if (json.ok) {
+        setStatus(json.data);
+      }
+    });
+  };
+
+  const remove = (id?: string) => {
+    if (!resId || !resType || !id) {
+      return;
+    }
+    const url = `/api/v2/like/${id}`;
+    console.info("api request", url);
+    delete_<ILikeCountResponse>(url).then((json) => {
+      console.info("api response", json);
+      if (json.ok) {
+        setStatus(json.data);
+      }
+    });
+  };
+
+  return (
+    <Space>
+      <Button
+        type="text"
+        icon={like?.selected ? <LikeFilled /> : <LikeOutlined />}
+        onClick={() => {
+          if (like?.selected) {
+            remove(like.my_id);
+          } else {
+            add("like");
+          }
+        }}
+      >
+        {like?.count === 0 ? <></> : like?.count}
+      </Button>
+      <Button
+        type="text"
+        icon={favorite?.selected ? <StarFilled /> : <StarOutlined />}
+        onClick={() => {
+          if (favorite?.selected) {
+            remove(favorite.my_id);
+          } else {
+            add("favorite");
+          }
+        }}
+      >
+        {favorite?.count === 0 ? <></> : favorite?.count}
+      </Button>
+      <Tooltip title="关注">
+        <Button
+          type="text"
+          icon={watch?.selected ? <EyeFilled /> : <EyeOutlined />}
+          onClick={() => {
+            if (watch?.selected) {
+              remove(watch.my_id);
+            } else {
+              add("watch");
+            }
+          }}
+        >
+          {watch?.count === 0 ? <></> : watch?.count}
+        </Button>
+      </Tooltip>
+    </Space>
+  );
+};
+
+export default Like;

+ 67 - 0
dashboard-v6/src/components/like/LikeAvatar.tsx

@@ -0,0 +1,67 @@
+import { useEffect, useState } from "react";
+
+import type {
+  ILikeListResponse,
+  ILikeRequest,
+  ILikeResponse,
+  TLikeType,
+} from "../../api/like";
+import { get, post } from "../../request";
+
+import type { IDataType } from "./WatchAdd";
+import EditableAvatarGroup from "./EditableAvatarGroup";
+import type { IUser } from "../../api/Auth";
+
+interface IWidget {
+  resId?: string;
+  resType?: string;
+  type?: TLikeType;
+}
+const LikeAvatar = ({ resId, resType, type }: IWidget) => {
+  const [data, setData] = useState<IUser[]>();
+  useEffect(() => {
+    if (!resId) {
+      return;
+    }
+    const url = `/api/v2/like?view=target&target_id=${resId}&type=${type}`;
+    console.info("api request", url);
+    get<ILikeListResponse>(url).then((json) => {
+      console.info("api response", json);
+      if (json.ok) {
+        setData(json.data.rows.map((item) => item.user));
+      }
+    });
+  }, [resId, type]);
+  return (
+    <>
+      <EditableAvatarGroup
+        users={data}
+        onFinish={async (values: IDataType) => {
+          if (!resId || !resType) {
+            console.error("no resId or resType", resId, resType);
+            return;
+          }
+          const update: ILikeRequest = {
+            type: "watch",
+            target_id: resId,
+            target_type: resType,
+            user_id: values.user_id,
+          };
+          const url = `/api/v2/like`;
+          console.info("watch add api request", url, values);
+          const add = await post<ILikeRequest, ILikeResponse>(url, update);
+          console.debug("watch add api response", add);
+          setData((origin) => {
+            if (origin) {
+              return [...origin, add.data.user];
+            } else {
+              return [add.data.user];
+            }
+          });
+        }}
+      />
+    </>
+  );
+};
+
+export default LikeAvatar;

+ 75 - 0
dashboard-v6/src/components/like/WatchAdd.tsx

@@ -0,0 +1,75 @@
+import { useRef } from "react";
+import {
+  ProForm,
+  ProFormDependency,
+  type ProFormInstance,
+  ProFormSelect,
+} from "@ant-design/pro-components";
+import { PlusOutlined } from "@ant-design/icons";
+
+import { Button, Divider, Popover } from "antd";
+import WatchList from "./WatchList";
+
+import { useIntl } from "react-intl";
+import type { IUser } from "../../api/Auth";
+import UserSelect from "../users/UserSelect";
+import AiAssistantSelect from "../ai-model/AiAssistantSelect";
+
+export interface IDataType {
+  user_type?: "user" | "ai-assistant";
+  user_id?: string;
+}
+
+interface IWidget {
+  data?: IUser[];
+  onFinish?: ((formData: IDataType) => Promise<boolean | void>) | undefined;
+  onDelete?: ((user: IUser) => Promise<boolean | void>) | undefined;
+}
+
+export const WatchAddButton = ({ data, onFinish, onDelete }: IWidget) => {
+  return (
+    <Popover
+      trigger={"click"}
+      content={<WatchAdd data={data} onFinish={onFinish} onDelete={onDelete} />}
+    >
+      <Button type="text" icon={<PlusOutlined />} />
+    </Popover>
+  );
+};
+const WatchAdd = ({ data, onFinish, onDelete }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+  return (
+    <div>
+      <ProForm<IDataType> formRef={formRef} onFinish={onFinish}>
+        <ProForm.Group>
+          <ProFormSelect
+            options={[
+              { label: "用户", value: "user" },
+              {
+                label: intl.formatMessage({ id: "labels.ai-assistant" }),
+                value: "ai-assistant",
+              },
+            ]}
+            width="xs"
+            name="userType"
+            label={"用户类型"}
+          />
+          <ProFormDependency name={["userType"]}>
+            {({ userType }) => {
+              if (userType === "user") {
+                return <UserSelect name="user_id" multiple={false} />;
+              } else {
+                return <AiAssistantSelect name="user_id" multiple={false} />;
+              }
+            }}
+          </ProFormDependency>
+        </ProForm.Group>
+      </ProForm>
+      <Divider />
+      <WatchList data={data} onDelete={onDelete} />
+    </div>
+  );
+};
+
+export default WatchAdd;

+ 45 - 0
dashboard-v6/src/components/like/WatchList.tsx

@@ -0,0 +1,45 @@
+import { Button, List } from "antd";
+import { DeleteOutlined } from "@ant-design/icons";
+
+import User from "../auth/User";
+import { useState } from "react";
+import type { IUser } from "../../api/Auth";
+
+interface IWidget {
+  data?: IUser[];
+  onDelete?: ((user: IUser) => Promise<boolean | void>) | undefined;
+}
+const WatchList = ({ data, onDelete }: IWidget) => {
+  const [del, setDel] = useState<string>();
+  return (
+    <List
+      dataSource={data}
+      renderItem={(item) => (
+        <List.Item
+          extra={[
+            <Button
+              type="text"
+              danger
+              loading={item.id === del}
+              icon={<DeleteOutlined />}
+              onClick={() => {
+                console.debug("delete", item);
+                if (typeof onDelete !== "undefined") {
+                  console.debug("delete", item);
+                  setDel(item.id);
+                  onDelete(item).finally(() => {
+                    setDel(undefined);
+                  });
+                }
+              }}
+            />,
+          ]}
+        >
+          <User {...item} />
+        </List.Item>
+      )}
+    />
+  );
+};
+
+export default WatchList;

+ 24 - 11
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -52,6 +52,7 @@ function useCurrentRouteId(): string | undefined {
 
 function matchActive(routeId: string | undefined, active?: string | string[]) {
   if (!routeId || !active) return false;
+  if (routeId === active) return true;
 
   const list = Array.isArray(active) ? active : [active];
 
@@ -65,12 +66,13 @@ function findSelectedKey(
   routeId?: string
 ): string | undefined {
   for (const item of items) {
-    if (matchActive(routeId, item.activeId)) return item.key;
-
+    // ✅ 先递归子级
     if (item.children) {
       const k = findSelectedKey(item.children, routeId);
       if (k) return k;
     }
+    // 子级没找到,再匹配自身
+    if (matchActive(routeId, item.activeId)) return item.key;
   }
 }
 
@@ -82,10 +84,7 @@ function findOpenKeys(
   parents: string[] = []
 ): string[] {
   for (const item of items) {
-    if (matchActive(routeId, item.activeId)) {
-      return parents;
-    }
-
+    // ✅ 先递归子级
     if (item.children) {
       const found = findOpenKeys(item.children, routeId, [
         ...parents,
@@ -93,6 +92,10 @@ function findOpenKeys(
       ]);
       if (found.length) return found;
     }
+    // 子级没找到,再匹配自身(叶子节点命中,返回父级路径)
+    if (matchActive(routeId, item.activeId)) {
+      return parents;
+    }
   }
   return [];
 }
@@ -218,14 +221,24 @@ const Widget = ({ onSearch }: Props) => {
           activeId: "workspace.task.pending",
         },
         {
-          key: "/workspace/task/to-do-list",
+          key: "/workspace/task/hall",
+          label: "Task hall",
+          activeId: "workspace.task.hall",
+        },
+        {
+          key: "/workspace/task/list",
           label: "To-Do List",
-          activeId: "workspace.task.todo",
+          activeId: "workspace.task.list",
+        },
+        {
+          key: "/workspace/task/project",
+          label: "projects",
+          activeId: "workspace.task.project",
         },
         {
-          key: "/workspace/task/hell",
-          label: "Task Hell",
-          activeId: "workspace.task.hell",
+          key: "/workspace/task/workflows",
+          label: "workflows",
+          activeId: "workspace.task.workflows",
         },
       ],
     },

+ 9 - 0
dashboard-v6/src/components/setting/SettingModal.tsx

@@ -5,6 +5,9 @@ import { useIntl } from "react-intl";
 import SettingNissaya from "./SettingNissaya";
 import SettingDict from "./SettingDict";
 import SettingEditor from "./SettingEditor";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import AiModelList from "../ai-model/AiModelList";
 interface IWidget {
   trigger?: React.ReactNode;
   open?: boolean;
@@ -13,6 +16,7 @@ interface IWidget {
 const SettingModal = ({ trigger, open, onClose }: IWidget) => {
   const [isInnerOpen, setIsInnerOpen] = useState(false);
   const intl = useIntl();
+  const currUser = useAppSelector(currentUser);
 
   const isModalOpen = open ?? isInnerOpen;
 
@@ -58,6 +62,11 @@ const SettingModal = ({ trigger, open, onClose }: IWidget) => {
               key: "dict",
               children: <SettingDict />,
             },
+            {
+              label: "model",
+              key: "model",
+              children: <AiModelList studioName={currUser?.realName} />,
+            },
           ]}
         />
       </Modal>

+ 88 - 0
dashboard-v6/src/components/task/Assignees.tsx

@@ -0,0 +1,88 @@
+import { message } from "antd";
+
+import type {
+  ITaskData,
+  ITaskResponse,
+  ITaskUpdateRequest,
+} from "../../api/task";
+
+import EditableAvatarGroup from "../like/EditableAvatarGroup";
+
+import type { IDataType } from "../like/WatchAdd";
+import { patch } from "../../request";
+import type { IUser } from "../../api/Auth";
+
+interface IWidget {
+  task?: ITaskData;
+  onChange?: (data: ITaskData[]) => void;
+}
+const Assignees = ({ task, onChange }: IWidget) => {
+  return (
+    <>
+      <EditableAvatarGroup
+        users={task?.assignees ?? undefined}
+        onDelete={async (user: IUser) => {
+          if (!task) {
+            console.error("no task");
+            return;
+          }
+          let users: string[] = [];
+          if (task.assignees_id) {
+            users = task.assignees_id.filter((value) => value !== user.id);
+          }
+          const setting: ITaskUpdateRequest = {
+            id: task.id,
+            studio_name: "",
+            assignees_id: users,
+          };
+          const url = `/api/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?.([json.data]);
+              } else {
+                message.error(json.message);
+              }
+            }
+          );
+        }}
+        onFinish={async (values: IDataType) => {
+          if (!task) {
+            console.error("no task");
+            return;
+          }
+          let users: string[] = [];
+          if (task.assignees_id) {
+            users = task.assignees_id;
+          }
+          if (values.user_id) {
+            users = [...users, values.user_id];
+          }
+          const setting: ITaskUpdateRequest = {
+            id: task.id,
+            studio_name: "",
+            assignees_id: users,
+          };
+          const url = `/api/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?.([json.data]);
+              } else {
+                message.error(json.message);
+              }
+            }
+          );
+        }}
+      />
+    </>
+  );
+};
+
+export default Assignees;

+ 70 - 0
dashboard-v6/src/components/task/Category.tsx

@@ -0,0 +1,70 @@
+import { Dropdown, type MenuProps, message, Tag, Typography } from "antd";
+import {
+  ATaskCategory,
+  type ITaskData,
+  type ITaskResponse,
+  type ITaskUpdateRequest,
+  type TTaskCategory,
+} from "../../api/task";
+import { useIntl } from "react-intl";
+import { patch } from "../../request";
+
+const { Text } = Typography;
+
+interface IWidget {
+  task?: ITaskData;
+  onChange?: (data: ITaskData[]) => void;
+}
+const Category = ({ task, onChange }: IWidget) => {
+  const intl = useIntl();
+
+  const placeholder = intl.formatMessage({ id: "labels.task.category" });
+
+  const items: MenuProps["items"] = ATaskCategory.map((item) => {
+    const value = {
+      key: item,
+      label: intl.formatMessage({
+        id: `labels.task.category.${item}`,
+      }),
+    };
+    return value;
+  });
+
+  const onClick: MenuProps["onClick"] = (e) => {
+    if (!task) {
+      console.error("no task");
+      return;
+    }
+
+    const setting: ITaskUpdateRequest = {
+      id: task.id,
+      studio_name: "",
+      category: e.key as TTaskCategory,
+    };
+    const url = `/api/v2/task/${task.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?.([json.data]);
+      } else {
+        message.error(json.message);
+      }
+    });
+  };
+  return (
+    <Dropdown menu={{ items, onClick }}>
+      <Tag>
+        <Text type={task?.category ? "success" : "secondary"}>
+          {intl.formatMessage({
+            id: `labels.task.category.${task?.category}`,
+            defaultMessage: placeholder,
+          })}
+        </Text>
+      </Tag>
+    </Dropdown>
+  );
+};
+
+export default Category;

+ 114 - 0
dashboard-v6/src/components/task/Description.tsx

@@ -0,0 +1,114 @@
+import { useState } from "react";
+import { Button, message, Space, Typography } from "antd";
+import { EditOutlined, SaveOutlined } from "@ant-design/icons";
+
+import type {
+  ITaskData,
+  ITaskResponse,
+  ITaskUpdateRequest,
+} from "../../api/task";
+
+import MDEditor from "@uiw/react-md-editor";
+import "../article/article.css";
+import { patch } from "../../request";
+
+import { useIntl } from "react-intl";
+import MdView from "../general/MdView";
+const { Text } = Typography;
+
+interface IWidget {
+  task?: ITaskData;
+  onChange?: (data: ITaskData[]) => void;
+  onDiscussion?: () => void;
+}
+const Description = ({ task, onChange, onDiscussion }: IWidget) => {
+  const intl = useIntl();
+
+  const [mode, setMode] = useState<"read" | "edit">("read");
+  const [innerContent, setInnerContent] = useState(task?.description);
+  const [loading, setLoading] = useState(false);
+
+  const content = task?.description ?? innerContent;
+
+  return (
+    <div>
+      <div
+        style={{
+          display: "flex",
+          justifyContent: "space-between",
+          padding: 8,
+        }}
+      >
+        <span>
+          <Text strong>任务描述</Text>
+        </span>
+        <span>
+          {mode === "read" ? (
+            <Space>
+              <Button key={1} onClick={onDiscussion}>
+                {intl.formatMessage({ id: "buttons.discussion" })}
+              </Button>
+              <Button
+                key={2}
+                ghost
+                type="primary"
+                icon={<EditOutlined />}
+                onClick={() => setMode("edit")}
+              >
+                {intl.formatMessage({ id: "buttons.edit" })}
+              </Button>
+            </Space>
+          ) : (
+            <Button
+              ghost
+              type="primary"
+              icon={<SaveOutlined />}
+              loading={loading}
+              onClick={() => {
+                if (!task) {
+                  return;
+                }
+                const setting: ITaskUpdateRequest = {
+                  id: task.id,
+                  studio_name: "",
+                  description: content,
+                };
+                const url = `/api/v2/task/${setting.id}`;
+                console.info("api request", url, setting);
+                setLoading(true);
+                patch<ITaskUpdateRequest, ITaskResponse>(url, setting)
+                  .then((json) => {
+                    console.info("api response", json);
+                    if (json.ok) {
+                      message.success("Success");
+                      setMode("read");
+                      onChange?.([json.data]);
+                    } else {
+                      message.error(json.message);
+                    }
+                  })
+                  .finally(() => setLoading(false));
+              }}
+            >
+              {intl.formatMessage({ id: "buttons.save" })}
+            </Button>
+          )}
+        </span>
+      </div>
+      {mode === "read" ? (
+        <MdView html={task?.html} />
+      ) : (
+        <MDEditor
+          className="pcd_md_editor"
+          value={content ?? undefined}
+          onChange={(value) => setInnerContent(value)}
+          height={450}
+          minHeight={200}
+          style={{ width: "100%" }}
+        />
+      )}
+    </div>
+  );
+};
+
+export default Description;

+ 26 - 0
dashboard-v6/src/components/task/Executors.tsx

@@ -0,0 +1,26 @@
+import type { IUser } from "../../api/Auth";
+import type { ITaskData } from "../../api/task";
+import User from "../auth/User";
+
+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 (
+    <div>
+      {executors.map((item, id) => {
+        return <User {...item} key={id} showName={executors.length === 1} />;
+      })}
+    </div>
+  );
+};
+
+export default Executors;

+ 204 - 0
dashboard-v6/src/components/task/Filter.tsx

@@ -0,0 +1,204 @@
+import { Button, Popover, Typography } from "antd";
+import type { IFilter } from "./TaskList";
+import { useRef, useState } from "react";
+import { useIntl } from "react-intl";
+
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormSelect,
+} from "@ant-design/pro-components";
+import { DeleteOutlined, FilterOutlined } from "@ant-design/icons";
+import UserSelect from "../users/UserSelect";
+
+const { Text } = Typography;
+
+interface IProps {
+  item: IFilter;
+  sn: number;
+  onRemove?: () => void;
+}
+const FilterItem = ({ item, sn, onRemove }: IProps) => {
+  const intl = useIntl();
+  const [operator, setOperator] = useState<string | null>(item.operator);
+  return (
+    <ProForm.Group>
+      <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" }),
+          },
+          {
+            value: "prev_executors_id",
+            label: intl.formatMessage({ id: "labels.task.prev.executors" }),
+          },
+        ]}
+      />
+      <ProFormSelect
+        initialValue={item.operator}
+        fieldProps={{
+          value: operator,
+          onChange(value) {
+            setOperator(value);
+          },
+        }}
+        name={`operator_${sn}`}
+        style={{ width: 120 }}
+        options={[
+          "includes",
+          "not-includes",
+          "equal",
+          "not-equal",
+          "null",
+          "not-null",
+        ].map((item) => {
+          return {
+            value: item,
+            label: intl.formatMessage({ id: `labels.filters.${item}` }),
+          };
+        })}
+      />
+      <UserSelect
+        name={"value_" + sn}
+        multiple={true}
+        initialValue={item.value}
+        required={false}
+        hiddenTitle
+        hidden={operator === "null" || operator === "not-null"}
+      />
+      <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 | undefined>(undefined);
+  return (
+    <Popover
+      placement="bottomLeft"
+      trigger={"click"}
+      arrow={{ pointAtCenter: true }}
+      title={intl.formatMessage({ id: "labels.filter" })}
+      content={
+        <div style={{ width: 780 }}>
+          <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;
+              const newValue: IFilter[] = [];
+              while (counter < Object.keys(value).length) {
+                const field = `field_${counter}`;
+                const field2 = `operator_${counter}`;
+                const field3 = `value_${counter}`;
+                if (Object.hasOwn(value, field)) {
+                  newValue.push({
+                    field: value[field],
+                    operator: value[field2],
+                    value: value[field3],
+                  });
+                }
+                counter++;
+              }
+              console.log(newValue);
+              if (onChange) {
+                onChange(newValue);
+              }
+            }}
+          >
+            <ProForm.Group>
+              <Text type="secondary">{"满足以下"}</Text>
+              <ProFormSelect
+                name={"operator"}
+                initialValue={"and"}
+                style={{ width: 120 }}
+                options={["and", "or"].map((item) => {
+                  return {
+                    value: item,
+                    label: intl.formatMessage({ id: `labels.filters.${item}` }),
+                  };
+                })}
+              />
+            </ProForm.Group>
+            <div
+              style={{
+                border: "1px solid rgba(128, 128, 128, 0.5)",
+                borderRadius: 6,
+                marginBottom: 8,
+                padding: "8px 0 8px 8px",
+              }}
+            >
+              {filterList.map((item, id) => {
+                return (
+                  <FilterItem
+                    item={item}
+                    key={id}
+                    sn={id}
+                    onRemove={() => {
+                      setFilterList((origin) => {
+                        return origin.filter(
+                          (_value, index: number) => index !== id
+                        );
+                      });
+                    }}
+                  />
+                );
+              })}
+            </div>
+          </ProForm>
+        </div>
+      }
+    >
+      <Button
+        type={filterList.length === 0 ? "text" : "primary"}
+        icon={<FilterOutlined />}
+      >
+        筛选 {filterList.length}
+      </Button>
+    </Popover>
+  );
+};
+
+export default Filter;

+ 155 - 0
dashboard-v6/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;

+ 52 - 0
dashboard-v6/src/components/task/Options.tsx

@@ -0,0 +1,52 @@
+import { Button, Dropdown, type MenuProps } from "antd";
+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 }) => {
+    onChange?.(key);
+    setCurrKey(key);
+  };
+
+  const menuItems: MenuProps["items"] = items.map(({ key, label }) => ({
+    key,
+    label,
+  }));
+
+  return (
+    <Dropdown
+      menu={{
+        items: menuItems,
+        onClick,
+        selectable: true,
+        defaultSelectedKeys: [currKey],
+      }}
+      trigger={["click"]}
+      placement="bottomLeft"
+    >
+      <Button type="text" icon={icon}>
+        {text}
+        {currValue}
+      </Button>
+    </Dropdown>
+  );
+};
+
+export default Options;

+ 32 - 0
dashboard-v6/src/components/task/PlanDate.tsx

@@ -0,0 +1,32 @@
+import { DatePicker, Space, Switch } from "antd";
+import dayjs from "dayjs";
+import { useState } from "react";
+const PlanDate = () => {
+  const [time, setTime] = useState(false);
+  return (
+    <DatePicker.RangePicker
+      placeholder={["", "截止日期"]}
+      defaultValue={[dayjs(), dayjs()]}
+      showTime={time}
+      bordered={false}
+      renderExtraFooter={() => {
+        return (
+          <Space>
+            {"具体时间"}
+            <Switch
+              onChange={(checked) => {
+                setTime(checked);
+              }}
+            />
+          </Space>
+        );
+      }}
+      allowEmpty={[true, false]}
+      onChange={(date, dateString) => {
+        console.log(date, dateString);
+      }}
+    />
+  );
+};
+
+export default PlanDate;

+ 139 - 0
dashboard-v6/src/components/task/PreTask.tsx

@@ -0,0 +1,139 @@
+import { Button, List, Popover, Switch, Tag, Typography } from "antd";
+import type { ITaskData, ITaskListResponse } from "../../api/task";
+import { get } from "../../request";
+import { useEffect, useState } from "react";
+import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
+import type { TRelation } from "./TaskEditButton";
+
+const { Text } = Typography;
+
+interface IProTaskListProps {
+  task?: ITaskData;
+  type: TRelation;
+  onClick?: (data?: ITaskData | null) => void;
+  onClose?: () => void;
+  onChange?: (data: ITaskData, has: boolean) => void;
+}
+const ProTaskList = ({
+  task,
+  type,
+  onClick,
+  onClose,
+  onChange,
+}: IProTaskListProps) => {
+  const [res, setRes] = useState<ITaskData[]>();
+  useEffect(() => {
+    const url = `/api/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) => {
+        let checked = false;
+        if (type === "pre") {
+          checked =
+            task?.pre_task?.find((value) => value.id === item.id) !== undefined;
+        } else {
+          checked =
+            task?.next_task?.find((value) => value.id === item.id) !==
+            undefined;
+        }
+        return (
+          <List.Item
+            actions={[
+              <Switch
+                size="small"
+                checked={checked}
+                onChange={(checked) => {
+                  onChange?.(item, checked);
+                }}
+              />,
+            ]}
+            onClick={() => {
+              onClick?.(item);
+            }}
+          >
+            {item.title}
+          </List.Item>
+        );
+      }}
+    />
+  );
+};
+
+interface IWidget {
+  task?: ITaskData;
+  open?: boolean;
+  type: TRelation;
+  onClick?: (data?: ITaskData | null) => void;
+  onTagClick?: () => void;
+  onClose?: () => void;
+  onChange?: (data: ITaskData, has: boolean) => void;
+}
+const PreTask = ({
+  task,
+  type,
+  open = false,
+  onClick,
+  onClose,
+  onTagClick,
+  onChange,
+}: 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 />} onClick={onTagClick}>
+        {task?.pre_task ? `${task?.pre_task?.length} 个前置任务` : ""}
+      </Tag>
+    );
+  } else if (nextTaskShow && type === "next") {
+    tag = (
+      <Tag color="warning" icon={<ArrowRightOutlined />} onClick={onTagClick}>
+        {task?.next_task ? `阻塞 ${task?.next_task?.length} 个任务` : ""}
+      </Tag>
+    );
+  }
+  return (
+    <Popover
+      trigger="click"
+      open={open}
+      content={
+        <div style={{ width: 400 }}>
+          <ProTaskList
+            type={type}
+            task={task}
+            onClick={onClick}
+            onClose={onClose}
+            onChange={onChange}
+          />
+        </div>
+      }
+    >
+      {tag}
+    </Popover>
+  );
+};
+export default PreTask;

+ 307 - 0
dashboard-v6/src/components/task/Project.tsx

@@ -0,0 +1,307 @@
+import type { ActionType, ProColumns } from "@ant-design/pro-components";
+import { EditableProTable, useRefFunction } from "@ant-design/pro-components";
+import { Button, Dropdown, Form, Space, Typography } from "antd";
+import React, { useEffect, useRef, useState } from "react";
+import { useIntl } from "react-intl";
+
+import ProjectEditDrawer from "./ProjectEditDrawer";
+import type {
+  IProjectData,
+  IProjectListResponse,
+  IProjectResponse,
+  IProjectUpdateRequest,
+} from "../../api/task";
+
+import { get, post } from "../../request";
+import { TaskBuilderProjectsModal } from "./TaskBuilderProjects";
+
+const { Text } = Typography;
+function generateUUID() {
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
+    const 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 [curr, setCurr] = useState<string>();
+  const actionRef = useRef<ActionType | null>(null);
+  const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
+  const [dataSource, setDataSource] = useState<readonly IProjectData[]>([]);
+  const [buildProjectsOpen, setBuildProjectsOpen] = useState(false);
+
+  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) => {
+        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) => [
+        <Button
+          size="small"
+          type="link"
+          key="editable"
+          onClick={() => {
+            setEditId(record.id);
+            setOpen(true);
+          }}
+        >
+          编辑
+        </Button>,
+        record.type === "workflow" ? (
+          <></>
+        ) : (
+          <EditableProTable.RecordCreator
+            key="copy"
+            parentKey={record.id}
+            record={{
+              id: generateUUID(),
+              parent_id: record.id,
+            }}
+          >
+            <Dropdown.Button
+              size="small"
+              type="link"
+              menu={{
+                items: [
+                  {
+                    key: "multi",
+                    label: "批量添加",
+                  },
+                ],
+                onClick: (e) => {
+                  switch (e.key) {
+                    case "multi":
+                      setCurr(record.id);
+                      setBuildProjectsOpen(true);
+                      break;
+                    default:
+                      break;
+                  }
+                },
+              }}
+            >
+              插入子节点
+            </Dropdown.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) };
+      });
+    if (children.length > 0) {
+      return children;
+    }
+    return undefined;
+  };
+
+  useEffect(() => {
+    actionRef.current?.reload();
+  }, [projectId]);
+  return (
+    <>
+      <TaskBuilderProjectsModal
+        studioName={studioName}
+        parentId={curr}
+        open={buildProjectsOpen}
+        onClose={() => setBuildProjectsOpen(false)}
+        onDone={() => actionRef.current?.reload()}
+      />
+      <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 = `/api/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 = `/api/v2/project`;
+            console.info("save api request", url, data);
+            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;

+ 112 - 0
dashboard-v6/src/components/task/ProjectClone.tsx

@@ -0,0 +1,112 @@
+import { ModalForm, ProForm, ProFormText } from "@ant-design/pro-components";
+import { Alert, Form } from "antd";
+import type {
+  IProjectData,
+  IProjectResponse,
+  IProjectUpdateRequest,
+  ITaskData,
+  ITaskGroupInsertData,
+  ITaskGroupInsertRequest,
+  ITaskGroupResponse,
+  ITaskListResponse,
+} from "../../api/task";
+import { get, post } from "../../request";
+import { useState, type JSX } from "react";
+
+interface IWidget {
+  trigger?: JSX.Element;
+  studioName?: string;
+  projectId?: string;
+}
+const ProjectClone = ({ trigger, studioName, projectId }: IWidget) => {
+  const [form] = Form.useForm<IProjectData>();
+  const [project, setProject] = useState<IProjectData>();
+  const [tasks, setTasks] = useState<ITaskData[]>();
+  const [message, setMessage] = useState<string>();
+  return (
+    <ModalForm<IProjectData>
+      title="Clone"
+      trigger={trigger}
+      form={form}
+      autoFocusFirstInput
+      modalProps={{
+        destroyOnHidden: true,
+        onCancel: () => console.log("run"),
+      }}
+      submitTimeout={2000}
+      onFinish={async (values) => {
+        if (!studioName || !project || !tasks) {
+          return false;
+        }
+        const data: IProjectUpdateRequest = {
+          studio_name: studioName,
+          title: values.title,
+          type: project.type,
+          privacy: "private",
+          description: project.description,
+        };
+        const url = `/api/v2/project`;
+        console.info("save api request", url, data);
+        const res = await post<IProjectUpdateRequest, IProjectResponse>(
+          url,
+          data
+        );
+        console.info("save api response", res);
+        if (!res.ok) {
+          setMessage("Project 建立错误 " + res.message);
+          return false;
+        }
+
+        const taskData: ITaskGroupInsertData = {
+          project_id: res.data.id,
+          tasks: tasks,
+        };
+        const taskUrl = "/api/v2/task-group";
+        const taskRes = await post<ITaskGroupInsertRequest, ITaskGroupResponse>(
+          taskUrl,
+          { data: [taskData] }
+        );
+        if (!taskRes.ok) {
+          setMessage("task 建立错误 " + taskRes.message);
+          return false;
+        }
+        return true;
+      }}
+      request={async () => {
+        const url = `/api/v2/project/${projectId}`;
+        console.info("api request", url);
+        const project = await get<IProjectResponse>(url);
+        setProject(project.data);
+        console.info("api response", project.ok);
+
+        //获取tasks
+        const taskUrl = `/api/v2/task?view=project&project_id=${projectId}`;
+        const res = await get<ITaskListResponse>(taskUrl);
+        setTasks(res.data.rows);
+        return project.data;
+      }}
+    >
+      <Alert
+        key={1}
+        title={message}
+        type="error"
+        style={{ display: message ? "unset" : "none" }}
+      />
+      <ProForm.Group key={2}>
+        <ProFormText
+          width="md"
+          name="title"
+          label="标题"
+          tooltip="最长为 24 位"
+          rules={[
+            {
+              required: true,
+            },
+          ]}
+        />
+      </ProForm.Group>
+    </ModalForm>
+  );
+};
+
+export default ProjectClone;

+ 75 - 0
dashboard-v6/src/components/task/ProjectCreate.tsx

@@ -0,0 +1,75 @@
+import { useIntl } from "react-intl";
+import { message } from "antd";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+
+import { post } from "../../request";
+import { useRef } from "react";
+import type {
+  IProjectCreateRequest,
+  IProjectResponse,
+  TProjectType,
+} from "../../api/task";
+
+interface IWidgetCourseCreate {
+  studio?: string;
+  type?: TProjectType;
+  onCreate?: () => void;
+}
+const ProjectCreate = ({
+  studio = "",
+  type = "instance",
+  onCreate,
+}: IWidgetCourseCreate) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <ProForm<IProjectCreateRequest>
+      formRef={formRef}
+      onFinish={async (values: IProjectCreateRequest) => {
+        console.log(values);
+        values.studio_name = studio;
+        values.type = type;
+        const url = `/api/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 ProjectCreate;

+ 82 - 0
dashboard-v6/src/components/task/ProjectEdit.tsx

@@ -0,0 +1,82 @@
+import {
+  ProForm,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import type {
+  IProjectData,
+  IProjectResponse,
+  IProjectUpdateRequest,
+} from "../../api/task";
+import { get, patch } from "../../request";
+import { useIntl } from "react-intl";
+import Publicity from "../studio/Publicity";
+
+interface IWidget {
+  projectId?: string;
+  studioName?: string;
+}
+const ProjectEdit = ({ projectId }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <ProForm<IProjectData>
+      onFinish={async (values) => {
+        const url = `/api/v2/project/${projectId}`;
+        console.info("api request", url, values);
+        const res = await patch<IProjectUpdateRequest, IProjectResponse>(
+          url,
+          values
+        );
+        console.log("api response", res);
+        message.success("提交成功");
+      }}
+      params={{}}
+      request={async () => {
+        const url = `/api/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>
+        <Publicity
+          name="privacy"
+          disable={["disable", "public_no_list", "blocked"]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormTextArea
+          width="md"
+          name="description"
+          label={intl.formatMessage({
+            id: "forms.fields.description.label",
+          })}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default ProjectEdit;

+ 47 - 0
dashboard-v6/src/components/task/ProjectEditDrawer.tsx

@@ -0,0 +1,47 @@
+import { Drawer } 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}
+        destroyOnHidden
+      >
+        <ProjectEdit studioName={studioName} projectId={projectId} />
+      </Drawer>
+    </>
+  );
+};
+
+export default ProjectEditDrawer;

+ 221 - 0
dashboard-v6/src/components/task/ProjectList.tsx

@@ -0,0 +1,221 @@
+import { useEffect, useRef, useState } from "react";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Link } from "react-router";
+import { Button, Popover, Space } from "antd";
+
+import { PlusOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+
+import { getSorterUrl } from "../../utils";
+
+import type {
+  IProjectData,
+  IProjectListResponse,
+  TProjectType,
+} from "../../api/task";
+import ProjectCreate from "./ProjectCreate";
+import ProjectEditDrawer from "./ProjectEditDrawer";
+import User from "../auth/User";
+import TimeShow from "../general/TimeShow";
+import ShareModal from "../share/ShareModal";
+
+import ProjectClone from "./ProjectClone";
+import { EResType } from "../share/utils";
+
+export interface IResNumberResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    my: number;
+    collaboration: number;
+  };
+}
+export type TView = "current" | "studio" | "shared" | "community";
+interface IWidget {
+  studioName?: string;
+  type?: TProjectType;
+  view?: TView;
+  readonly?: boolean;
+  onSelect?: (data: IProjectData) => void;
+}
+
+const ProjectListWidget = ({
+  studioName,
+  view = "studio",
+  type = "instance",
+  readonly = false,
+  onSelect,
+}: IWidget) => {
+  const intl = useIntl();
+  const [openCreate, setOpenCreate] = useState(false);
+  const [editId, setEditId] = useState<string>();
+  const [open, setOpen] = useState(false);
+
+  const ref = useRef<ActionType | null>(null);
+
+  useEffect(() => {
+    ref.current?.reload();
+  }, [view]);
+
+  return (
+    <>
+      <ProList<IProjectData>
+        actionRef={ref}
+        metas={{
+          title: {
+            render: (_text, row) => {
+              return readonly ? (
+                <>{row.title}</>
+              ) : (
+                <Link to={`/studio/${studioName}/task/project/${row.id}`}>
+                  {row.title}
+                </Link>
+              );
+            },
+          },
+          description: {
+            dataIndex: "description",
+            render(_dom, entity) {
+              return (
+                <Space>
+                  <User {...entity.editor} showAvatar={false} />
+                  <TimeShow
+                    createdAt={entity.created_at}
+                    updatedAt={entity.updated_at}
+                  />
+                </Space>
+              );
+            },
+          },
+          content: {
+            dataIndex: "description",
+          },
+          subTitle: {
+            render: () => {
+              return <></>;
+            },
+          },
+          actions: {
+            render: (_text, row) => [
+              <Button
+                size="small"
+                type="link"
+                key="edit"
+                onClick={() => {
+                  setEditId(row.id);
+                  setOpen(true);
+                }}
+              >
+                {intl.formatMessage({
+                  id: "buttons.edit",
+                })}
+              </Button>,
+              <ProjectClone
+                key="clone"
+                projectId={row.id}
+                studioName={studioName}
+                trigger={
+                  <Button size="small" type="link" key="clone">
+                    {intl.formatMessage({
+                      id: "buttons.clone",
+                    })}
+                  </Button>
+                }
+              />,
+              <ShareModal
+                key="share"
+                trigger={
+                  <Button type="link" size="small">
+                    {intl.formatMessage({
+                      id: "buttons.share",
+                    })}
+                  </Button>
+                }
+                resId={row.id}
+                resType={EResType.workflow}
+              />,
+            ],
+          },
+        }}
+        onRow={(record) => {
+          return {
+            onClick: () => {
+              console.info(`点击了行:${record.title}`);
+              onSelect?.(record);
+            },
+          };
+        }}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/api/v2/project?view=${view}&type=${type}`;
+          url += `&studio=${studioName}`;
+          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 += getSorterUrl(sorter);
+          console.info("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,
+        }}
+        toolbar={{
+          actions: [
+            view === "studio" ? (
+              <Popover
+                content={
+                  <ProjectCreate
+                    studio={studioName}
+                    type={"workflow"}
+                    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>
+            ) : (
+              <></>
+            ),
+          ],
+        }}
+      />
+      <ProjectEditDrawer
+        studioName={studioName}
+        projectId={editId}
+        openDrawer={open}
+        onClose={() => setOpen(false)}
+      />
+    </>
+  );
+};
+
+export default ProjectListWidget;

+ 301 - 0
dashboard-v6/src/components/task/ProjectTable.tsx

@@ -0,0 +1,301 @@
+import { type ActionType, ProTable } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Link } from "react-router";
+import { Button, message, Modal, Popover } from "antd";
+import { Dropdown } from "antd";
+import {
+  ExclamationCircleOutlined,
+  DeleteOutlined,
+  PlusOutlined,
+} from "@ant-design/icons";
+
+import { delete_, get } from "../../request";
+import { PublicityValueEnum } from "../studio/table";
+
+import { useEffect, useRef, useState } from "react";
+
+import { getSorterUrl } from "../../utils";
+import { TransferOutLinedIcon } from "../../assets/icon";
+import type { IProjectData, IProjectListResponse } from "../../api/task";
+import ProjectCreate from "./ProjectCreate";
+import ShareModal from "../share/ShareModal";
+import type { IDeleteResponse } from "../../api/article";
+import { EResType } from "../share/utils";
+
+export interface IResNumberResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    my: number;
+    collaboration: number;
+  };
+}
+
+interface IWidget {
+  studioName?: string;
+  type?: string;
+  disableChannels?: string[];
+}
+
+const ProjectTableWidget = ({ studioName, disableChannels }: IWidget) => {
+  const intl = useIntl();
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("studio");
+  const [openCreate, setOpenCreate] = useState(false);
+  const [shareId, setShareId] = useState<string>();
+  const [shareOpen, setShareOpen] = 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 = `/api/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 | null>(null);
+
+  return (
+    <>
+      {shareId ? (
+        <ShareModal
+          open={shareOpen}
+          onClose={() => setShareOpen(false)}
+          resId={shareId}
+          resType={EResType.project}
+        />
+      ) : (
+        <></>
+      )}
+
+      <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) {
+              return (
+                <Link to={`/workspace/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) => {
+              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: "share",
+                        label: intl.formatMessage({
+                          id: "buttons.share",
+                        }),
+                      },
+                      {
+                        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;
+                        case "share":
+                          setShareId(row.id);
+                          setShareOpen(true);
+                          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 = `/api/v2/project?view=${activeKey}&type=instance`;
+          url += `&studio=${studioName}`;
+          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 += 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={"instance"}
+                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: "studio",
+                label: "我的项目",
+              },
+              {
+                key: "shared",
+                label: intl.formatMessage({ id: "labels.shared" }),
+              },
+            ],
+            onChange(key) {
+              console.log("show course", key);
+              setActiveKey(key);
+              ref.current?.reload();
+            },
+          },
+        }}
+      />
+    </>
+  );
+};
+
+export default ProjectTableWidget;

+ 79 - 0
dashboard-v6/src/components/task/ProjectTask.tsx

@@ -0,0 +1,79 @@
+import { Tabs } from "antd";
+
+import TaskList from "./TaskList";
+import TaskTable from "./TaskTable";
+import TaskRelation from "./TaskRelation";
+import { useState } from "react";
+import type { ITaskData } from "../../api/task";
+import { useIntl } from "react-intl";
+import { treeToList, update } from "./utils";
+
+interface IWidget {
+  studioName?: string;
+  projectId?: string;
+  readonly?: boolean;
+  onChange?: (data: ITaskData[]) => void;
+}
+const ProjectTask = ({
+  studioName,
+  projectId,
+  readonly = false,
+  onChange,
+}: IWidget) => {
+  const [tasks, setTasks] = useState<ITaskData[]>([]);
+  const [taskTree, setTaskTree] = useState<ITaskData[]>();
+  const intl = useIntl();
+
+  const onDataChange = (treeData: ITaskData[]) => {
+    setTaskTree(treeData);
+    const listData = treeToList(treeData);
+    setTasks(listData);
+    onChange?.(listData);
+  };
+
+  return (
+    <>
+      <Tabs
+        type="card"
+        items={[
+          {
+            label: intl.formatMessage({ id: "labels.list" }),
+            key: "list",
+            children: (
+              <TaskList
+                editable={!readonly}
+                studioName={studioName}
+                projectId={projectId}
+                taskTree={taskTree}
+                onChange={onDataChange}
+              />
+            ),
+          },
+          {
+            label: intl.formatMessage({ id: "labels.table" }),
+            key: "table",
+            children: (
+              <TaskTable
+                tasks={tasks}
+                onChange={(data: ITaskData[]) => {
+                  if (origin) {
+                    const origin = JSON.parse(JSON.stringify(taskTree));
+                    update(data, origin);
+                    onDataChange(origin);
+                  }
+                }}
+              />
+            ),
+          },
+          {
+            label: intl.formatMessage({ id: "labels.flowchart" }),
+            key: "flowchart",
+            children: <TaskRelation tasks={tasks} />,
+          },
+        ]}
+      ></Tabs>
+    </>
+  );
+};
+
+export default ProjectTask;

+ 23 - 0
dashboard-v6/src/components/task/ProjectWithTasks.tsx

@@ -0,0 +1,23 @@
+import Project from "./Project";
+import ProjectTask from "./ProjectTask";
+
+interface IWidget {
+  studioName?: string;
+  projectId?: string;
+  onChange?: (id: string) => void;
+}
+const ProjectWithTasks = ({ studioName, projectId, onChange }: IWidget) => {
+  return (
+    <>
+      <Project
+        studioName={studioName}
+        projectId={projectId}
+        onSelect={(id: string) => {
+          onChange?.(id);
+        }}
+      />
+      <ProjectTask studioName={studioName} projectId={projectId} />
+    </>
+  );
+};
+export default ProjectWithTasks;

+ 24 - 0
dashboard-v6/src/components/task/Task.tsx

@@ -0,0 +1,24 @@
+import type { ITaskData } from "../../api/task";
+import TaskReader from "./TaskReader";
+
+interface IWidget {
+  taskId?: string;
+  onLoad?: (task: ITaskData) => void;
+  onChange?: (task: ITaskData[]) => void;
+  onDiscussion?: () => void;
+}
+const Task = ({ taskId, onChange, onDiscussion }: IWidget) => {
+  return (
+    <div>
+      <TaskReader
+        taskId={taskId}
+        onChange={(data: ITaskData[]) => {
+          onChange?.(data);
+        }}
+        onDiscussion={onDiscussion}
+      />
+    </div>
+  );
+};
+
+export default Task;

+ 496 - 0
dashboard-v6/src/components/task/TaskBuilderChapter.tsx

@@ -0,0 +1,496 @@
+import {
+  Button,
+  Divider,
+  Input,
+  Modal,
+  notification,
+  Space,
+  Steps,
+  Typography,
+} from "antd";
+
+import { useState } from "react";
+import Workflow from "./Workflow";
+import type {
+  IProjectTreeData,
+  IProjectTreeInsertRequest,
+  IProjectTreeResponse,
+  ITaskData,
+  ITaskGroupInsertData,
+  ITaskGroupInsertRequest,
+  ITaskGroupResponse,
+} from "../../api/task";
+
+import { post } from "../../request";
+import TaskBuilderProp, { type IParam, type IProp } from "./TaskBuilderProp";
+import type {
+  IPayload,
+  ITokenCreate,
+  ITokenCreateResponse,
+  ITokenData,
+  TPower,
+} from "../../api/token";
+import ProjectWithTasks from "./ProjectWithTasks";
+import { useIntl } from "react-intl";
+import React from "react";
+import type { IChapterToc } from "../../api/pali-text";
+import ChapterToc from "../article/ChapterToc";
+
+const { Text, Paragraph } = Typography;
+
+interface IModal {
+  studioName?: string;
+  channels?: string[];
+  book?: number;
+  para?: number;
+  open?: boolean;
+  onClose?: () => void;
+}
+export const TaskBuilderChapterModal = ({
+  studioName,
+  channels,
+  book,
+  para,
+  open = false,
+  onClose,
+}: IModal) => {
+  return (
+    <>
+      <Modal
+        destroyOnHidden={true}
+        maskClosable={false}
+        width={1400}
+        style={{ top: 10 }}
+        title={""}
+        footer={false}
+        open={open}
+        onOk={onClose}
+        onCancel={onClose}
+      >
+        <TaskBuilderChapter
+          style={{ marginTop: 20 }}
+          studioName={studioName}
+          channels={channels}
+          book={book}
+          para={para}
+        />
+      </Modal>
+    </>
+  );
+};
+
+type NotificationType = "success" | "info" | "warning" | "error";
+interface IWidget {
+  studioName?: string;
+  channels?: string[];
+  book?: number;
+  para?: number;
+  style?: React.CSSProperties;
+}
+const TaskBuilderChapter = ({
+  studioName,
+  book,
+  para,
+  style,
+  channels,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const [current, setCurrent] = useState(0);
+  const [workflow, setWorkflow] = useState<ITaskData[]>();
+  const [chapter, setChapter] = useState<IChapterToc[]>();
+  const [tokens, setTokens] = useState<ITokenData[]>();
+  const [messages, setMessages] = useState<string[]>([]);
+  const [prop, setProp] = useState<IProp[]>();
+  const [title, setTitle] = useState<string>();
+  const [loading, setLoading] = useState(false);
+  const [projects, setProjects] = useState<IProjectTreeData[]>();
+  const [done, setDone] = useState(false);
+
+  const steps = [
+    {
+      title: "选择章节",
+      content: (
+        <div style={{ padding: 8 }}>
+          <Space key={1}>
+            <Text type="secondary">{"任务组标题"}</Text>
+            <Input
+              value={title}
+              onChange={(e) => {
+                setTitle(e.target.value);
+              }}
+            />
+          </Space>
+          <ChapterToc
+            key={2}
+            book={book}
+            para={para}
+            maxLevel={7}
+            onData={(data: IChapterToc[]) => {
+              setChapter(data);
+              if (data.length > 0) {
+                if (!title && data[0].text) {
+                  setTitle(data[0].text);
+                }
+              }
+            }}
+          />
+        </div>
+      ),
+    },
+    {
+      title: "选择工作流",
+      content: (
+        <Workflow
+          studioName={studioName}
+          onSelect={(data) => {
+            if (typeof data === "undefined") {
+              setWorkflow(undefined);
+            }
+          }}
+          onData={(data) => {
+            console.debug("workflow", data);
+            setWorkflow(data);
+          }}
+        />
+      ),
+    },
+    {
+      title: "参数设置",
+      content: (
+        <div>
+          <TaskBuilderProp
+            book={book}
+            para={para}
+            workflow={workflow}
+            channelsId={channels}
+            onChange={(data: IProp[] | undefined) => {
+              console.info("TaskBuilderProp prop value", data);
+              setProp(data);
+              const channels = new Map<string, number>();
+              data?.forEach((value) => {
+                value.param?.forEach((param) => {
+                  if (param.type.includes("channel")) {
+                    channels.set(param.value, 1);
+                  }
+                });
+              });
+              //获取channel token
+              let payload: IPayload[] = [];
+              if (chapter) {
+                channels.forEach((_value, key) => {
+                  const [channelId, power] = key.split("@");
+                  payload = payload.concat(
+                    chapter.map((item) => {
+                      return {
+                        res_id: channelId,
+                        res_type: "channel",
+                        book: item.book,
+                        para_start: item.paragraph,
+                        para_end: item.paragraph + item.chapter_len,
+                        power: power as TPower,
+                      };
+                    })
+                  );
+                });
+
+                const url = "/api/v2/access-token";
+                const values = { payload: payload };
+                console.info("api request", url, values);
+                post<ITokenCreate, ITokenCreateResponse>(url, values).then(
+                  (json) => {
+                    console.info("api response token", json);
+                    setTokens(json.data.rows);
+                  }
+                );
+              }
+            }}
+          />
+        </div>
+      ),
+    },
+    {
+      title: "生成任务",
+      content: (
+        <div style={{ padding: 8 }}>
+          <div>
+            <Space>
+              <Text type="secondary">title</Text>
+              <Text>{title}</Text>
+            </Space>
+          </div>
+          <div>
+            <Space>
+              <Text type="secondary">新增任务组</Text>
+              <Text>{chapter?.length}</Text>
+            </Space>
+          </div>
+          <div>
+            <Space>
+              <Text type="secondary">每个任务组任务数量</Text>
+              <Text>{workflow?.length}</Text>
+            </Space>
+          </div>
+          <div>
+            <Paragraph>点击生成按钮生成</Paragraph>
+          </div>
+          <div>
+            {messages?.map((item, id) => {
+              return <div key={id}>{item}</div>;
+            })}
+          </div>
+        </div>
+      ),
+    },
+    {
+      title: "完成",
+      content: projects ? (
+        <ProjectWithTasks projectId={projects[0].id} />
+      ) : (
+        <></>
+      ),
+    },
+  ];
+
+  const next = () => {
+    setCurrent(current + 1);
+  };
+
+  const prev = () => {
+    setCurrent(current - 1);
+  };
+  const items = steps.map((item) => ({ key: item.title, title: item.title }));
+
+  const [api, contextHolder] = notification.useNotification();
+
+  const openNotification = (
+    type: NotificationType,
+    title: string,
+    description?: string
+  ) => {
+    api[type]({
+      message: title,
+      description: description,
+    });
+  };
+
+  //生成任务组
+  const projectGroup = async () => {
+    if (!studioName || !chapter) {
+      console.error("缺少参数", studioName, chapter);
+      return;
+    }
+    const url = "/api/v2/project-tree";
+    const values: IProjectTreeInsertRequest = {
+      studio_name: studioName,
+      data: chapter.map((item, id) => {
+        return {
+          id: item.paragraph.toString(),
+          title: id === 0 && title ? title : (item.text ?? ""),
+          type: "instance",
+          weight: item.chapter_strlen,
+          parent_id: item.parent.toString(),
+          res_id: `${item.book}-${item.paragraph}`,
+        };
+      }),
+    };
+    let res;
+    try {
+      console.info("api request", url, values);
+      res = await post<IProjectTreeInsertRequest, IProjectTreeResponse>(
+        url,
+        values
+      );
+      console.info("api response", res);
+      // 检查响应状态
+      if (!res.ok) {
+        throw new Error(`HTTP error! status: `);
+      }
+      setProjects(res.data.rows);
+      setMessages((origin) => [...origin, "生成任务组成功"]);
+    } catch (error) {
+      console.error("Fetch error:", error);
+      openNotification("error", "生成任务组失败");
+      throw error;
+    }
+    return res.data.rows;
+  };
+
+  const DoButton = () => {
+    return (
+      <>
+        <Button
+          loading={loading}
+          disabled={loading}
+          type="primary"
+          onClick={async () => {
+            if (!studioName || !chapter) {
+              console.error("缺少参数", studioName, chapter);
+              return;
+            }
+            setLoading(true);
+            //生成projects
+            setMessages((origin) => [...origin, "正在生成任务组……"]);
+            const res = await projectGroup();
+            if (!res) {
+              return;
+            }
+
+            //生成tasks
+            setMessages((origin) => [...origin, "正在生成任务……"]);
+
+            const taskUrl = "/api/v2/task-group";
+            if (!workflow) {
+              return;
+            }
+
+            const taskData: ITaskGroupInsertData[] = res
+              .filter((value) => value.isLeaf)
+              .map((project, pId) => {
+                return {
+                  project_id: project.id,
+                  tasks: workflow.map((task) => {
+                    let newContent = task.description;
+                    prop
+                      ?.find((pValue) => pValue.taskId === task.id)
+                      ?.param?.forEach((value: IParam) => {
+                        //替换数字参数
+                        if (value.type === "number") {
+                          const searchValue = `${value.key}=${value.value}`;
+                          const replaceValue =
+                            `${value.key}=` +
+                            (value.initValue + value.step * pId).toString();
+                          newContent = newContent?.replace(
+                            searchValue,
+                            replaceValue
+                          );
+                        } else {
+                          //替换book
+                          if (project.resId) {
+                            const [book, paragraph] = project.resId.split("-");
+                            newContent = newContent?.replace(
+                              "book=#",
+                              `book=${book}`
+                            );
+                            newContent = newContent?.replace(
+                              "paragraphs=#",
+                              `paragraphs=${paragraph}`
+                            );
+                            //替换channel
+                            //查找toke
+
+                            const [channel, power] = value.value.split("@");
+                            const mToken = tokens?.find(
+                              (token) =>
+                                token.payload.book?.toString() === book &&
+                                token.payload.para_start?.toString() ===
+                                  paragraph &&
+                                token.payload.res_id === channel &&
+                                (power && power.length > 0
+                                  ? token.payload.power === power
+                                  : true)
+                            );
+                            if (!mToken) {
+                              console.warn(
+                                "token not found",
+                                book,
+                                paragraph,
+                                channel,
+                                power
+                              );
+                            }
+                            newContent = newContent?.replace(
+                              value.key,
+                              channel + (mToken ? "@" + mToken?.token : "")
+                            );
+                          }
+                        }
+                      });
+
+                    console.debug("description", newContent);
+                    return {
+                      ...task,
+                      type: "instance",
+                      description: newContent,
+                    };
+                  }),
+                };
+              });
+
+            console.info("api request", taskUrl, taskData);
+            const taskRes = await post<
+              ITaskGroupInsertRequest,
+              ITaskGroupResponse
+            >(taskUrl, { data: taskData });
+            if (taskRes.ok) {
+              setMessages((origin) => [...origin, "生成任务成功"]);
+              setMessages((origin) => [
+                ...origin,
+                "生成任务" + taskRes.data.taskCount,
+              ]);
+              setMessages((origin) => [
+                ...origin,
+                "生成任务关联" + taskRes.data.taskRelationCount,
+              ]);
+              setMessages((origin) => [
+                ...origin,
+                "打开译经楼-我的任务查看已经生成的任务",
+              ]);
+              openNotification("success", "生成任务成功");
+              setDone(true);
+            }
+            setLoading(false);
+          }}
+        >
+          Done
+        </Button>
+      </>
+    );
+  };
+  return (
+    <div style={style}>
+      {contextHolder}
+      <Steps current={current} items={items} />
+      <div className="steps-content" style={{ minHeight: 400 }}>
+        {steps[current].content}
+      </div>
+      <Divider></Divider>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        {current < steps.length - 1 ? (
+          <Button
+            style={{ margin: "0 8px" }}
+            disabled={current === 0}
+            onClick={() => prev()}
+          >
+            {intl.formatMessage({ id: "buttons.previous" })}
+          </Button>
+        ) : (
+          <></>
+        )}
+
+        {current < steps.length - 2 && (
+          <Button
+            type="primary"
+            disabled={current === 1 && typeof workflow === "undefined"}
+            onClick={() => next()}
+          >
+            {intl.formatMessage({ id: "buttons.next" })}
+          </Button>
+        )}
+        {current === steps.length - 2 && (
+          <>
+            {done ? (
+              <Button type="primary" onClick={() => next()}>
+                完成
+              </Button>
+            ) : (
+              <DoButton />
+            )}
+          </>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default TaskBuilderChapter;

+ 1 - 1
dashboard-v6/src/components/task/TaskBuilderChapterModal.tsx

@@ -13,7 +13,7 @@ export const TaskBuilderChapterModal = ({ open = false, onClose }: IModal) => {
     <>
       <Modal
         destroyOnHidden={true}
-        maskClosable={false}
+        mask={{ closable: false }}
         width={1400}
         style={{ top: 10 }}
         title={""}

+ 364 - 0
dashboard-v6/src/components/task/TaskBuilderProjects.tsx

@@ -0,0 +1,364 @@
+import {
+  Button,
+  Divider,
+  Input,
+  message,
+  Modal,
+  Space,
+  Steps,
+  Tag,
+  Typography,
+} from "antd";
+
+import { useState } from "react";
+import Workflow from "./Workflow";
+import type {
+  IProjectTreeInsertRequest,
+  IProjectTreeResponse,
+  IProjectUpdateRequest,
+  ITaskData,
+  ITaskGroupInsertData,
+  ITaskGroupInsertRequest,
+  ITaskGroupResponse,
+} from "../../api/task";
+
+import { post } from "../../request";
+import TaskBuilderProp, { type IParam, type IProp } from "./TaskBuilderProp";
+
+const { Paragraph, Text } = Typography;
+
+interface IBuildProjects {
+  onChange?: (titles: string[]) => void;
+}
+const BuildProjects = ({ onChange }: IBuildProjects) => {
+  const [projectsTitle, setProjectsTitle] = useState<string[]>([]);
+  const [projectTitle, setProjectTitle] = useState<string>("");
+  const [projectTitlePerf, setProjectTitlePerf] = useState<string>("01");
+  const [projectsCount, setProjectsCount] = useState<number>(0);
+
+  const buildTitles = (base: string, perf: string, count: number): string[] => {
+    return Array.from(Array(count).keys()).map((item) => {
+      const sn = parseInt(perf) + item;
+      const extraZero = perf.length - sn.toString().length;
+      let strSn: string = sn.toString();
+      if (extraZero > 0) {
+        strSn = Array(extraZero).fill("0").join("") + sn.toString();
+      }
+      return `${base}${strSn}`;
+    });
+  };
+
+  return (
+    <div>
+      <div>
+        名称:
+        <Input
+          onChange={(e) => {
+            setProjectTitle(e.target.value);
+            const projects = buildTitles(
+              e.target.value,
+              projectTitlePerf,
+              projectsCount
+            );
+            setProjectsTitle(projects);
+            onChange?.(projects);
+          }}
+        />
+      </div>
+      <div>
+        后缀:
+        <Input
+          defaultValue={projectTitlePerf}
+          onChange={(e) => {
+            setProjectTitlePerf(e.target.value);
+            const projects = buildTitles(
+              projectTitle,
+              e.target.value,
+              projectsCount
+            );
+            setProjectsTitle(projects);
+            onChange?.(projects);
+          }}
+        />
+      </div>
+      <div>
+        数量:
+        <Input
+          onChange={(e) => {
+            setProjectsCount(parseInt(e.target.value));
+            const projects = buildTitles(
+              projectTitle,
+              projectTitlePerf,
+              parseInt(e.target.value)
+            );
+            setProjectsTitle(projects);
+            onChange?.(projects);
+          }}
+        />
+      </div>
+      <div style={{ overflowY: "scroll", height: 370 }}>
+        {projectsTitle.map((item, id) => {
+          return (
+            <Paragraph key={id}>
+              <Tag>{`${id + 1}`}</Tag>
+              {item}
+            </Paragraph>
+          );
+        })}
+      </div>
+    </div>
+  );
+};
+
+interface IModal {
+  studioName?: string;
+  parentId?: string;
+
+  open?: boolean;
+  onClose?: () => void;
+  onDone?: () => void;
+}
+export const TaskBuilderProjectsModal = ({
+  studioName,
+  parentId,
+  open = false,
+  onClose,
+  onDone,
+}: IModal) => {
+  return (
+    <>
+      <Modal
+        destroyOnHidden={true}
+        width={1400}
+        style={{ top: 10 }}
+        title={""}
+        footer={false}
+        open={open}
+        onOk={onClose}
+        onCancel={onClose}
+      >
+        <TaskBuilderProjects
+          style={{ marginTop: 20 }}
+          studioName={studioName}
+          parentId={parentId}
+          onDone={onDone}
+        />
+      </Modal>
+    </>
+  );
+};
+
+interface IWidget {
+  studioName?: string;
+  parentId?: string;
+  style?: React.CSSProperties;
+  onDone?: () => void;
+}
+const TaskBuilderProjects = ({
+  studioName,
+  parentId,
+  style,
+  onDone,
+}: IWidget) => {
+  const [current, setCurrent] = useState(0);
+  const [workflow, setWorkflow] = useState<ITaskData[]>();
+  const [projectsTitle, setProjectsTitle] = useState<string[]>();
+  const [prop, setProp] = useState<IProp[]>();
+  const [loading, setLoading] = useState(false);
+  const [messages, setMessages] = useState<string[]>([]);
+  const steps = [
+    {
+      title: "Projects",
+      content: <BuildProjects onChange={setProjectsTitle} />,
+    },
+    {
+      title: "工作流",
+      content: (
+        <Workflow
+          studioName={studioName}
+          onData={(data) => setWorkflow(data)}
+        />
+      ),
+    },
+    {
+      title: "参数设置",
+      content: (
+        <div>
+          <TaskBuilderProp
+            workflow={workflow}
+            onChange={(data: IProp[] | undefined) => setProp(data)}
+          />
+        </div>
+      ),
+    },
+    {
+      title: "生成",
+      content: (
+        <div>
+          <div>
+            <Space>
+              <Text type="secondary">title</Text>
+              <Text>{projectsTitle}</Text>
+            </Space>
+          </div>
+          <div>
+            <Paragraph>新增任务组:{projectsTitle}</Paragraph>
+            <Paragraph>每个任务组任务数量:{workflow?.length}</Paragraph>
+            <Paragraph>点击生成按钮生成</Paragraph>
+          </div>
+          <div>
+            {messages?.map((item, id) => {
+              return <div key={id}>{item}</div>;
+            })}
+          </div>
+        </div>
+      ),
+    },
+  ];
+
+  const next = () => {
+    setCurrent(current + 1);
+  };
+
+  const prev = () => {
+    setCurrent(current - 1);
+  };
+  const items = steps.map((item) => ({ key: item.title, title: item.title }));
+
+  return (
+    <div style={style}>
+      <Steps current={current} items={items} />
+      <div className="steps-content" style={{ minHeight: 400 }}>
+        {steps[current].content}
+      </div>
+      <Divider></Divider>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <Button
+          style={{ margin: "0 8px" }}
+          disabled={current === 0}
+          onClick={() => prev()}
+        >
+          Previous
+        </Button>
+
+        {current < steps.length - 1 && (
+          <Button type="primary" onClick={() => next()}>
+            Next
+          </Button>
+        )}
+        {current === steps.length - 1 && (
+          <Button
+            type="primary"
+            loading={loading}
+            disabled={loading}
+            onClick={async () => {
+              if (!studioName || !parentId || !projectsTitle) {
+                console.error("缺少参数", studioName, parentId, projectsTitle);
+                return;
+              }
+              setLoading(true);
+              //生成projects
+              setMessages((origin) => [...origin, "正在生成任务组……"]);
+              const url = "/api/v2/project-tree";
+              const values: IProjectTreeInsertRequest = {
+                studio_name: studioName,
+                parent_id: parentId,
+                data: projectsTitle.map((item, id) => {
+                  const project: IProjectUpdateRequest = {
+                    id: item,
+                    title: item,
+                    type: "instance",
+                    parent_id: null,
+                    res_id: id.toString(),
+                  };
+                  return project;
+                }),
+              };
+              console.info("api request", url, values);
+              const res = await post<
+                IProjectTreeInsertRequest,
+                IProjectTreeResponse
+              >(url, values);
+              console.info("api response", res);
+              if (!res.ok) {
+                setMessages((origin) => [...origin, "正在生成任务组失败"]);
+                return;
+              } else {
+                setMessages((origin) => [...origin, "生成任务组成功"]);
+              }
+              //生成tasks
+              setMessages((origin) => [...origin, "正在生成任务……"]);
+              const taskUrl = "/api/v2/task-group";
+              if (!workflow) {
+                return;
+              }
+              console.debug("prop", prop);
+              const taskData: ITaskGroupInsertData[] = res.data.rows
+                .filter((value) => value.isLeaf)
+                .map((project, pId) => {
+                  return {
+                    project_id: project.id,
+                    tasks: workflow.map((task) => {
+                      let newContent = task.description;
+                      prop
+                        ?.find((pValue) => pValue.taskId === task.id)
+                        ?.param?.forEach((value: IParam) => {
+                          const searchValue = `${value.key}=${value.value}`;
+                          const replaceValue =
+                            `${value.key}=` +
+                            (value.initValue + value.step * pId).toString();
+                          newContent = newContent?.replace(
+                            searchValue,
+                            replaceValue
+                          );
+                        });
+                      console.debug("description", newContent);
+                      return {
+                        ...task,
+                        type: "instance",
+                        description: newContent,
+                      };
+                    }),
+                  };
+                });
+
+              console.info("api request", taskUrl, taskData);
+              const taskRes = await post<
+                ITaskGroupInsertRequest,
+                ITaskGroupResponse
+              >(taskUrl, { data: taskData });
+              console.info("api response", taskRes);
+              if (taskRes.ok) {
+                message.success("ok");
+                setMessages((origin) => [...origin, "生成任务成功."]);
+                setMessages((origin) => [
+                  ...origin,
+                  "生成任务" + taskRes.data.taskCount,
+                ]);
+                setMessages((origin) => [
+                  ...origin,
+                  "生成任务关联" + taskRes.data.taskRelationCount,
+                ]);
+                setMessages((origin) => [
+                  ...origin,
+                  "打开译经楼-我的任务查看已经生成的任务",
+                ]);
+                onDone?.();
+              } else {
+                setMessages((origin) => [
+                  ...origin,
+                  "生成任务失败。错误信息:" + taskRes.data,
+                ]);
+              }
+              setLoading(false);
+            }}
+          >
+            Done
+          </Button>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default TaskBuilderProjects;

+ 223 - 0
dashboard-v6/src/components/task/TaskBuilderProp.tsx

@@ -0,0 +1,223 @@
+import { Divider, Input, InputNumber } from "antd";
+
+import type { ITaskData } from "../../api/task";
+import "../article/article.css";
+import { useState } from "react";
+import ChannelSelectWithToken from "../channel/ChannelSelectWithToken";
+
+import type { TPower } from "../../api/token";
+import type { TChannelType } from "../../api/channel";
+
+type TParamType =
+  | "number"
+  | "string"
+  | "channel:translation"
+  | "channel:nissaya";
+
+export interface IParam {
+  key: string;
+  label: string;
+  value: string;
+  type: TParamType;
+  initValue: number;
+  step: number;
+}
+
+export interface IProp {
+  taskTitle: string;
+  taskId: string;
+  param?: IParam[];
+}
+
+interface IWidget {
+  workflow?: ITaskData[];
+  channelsId?: string[];
+  book?: number;
+  para?: number;
+  onChange?: (data: IProp[] | undefined) => void;
+}
+
+const buildProp = (workflow: ITaskData[] | undefined): IProp[] | undefined => {
+  return workflow?.map((item) => {
+    const num = item.description
+      ?.replaceAll("}}", "|}}")
+      .split("|")
+      .filter((value) => value.includes("=?"))
+      .map((item) => {
+        const [k, v] = item.split("=");
+        const value: IParam = {
+          key: k,
+          label: k,
+          value: v,
+          type: "number",
+          initValue: 1,
+          step: v === "?++" ? 1 : v === "?+" ? 0 : -1,
+        };
+        return value;
+      });
+
+    const constant = item.description
+      ?.replaceAll("}}", "|}}")
+      .split("|")
+      .filter((value) => value.includes("=%"))
+      .map((item) => {
+        const [, v] = item.split("=");
+        const paramKey = v.split("@");
+        const value: IParam = {
+          key: v,
+          label: paramKey[0],
+          value: "",
+          type:
+            paramKey.length > 1 && paramKey[1]
+              ? (paramKey[1] as TParamType)
+              : "string",
+          initValue: 0,
+          step: 0,
+        };
+        return value;
+      });
+
+    let output: IParam[] = [];
+    if (num) output = [...output, ...num];
+    if (constant) output = [...output, ...constant];
+
+    return {
+      taskTitle: item.title,
+      taskId: item.id,
+      param: output,
+    };
+  });
+};
+
+const TaskBuilderProp = ({
+  workflow,
+  channelsId,
+  book,
+  para,
+  onChange,
+}: IWidget) => {
+  // Store the previous workflow reference in state so we can compare during render
+  // without touching refs. This is the React-recommended pattern for derived state
+  // that must reset when a prop changes:
+  // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
+  const [prevWorkflow, setPrevWorkflow] = useState(workflow);
+  const [prop, setProp] = useState(() => buildProp(workflow));
+
+  if (prevWorkflow !== workflow) {
+    // Runs synchronously during render — React will discard this render output
+    // and immediately re-render with the updated state, so only one extra render occurs.
+    setPrevWorkflow(workflow);
+    setProp(buildProp(workflow));
+  }
+
+  const change = (
+    tIndex: number,
+    pIndex: number,
+    value: string,
+    initValue: number,
+    step: number
+  ) => {
+    const newData = prop?.map((item, tId) => ({
+      taskTitle: item.taskTitle,
+      taskId: item.taskId,
+      param: item.param?.map((param, pId) => {
+        if (tIndex === tId && pIndex === pId) {
+          return { ...param, value, initValue, step };
+        }
+        return param;
+      }),
+    }));
+    setProp(newData);
+    console.debug("newData", newData);
+    onChange?.(newData);
+  };
+
+  const Value = (item: IParam, taskId: number, paramId: number) => {
+    let channelType: string | undefined;
+    const [, channel, power] = item.key.replaceAll("%", "").split("@");
+    if (item.key.includes("@channel") && channel.includes(":")) {
+      channelType = channel.split(":")[1].replaceAll("%", "");
+    }
+
+    if (item.type === "number") {
+      return (
+        <InputNumber
+          value={item.initValue}
+          onChange={(e) => {
+            if (e) change(taskId, paramId, item.value, e, item.step);
+          }}
+        />
+      );
+    }
+
+    if (item.type === "string") {
+      return (
+        <Input
+          value={item.value}
+          onChange={(e) => {
+            change(taskId, paramId, e.target.value, item.initValue, item.step);
+          }}
+        />
+      );
+    }
+
+    return (
+      <ChannelSelectWithToken
+        channelsId={channelsId}
+        book={book}
+        para={para}
+        type={channelType as TChannelType}
+        power={power ? (power as TPower) : undefined}
+        onChange={(e) => {
+          console.debug("channel select onChange", e);
+          change(taskId, paramId, e ?? "", item.initValue, item.step);
+        }}
+      />
+    );
+  };
+
+  const Step = (item: IParam, taskId: number, paramId: number) => {
+    if (item.type === "string" || item.value === "?") {
+      return <>{"无"}</>;
+    }
+    return (
+      <InputNumber
+        defaultValue={item.step}
+        readOnly={item.value === "?++"}
+        onChange={(e) => {
+          if (e) change(taskId, paramId, item.value, item.initValue, e);
+        }}
+      />
+    );
+  };
+
+  return (
+    <>
+      {prop?.map((item, taskId) => (
+        <div key={taskId}>
+          <Divider>{item.taskTitle}</Divider>
+          <table>
+            <thead>
+              <tr>
+                <td>变量名</td>
+                <td>值</td>
+                <td>递增步长</td>
+              </tr>
+            </thead>
+            <tbody>
+              {item.param?.map((param, paramId) => (
+                <tr key={paramId}>
+                  <td>{param.label}</td>
+                  <td>{Value(param, taskId, paramId)}</td>
+                  <td>{Step(param, taskId, paramId)}</td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      ))}
+    </>
+  );
+};
+
+export default TaskBuilderProp;

+ 100 - 0
dashboard-v6/src/components/task/TaskEdit.tsx

@@ -0,0 +1,100 @@
+import {
+  ProForm,
+  ProFormText,
+  ProFormTextArea,
+  type RequestOptionsType,
+} from "@ant-design/pro-components";
+import { message } from "antd";
+import { useState } from "react";
+import type {
+  ITaskData,
+  ITaskResponse,
+  ITaskUpdateRequest,
+} from "../../api/task";
+import { get, patch } from "../../request";
+import { useIntl } from "react-intl";
+
+import User from "../auth/User";
+import UserSelect from "../users/UserSelect";
+
+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 = `/api/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?.(res.data);
+          message.success("提交成功");
+        } else {
+          message.error(res.message);
+        }
+      }}
+      params={{}}
+      request={async () => {
+        const url = `/api/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) => {
+          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;

+ 116 - 0
dashboard-v6/src/components/task/TaskEditButton.tsx

@@ -0,0 +1,116 @@
+import { Dropdown, Space, message } from "antd";
+import {
+  ArrowLeftOutlined,
+  CodeSandboxOutlined,
+  DeleteOutlined,
+  FieldTimeOutlined,
+  ArrowRightOutlined,
+} from "@ant-design/icons";
+import { useIntl } from "react-intl";
+import type { MenuProps } from "antd";
+
+import type {
+  ITaskData,
+  ITaskResponse,
+  ITaskUpdateRequest,
+} from "../../api/task";
+import { patch } from "../../request";
+import TaskStatusButton from "./TaskStatusButton";
+
+export type TRelation = "pre" | "next";
+interface IWidget {
+  task?: ITaskData;
+  studioName?: string;
+  onChange?: (task: ITaskData[]) => void;
+  onPreTask?: (type: TRelation) => void;
+}
+const TaskEditButton = ({ task, onChange, onPreTask }: IWidget) => {
+  const intl = useIntl();
+
+  const setValue = (setting: ITaskUpdateRequest) => {
+    const url = `/api/v2/task/${setting.id}`;
+
+    patch<ITaskUpdateRequest, ITaskResponse>(url, setting).then((json) => {
+      if (json.ok) {
+        message.success("Success");
+        onChange?.([json.data]);
+      } else {
+        message.error(json.message);
+      }
+    });
+  };
+
+  const mainMenuItems: MenuProps["items"] = [
+    {
+      key: "milestone",
+      label: task?.is_milestone
+        ? intl.formatMessage({ id: "buttons.remove.milestone" })
+        : intl.formatMessage({ id: "buttons.set.milestone" }),
+      icon: <CodeSandboxOutlined />,
+    },
+    {
+      key: "pre-task",
+      label: intl.formatMessage({ id: "buttons.task.add.pre-task" }),
+      icon: <ArrowLeftOutlined />,
+    },
+    {
+      key: "next-task",
+      label: intl.formatMessage({ id: "buttons.task.add.next-task" }),
+      icon: <ArrowRightOutlined />,
+    },
+    {
+      type: "divider",
+    },
+    {
+      label: intl.formatMessage({ id: "buttons.timeline" }),
+      key: "timeline",
+      icon: <FieldTimeOutlined />,
+    },
+    {
+      label: intl.formatMessage({ id: "buttons.delete" }),
+      key: "delete",
+      icon: <DeleteOutlined />,
+      danger: true,
+    },
+  ];
+  const mainMenuClick: MenuProps["onClick"] = (e) => {
+    switch (e.key) {
+      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?.("pre");
+        break;
+      case "next-task":
+        onPreTask?.("next");
+        break;
+      default:
+        break;
+    }
+  };
+
+  return (
+    <Space>
+      <TaskStatusButton task={task} onChange={onChange} />
+      <Dropdown.Button
+        key={1}
+        type="link"
+        trigger={["click", "contextMenu"]}
+        menu={{
+          items: mainMenuItems,
+          onClick: mainMenuClick,
+        }}
+      ></Dropdown.Button>
+    </Space>
+  );
+};
+
+export default TaskEditButton;

+ 78 - 0
dashboard-v6/src/components/task/TaskEditDrawer.tsx

@@ -0,0 +1,78 @@
+import { Button, Drawer, Space, Typography } from "antd";
+import { useEffect, useState } from "react";
+
+import type { ITaskData } from "../../api/task";
+import Task from "./Task";
+import { useIntl } from "react-intl";
+import { fullUrl } from "../../utils";
+import LikeAvatar from "../like/LikeAvatar";
+
+const { Text } = Typography;
+
+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 (document.getElementsByTagName("body")[0].hasAttribute("style")) {
+      document.getElementsByTagName("body")[0].removeAttribute("style");
+    }
+    if (onClose) {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <Drawer
+        title={""}
+        placement={"right"}
+        width={1000}
+        onClose={onCloseDrawer}
+        open={open}
+        destroyOnHidden={true}
+        footer={
+          <Space>
+            <Text>关注</Text>
+            <LikeAvatar resId={taskId} resType="task" type="watch" />
+          </Space>
+        }
+        extra={
+          <Button
+            type="link"
+            onClick={() => {
+              window.open(fullUrl(`/article/task/${taskId}`), "_blank");
+            }}
+          >
+            {intl.formatMessage(
+              {
+                id: "buttons.open.in.new.tab",
+              },
+              { item: intl.formatMessage({ id: "labels.task" }) }
+            )}
+          </Button>
+        }
+      >
+        <Task taskId={taskId} onChange={onChange} />
+      </Drawer>
+    </>
+  );
+};
+
+export default TaskEditDrawer;

+ 84 - 0
dashboard-v6/src/components/task/TaskFlowchart.tsx

@@ -0,0 +1,84 @@
+import type { ITaskData } from "../../api/task";
+import Mermaid from "../general/Mermaid";
+
+interface IWidget {
+  tasks?: ITaskData[];
+}
+const TaskFlowchart = ({ tasks }: IWidget) => {
+  let mermaidText = "flowchart LR\n";
+
+  //节点样式
+  const color = [
+    {
+      status: "pending",
+      fill: "#fafafa",
+      stroke: "#d9d9d9",
+      color: "#000000d9",
+    },
+    {
+      status: "published",
+      fill: "#fff7e6",
+      stroke: "#ffd591",
+      color: "#d46b08",
+    },
+    { status: "running", fill: "#e6f7ff", stroke: "#91d5ff", color: "#1890ff" },
+    { status: "done", fill: "#f6ffed", stroke: "#b7eb8f", color: "#52c41a" },
+    {
+      status: "restarted",
+      fill: "r#fff2f0",
+      stroke: "#ffccc7",
+      color: "#ff4d4f",
+    },
+    {
+      status: "requested_restart",
+      fill: "#fffbe6",
+      stroke: "#ffe58f",
+      color: "#faad14",
+    },
+    { status: "closed", fill: "yellow", stroke: "#333", color: "#333" },
+    { status: "canceled", fill: "gray", stroke: "#333", color: "#333" },
+    { status: "expired", fill: "brown", stroke: "#333", color: "#333" },
+  ];
+
+  color.forEach((value) => {
+    mermaidText += `classDef ${value.status} fill:${value.fill},stroke:${value.stroke},color:${value.color},stroke-width:1px;\n`;
+  });
+
+  const relationLine = new Map<string, number>();
+  tasks?.forEach((task: ITaskData, _index: number, array: ITaskData[]) => {
+    //输出节点
+    mermaidText += `${task.id}[${task.title}]:::${task.status};\n`;
+
+    //输出带有子任务的节点
+    const children = array.filter((value: 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";
+    }
+
+    //关系线
+    task.pre_task?.map((item) =>
+      relationLine.set(`${item.id} --> ${task.id};\n`, 0)
+    );
+    task.next_task?.map((item) =>
+      relationLine.set(`${task.id} --> ${item.id};\n`, 0)
+    );
+  });
+
+  Array.from(relationLine.keys()).forEach((value) => {
+    mermaidText += value;
+  });
+
+  console.debug(mermaidText);
+
+  return (
+    <div>
+      <Mermaid text={mermaidText} />
+    </div>
+  );
+};
+
+export default TaskFlowchart;

+ 648 - 0
dashboard-v6/src/components/task/TaskList.tsx

@@ -0,0 +1,648 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useIntl } from "react-intl";
+import { Button, Form, message, Space, Typography } from "antd";
+import type { ActionType, ProColumns } from "@ant-design/pro-components";
+import { EditableProTable, useRefFunction } from "@ant-design/pro-components";
+
+import type {
+  IProject,
+  IProjectData,
+  IProjectResponse,
+  ITaskData,
+  ITaskListResponse,
+  ITaskResponse,
+  ITaskUpdateRequest,
+  TTaskStatus,
+} from "../../api/task";
+import { get, post } from "../../request";
+import TaskEditDrawer from "./TaskEditDrawer";
+import { GroupIcon } from "../../assets/icon";
+import Options, { type IMenu } from "./Options";
+import Filter from "./Filter";
+import { Milestone } from "./TaskReader";
+import Assignees from "./Assignees";
+import TaskStatusButton from "./TaskStatusButton";
+import Executors from "./Executors";
+import Category from "./Category";
+import TaskListAdd from "./TaskListAdd";
+
+import User from "../auth/User";
+import { treeToList, updateNode } from "./utils";
+
+const { Text } = Typography;
+
+function generateUUID() {
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
+    const r = (Math.random() * 16) | 0,
+      v = c === "x" ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+}
+
+export interface IFilter {
+  field:
+    | "executor_id"
+    | "owner_id"
+    | "finished_at"
+    | "assignees_id"
+    | "participants_id"
+    | "sign_up";
+  operator:
+    | "includes"
+    | "not-includes"
+    | "equals"
+    | "not-equals"
+    | "null"
+    | "not-null"
+    | null;
+  value: string | string[] | null;
+}
+
+interface IParams {
+  status?: string;
+  orderby?: string;
+  direction?: string;
+}
+interface IWidget {
+  studioName?: string;
+  projectId?: string;
+  taskTree?: readonly ITaskData[];
+  editable?: boolean;
+  filters?: IFilter[];
+  status?: TTaskStatus[];
+  sortBy?: "order" | "created_at" | "updated_at" | "started_at" | "finished_at";
+  groupBy?: "executor_id" | "owner_id" | "status" | "project_id";
+  onChange?: (treeData: ITaskData[]) => void;
+}
+const TaskList = ({
+  studioName,
+  projectId,
+  taskTree,
+  editable = false,
+  status,
+  sortBy = "order",
+  filters,
+  onChange,
+}: IWidget) => {
+  const intl = useIntl();
+  const [open, setOpen] = useState(false);
+
+  const actionRef = useRef<ActionType | null>(null);
+  const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
+  const [innerDataSource, setInnerDataSource] = useState<readonly ITaskData[]>(
+    []
+  );
+  const [rawData, setRawData] = useState<readonly ITaskData[]>([]);
+  const [form] = Form.useForm();
+  const [selectedTask, setSelectedTask] = useState<string>();
+  const [project, setProject] = useState<IProjectData>();
+
+  const [currFilter, setCurrFilter] = useState(filters);
+
+  console.info("render");
+  const getChildren = (record: ITaskData, findIn: ITaskData[]): ITaskData[] => {
+    const children = findIn
+      .filter((item) => item.parent_id === record.id)
+      .map((item) => {
+        return { ...item, children: getChildren(item, findIn) };
+      });
+
+    return children;
+  };
+
+  useEffect(() => {
+    if (!projectId) {
+      return;
+    }
+    const url = `/api/v2/project/${projectId}`;
+    console.info("api request", url);
+    get<IProjectResponse>(url).then((json) => {
+      if (json.ok) {
+        setProject(json.data);
+      } else {
+        console.error(json.message);
+      }
+    });
+  }, [projectId]);
+
+  const dataSource = taskTree ?? innerDataSource;
+
+  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) => {
+    setInnerDataSource(loopDataSourceFilter(dataSource, record.id));
+  });
+
+  const changeData = (data: ITaskData[]) => {
+    /*    console.debug("task change", data);
+    const update = (item: ITaskData): ITaskData => {
+      item.children = item.children?.map(update);
+      const found = data.find((t) => t.id === item.id);
+      if (found) {
+        return { ...found, children: item.children };
+      }
+      return item;
+    };
+    const newData = dataSource.map(update);*/
+    const origin = JSON.parse(JSON.stringify(dataSource));
+    data.forEach((value) => {
+      updateNode(origin, value);
+    });
+    console.debug("TaskList change", dataSource, origin);
+    setRawData(treeToList(origin));
+    setInnerDataSource(origin);
+    onChange?.(origin);
+  };
+
+  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) {
+        return (
+          <Space>
+            <Button
+              type="link"
+              onClick={() => {
+                setSelectedTask(entity.id);
+                setOpen(true);
+              }}
+            >
+              {entity.title}
+            </Button>
+            {entity.type === "group" ? (
+              <Text type="secondary">{entity.order}</Text>
+            ) : (
+              <>{entity.category ? <Category task={entity} /> : ""}</>
+            )}
+            <TaskStatusButton type="tag" task={entity} onChange={changeData} />
+            <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) {
+        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) {
+        return <Assignees task={entity} onChange={changeData} />;
+      },
+    },
+    {
+      title: intl.formatMessage({
+        id: "labels.task.prev.executors",
+      }),
+      key: "prev_executor",
+      dataIndex: "executor",
+      search: false,
+      readonly: true,
+      render(_dom, entity) {
+        return (
+          <div>
+            {entity.pre_task?.map((item, id) => {
+              return (
+                <User
+                  {...item.executor}
+                  key={id}
+                  showName={entity.pre_task?.length === 1}
+                />
+              );
+            })}
+          </div>
+        );
+      },
+    },
+    {
+      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: "未完成" },
+        running_restarted_published: { 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) => [
+            <EditableProTable.RecordCreator
+              key="copy"
+              parentKey={record.id}
+              record={{
+                id: generateUUID(),
+                parent_id: record.id,
+                status: "pending",
+                type: project?.type === "workflow" ? "workflow" : "instance",
+              }}
+            >
+              <Button size="small" type="link">
+                插入子节点
+              </Button>
+            </EditableProTable.RecordCreator>,
+            <Button
+              type="link"
+              danger
+              size="small"
+              key="delete"
+              onClick={() => {
+                removeRow(record);
+              }}
+            >
+              删除
+            </Button>,
+          ],
+        }
+      : { search: false },
+  ];
+
+  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}
+        // 关闭默认的新建按钮
+        recordCreatorProps={false}
+        columns={columns}
+        request={async (params = {}) => {
+          let url = `/api/v2/task?a=a`;
+          if (projectId) {
+            url += `&view=project&project_id=${projectId}`;
+          } else {
+            url += `&view=instance`;
+          }
+          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("task list api request", url);
+          const res = await get<ITaskListResponse>(url);
+          console.info("task list api response", res);
+          //setRawData(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[]) => {
+          console.info("onChange");
+          setRawData(treeToList(value));
+          if (onChange) {
+            onChange(JSON.parse(JSON.stringify(value)));
+          } else {
+            setInnerDataSource(value);
+          }
+        }}
+        editable={{
+          form,
+          editableKeys,
+          onSave: async (_key, values) => {
+            const data: ITaskUpdateRequest = {
+              ...values,
+              studio_name: studioName ?? "",
+              project_id: projectId,
+            };
+            const url = `/api/v2/task`;
+            console.info("task save api request", url, values);
+            const res = await post<ITaskUpdateRequest, ITaskResponse>(
+              url,
+              data
+            );
+            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": {
+                  const 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);
+                      }
+                    }
+                  });
+                  const 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) => {
+                    return {
+                      ...item,
+                      children: rawData.filter(
+                        (task) => task.status === item.id
+                      ),
+                    };
+                  });
+                  setInnerDataSource(newGroup);
+                  break;
+                }
+                case "project": {
+                  const projectsId = new Map<string, number>();
+                  const 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);
+                      }
+                    }
+                  });
+                  const 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) => {
+                    return {
+                      ...item,
+                      children: rawData.filter(
+                        (task) => task.project_id === item.id
+                      ),
+                    };
+                  });
+                  setInnerDataSource(newProject);
+                  break;
+                }
+                case "title": {
+                  const 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);
+                      }
+                    }
+                  });
+                  const titleGroups: ITaskData[] = [];
+                  titles.forEach((value, key) => {
+                    titleGroups.push({
+                      id: key,
+                      title: key,
+                      order: value,
+                      type: "group",
+                      is_milestone: false,
+                    });
+                  });
+                  const newTitleGroup = titleGroups.map((item) => {
+                    return {
+                      ...item,
+                      children: rawData.filter(
+                        (task) => task.title === item.title
+                      ),
+                    };
+                  });
+                  setInnerDataSource(newTitleGroup);
+                  break;
+                }
+                default:
+                  break;
+              }
+            }}
+          />,
+          <Filter
+            initValue={filters}
+            onChange={(data) => {
+              setCurrFilter(data);
+              actionRef.current?.reload();
+            }}
+          />,
+          <TaskListAdd
+            studioName={studioName}
+            projectId={projectId}
+            project={project}
+            readonly={!editable}
+            onAddNew={() => {
+              if (project) {
+                actionRef.current?.addEditRecord?.({
+                  id: generateUUID(),
+                  title: "新建任务",
+                  type: project.type === "workflow" ? "workflow" : "instance",
+                  is_milestone: false,
+                  status: "pending",
+                });
+              }
+            }}
+            onWorkflow={() => {
+              message.success("ok");
+              actionRef.current?.reload();
+            }}
+          />,
+        ]}
+      />
+      <TaskEditDrawer
+        taskId={selectedTask}
+        openDrawer={open}
+        onClose={() => setOpen(false)}
+        onChange={changeData}
+      />
+    </>
+  );
+};
+
+export default TaskList;

+ 106 - 0
dashboard-v6/src/components/task/TaskListAdd.tsx

@@ -0,0 +1,106 @@
+import { Dropdown } from "antd";
+import { DownOutlined } from "@ant-design/icons";
+import type { MenuProps } from "antd";
+import type {
+  IProjectData,
+  ITaskGroupInsertRequest,
+  ITaskGroupResponse,
+} from "../../api/task";
+import { useState } from "react";
+import { WorkflowModal } from "./Workflow";
+import { post } from "../../request";
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  studioName?: string;
+  projectId?: string;
+  project?: IProjectData;
+  readonly?: boolean;
+  onAddNew: () => void;
+  onWorkflow: () => void;
+}
+const TaskListAdd = ({
+  studioName,
+  projectId,
+  project,
+  readonly = false,
+  onAddNew,
+  onWorkflow,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const [open, setOpen] = useState(false);
+
+  const items: MenuProps["items"] = [
+    {
+      label: "从工作流创建任务",
+      key: "workflow",
+    },
+  ];
+  return (
+    <>
+      <Dropdown.Button
+        type="primary"
+        icon={<DownOutlined />}
+        disabled={readonly}
+        menu={{
+          items,
+          onClick: (info) => {
+            switch (info.key) {
+              case "workflow":
+                setOpen(true);
+                break;
+
+              default:
+                break;
+            }
+          },
+        }}
+        onClick={onAddNew}
+      >
+        {intl.formatMessage({ id: "buttons.add" })}
+      </Dropdown.Button>
+
+      <WorkflowModal
+        studioName={studioName}
+        open={open}
+        onClose={() => setOpen(false)}
+        onOk={(data) => {
+          if (!projectId || !project || !data) {
+            return;
+          }
+          const url = "/api/v2/task-group";
+          const values: ITaskGroupInsertRequest = {
+            data: [
+              {
+                project_id: projectId,
+                tasks: data.map((item) => {
+                  return {
+                    id: item.id,
+                    title: item.title,
+                    type: project.type === "workflow" ? "workflow" : "instance",
+                    order: item.order,
+                    status: item.status,
+                    parent_id: item.parent_id,
+                    project_id: projectId,
+                    is_milestone: item.is_milestone,
+                  };
+                }),
+              },
+            ],
+          };
+          console.info("api request", url, values);
+          post<ITaskGroupInsertRequest, ITaskGroupResponse>(url, values).then(
+            (json) => {
+              console.info("api response", json);
+              if (json.ok) {
+                onWorkflow();
+              }
+            }
+          );
+        }}
+      />
+    </>
+  );
+};
+export default TaskListAdd;

+ 22 - 0
dashboard-v6/src/components/task/TaskLoader.tsx

@@ -0,0 +1,22 @@
+import { useEffect } from "react";
+import { get } from "../../request";
+import type { ITaskListResponse } from "../../api/task";
+
+interface IWidget {
+  projectId?: string;
+}
+const TaskLoader = ({ projectId }: IWidget) => {
+  useEffect(() => {
+    let url = `/api/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);
+    });
+  }, [projectId]);
+  return <></>;
+};
+
+export default TaskLoader;

+ 60 - 0
dashboard-v6/src/components/task/TaskLog.tsx

@@ -0,0 +1,60 @@
+import { Button, Skeleton, Timeline } from "antd";
+
+import TimeShow from "../general/TimeShow";
+import { StatusButtons, type TTaskStatus } from "../../api/task";
+import { TaskStatusColor } from "./TaskStatus";
+import User from "../auth/User";
+import { useDiscussion } from "../discussion/hooks/useDiscussion";
+
+interface IWidget {
+  taskId?: string;
+  onMore?: () => void;
+}
+
+function findKeywordInTitle(title?: string): string | undefined {
+  if (!title) return undefined;
+  for (const keyword of StatusButtons) {
+    if (title.includes(keyword)) return keyword;
+  }
+  return undefined;
+}
+
+const TaskLog = ({ taskId, onMore }: IWidget) => {
+  const { data: logData, loading } = useDiscussion(taskId);
+
+  return (
+    <>
+      <Timeline>
+        {loading && <Skeleton paragraph={{ rows: 1 }} active avatar />}
+        {logData?.rows.map((item, id) => {
+          const status = findKeywordInTitle(item.title);
+          return (
+            <Timeline.Item
+              key={id}
+              color={TaskStatusColor(status as TTaskStatus)}
+              icon={<User {...item.editor} showName={false} />}
+            >
+              <div>
+                <TimeShow
+                  showLabel={false}
+                  showIcon={false}
+                  createdAt={item.created_at}
+                />
+              </div>
+              <div>{item.title}</div>
+            </Timeline.Item>
+          );
+        })}
+        {logData && logData.count > 5 && (
+          <Timeline.Item>
+            <Button type="link" onClick={onMore}>
+              更多
+            </Button>
+          </Timeline.Item>
+        )}
+      </Timeline>
+    </>
+  );
+};
+
+export default TaskLog;

+ 223 - 0
dashboard-v6/src/components/task/TaskReader.tsx

@@ -0,0 +1,223 @@
+import { useEffect, useState } from "react";
+
+import { Divider, Skeleton, Space, Tag, Typography, message } from "antd";
+import { CodeSandboxOutlined } from "@ant-design/icons";
+
+import type {
+  ITaskData,
+  ITaskResponse,
+  ITaskUpdateRequest,
+} from "../../api/task";
+import { get, patch } from "../../request";
+import User from "../auth/User";
+import TimeShow from "../general/TimeShow";
+import TaskEditButton, { type TRelation } from "./TaskEditButton";
+import PreTask from "./PreTask";
+import Like from "../like/Like";
+import Assignees from "./Assignees";
+import PlanDate from "./PlanDate";
+import TaskTitle from "./TaskTitle";
+import TaskStatus from "./TaskStatus";
+import Description from "./Description";
+import Category from "./Category";
+import { useIntl } from "react-intl";
+import TaskLog from "./TaskLog";
+import DiscussionDrawer from "../discussion/DiscussionDrawer";
+
+const { Text } = Typography;
+
+export const Milestone = ({ task }: { task?: ITaskData }) => {
+  const intl = useIntl();
+
+  return task?.is_milestone ? (
+    <Tag icon={<CodeSandboxOutlined />} color="error">
+      {intl.formatMessage({ id: "labels.milestone" })}
+    </Tag>
+  ) : null;
+};
+
+interface IWidget {
+  taskId?: string;
+  onChange?: (data: ITaskData[]) => void;
+  onDiscussion?: () => void;
+}
+const TaskReader = ({ taskId, onChange }: IWidget) => {
+  const [openPreTask, setOpenPreTask] = useState(false);
+  const [openNextTask, setOpenNextTask] = useState(false);
+  const [task, setTask] = useState<ITaskData>();
+  const [loading, setLoading] = useState(true);
+  const [open, setOpen] = useState(false);
+
+  useEffect(() => {
+    async function load() {
+      const url = `/api/v2/task/${taskId}`;
+      console.info("task api request", url);
+      setLoading(true);
+      get<ITaskResponse>(url)
+        .then((json) => {
+          console.info("task api response", json);
+          if (json.ok) {
+            setTask(json.data);
+          }
+        })
+        .finally(() => setLoading(false));
+    }
+    load();
+  }, [taskId]);
+
+  const updatePreTask = (type: TRelation, data: ITaskData, has: boolean) => {
+    if (!taskId || !data) {
+      return;
+    }
+    const setting: ITaskUpdateRequest = {
+      id: taskId,
+      studio_name: "",
+    };
+    if (type === "pre") {
+      let newPre =
+        task?.pre_task?.filter((value) => value.id !== data.id) ?? [];
+      if (has) {
+        newPre = [...newPre, data];
+      }
+      setting.pre_task_id = newPre?.map((item) => item.id).join();
+    } else if (type === "next") {
+      let newNext =
+        task?.next_task?.filter((value) => value.id !== data.id) ?? [];
+      if (has) {
+        newNext = [...newNext, data];
+      }
+      setting.next_task_id = newNext?.map((item) => item.id).join();
+    }
+
+    const url = `/api/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");
+        setTask(json.data);
+        onChange?.([json.data]);
+      } else {
+        message.error(json.message);
+      }
+    });
+  };
+  return loading ? (
+    <Skeleton active />
+  ) : (
+    <div>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <Space>
+          <TaskStatus task={task} />
+          <Milestone task={task} />
+          <PreTask
+            task={task}
+            open={openPreTask}
+            type="pre"
+            onChange={(data, has) => {
+              updatePreTask("pre", data, has);
+              setOpenPreTask(false);
+            }}
+            onTagClick={() => setOpenPreTask(true)}
+            onClose={() => setOpenPreTask(false)}
+          />
+          <PreTask
+            task={task}
+            open={openNextTask}
+            type="next"
+            onChange={(data, has) => {
+              updatePreTask("next", data, has);
+              setOpenNextTask(false);
+            }}
+            onClose={() => setOpenNextTask(false)}
+            onTagClick={() => setOpenNextTask(true)}
+          />
+        </Space>
+        <div>
+          <TaskEditButton
+            task={task}
+            onChange={(tasks: ITaskData[]) => {
+              setTask(tasks.find((value) => value.id === taskId));
+              onChange?.(tasks);
+            }}
+            onPreTask={(type: TRelation) => {
+              if (type === "pre") {
+                setOpenPreTask(true);
+              } else if (type === "next") {
+                setOpenNextTask(true);
+              }
+            }}
+          />
+        </div>
+      </div>
+      <TaskTitle
+        task={task}
+        onChange={(data) => {
+          setTask(data[0]);
+          onChange?.(data);
+        }}
+      />
+      <div style={{ display: "flex", flexDirection: "column" }}>
+        <Space>
+          <User {...task?.editor} />
+          <TimeShow updatedAt={task?.updated_at} />
+          <Like resId={task?.id} resType="task" />
+        </Space>
+        <Space
+          style={{ display: task?.type === "workflow" ? "none" : "unset" }}
+        >
+          <Text type="secondary" key={"2"}>
+            执行人
+          </Text>
+          <User key={"executor"} {...task?.executor} />
+        </Space>
+        <Space>
+          <Text type="secondary" key={"1"}>
+            指派给
+          </Text>
+          <Assignees
+            key={"assignees"}
+            task={task}
+            onChange={(data) => {
+              setTask(data[0]);
+              onChange?.(data);
+            }}
+          />
+        </Space>
+        <Space>
+          <Text type="secondary">起止日期</Text>
+          <div style={{ width: 400 }}>
+            <PlanDate />
+          </div>
+        </Space>
+        <Space>
+          <Text type="secondary">类别</Text>
+          <Category
+            task={task}
+            onChange={(data) => {
+              setTask(data[0]);
+              onChange?.(data);
+            }}
+          />
+        </Space>
+      </div>
+      <Divider />
+      <TaskLog taskId={taskId} onMore={() => setOpen(true)} />
+      <Description
+        task={task}
+        onChange={(data) => {
+          setTask(data[0]);
+          onChange?.(data);
+        }}
+        onDiscussion={() => setOpen(true)}
+      />
+      <DiscussionDrawer
+        open={open}
+        onClose={() => setOpen(false)}
+        resId={taskId}
+        resType="task"
+      />
+    </div>
+  );
+};
+export default TaskReader;

+ 42 - 0
dashboard-v6/src/components/task/TaskRelation.tsx

@@ -0,0 +1,42 @@
+import { Collapse } from "antd";
+import type { IProject, ITaskData } from "../../api/task";
+import TaskFlowchart from "./TaskFlowchart";
+
+const { Panel } = Collapse;
+
+interface IWidget {
+  projectId?: string;
+  tasks?: ITaskData[];
+}
+const TaskRelation = ({ tasks }: IWidget) => {
+  const projects = new Map<string, IProject>();
+  tasks?.forEach((value) => {
+    if (value.project) {
+      projects.set(value.project.id, value.project);
+    }
+  });
+  const flowcharts: IProject[] = [];
+  projects.forEach((value: IProject) => {
+    flowcharts.push(value);
+  });
+
+  return (
+    <Collapse
+      defaultActiveKey={Array.from({ length: flowcharts.length }, (_, i) => i)}
+    >
+      {flowcharts
+        .sort((a, b) => a.sn - b.sn)
+        .map((item, id) => {
+          return (
+            <Panel header={item.title} key={id}>
+              <TaskFlowchart
+                tasks={tasks?.filter((value) => value.project_id === item.id)}
+              />
+            </Panel>
+          );
+        })}
+    </Collapse>
+  );
+};
+
+export default TaskRelation;

+ 79 - 0
dashboard-v6/src/components/task/TaskStatus.tsx

@@ -0,0 +1,79 @@
+import { Progress, Tag, Tooltip } from "antd";
+import type { ITaskData, ITaskResponse, TTaskStatus } from "../../api/task";
+import { useIntl } from "react-intl";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+
+const taskStatusColors: Record<TTaskStatus, string> = {
+  pending: "default",
+  published: "orange",
+  running: "processing",
+  done: "success",
+  restarted: "warning",
+  requested_restart: "warning",
+  closed: "error",
+  canceled: "error",
+  expired: "error",
+  queue: "default",
+  stop: "error",
+  quit: "error",
+  pause: "warning",
+};
+export const TaskStatusColor = (status: TTaskStatus = "pending"): string => {
+  return taskStatusColors[status];
+};
+
+interface IWidget {
+  task?: ITaskData;
+}
+const TaskStatus = ({ task }: IWidget) => {
+  const intl = useIntl();
+  const [progress, setProgress] = useState(task?.progress);
+
+  useEffect(() => {
+    if (!task?.id) {
+      return;
+    }
+    if (task.status !== "running") {
+      return;
+    }
+    const query = () => {
+      const url = `/api/v2/task/${task?.id}`;
+      console.info("api request", url);
+      get<ITaskResponse>(url).then((json) => {
+        console.log("api response", json);
+        if (json.ok) {
+          setProgress(json.data.progress);
+        }
+      });
+    };
+
+    const timer = setInterval(query, 1000 * (60 + Math.random() * 10));
+    return () => {
+      clearInterval(timer);
+    };
+  }, [task]);
+
+  const color = TaskStatusColor(task?.status);
+  return (
+    <>
+      <Tag color={color}>
+        {intl.formatMessage({
+          id: `labels.task.status.${task?.status}`,
+          defaultMessage: "unknown",
+        })}
+      </Tag>
+      {task?.status === "running" && task?.executor?.roles?.includes("ai") ? (
+        <div style={{ display: "inline-block", width: 80 }}>
+          <Tooltip title={`${progress}%`}>
+            <Progress percent={progress ?? 0} size="small" showInfo={false} />
+          </Tooltip>
+        </div>
+      ) : (
+        <></>
+      )}
+    </>
+  );
+};
+
+export default TaskStatus;

+ 218 - 0
dashboard-v6/src/components/task/TaskStatusButton.tsx

@@ -0,0 +1,218 @@
+import type { DropdownButtonType } from "antd/lib/dropdown/dropdown-button";
+import {
+  Button,
+  Dropdown,
+  type MenuProps,
+  message,
+  Popconfirm,
+  type PopconfirmProps,
+} from "antd";
+import { useIntl } from "react-intl";
+import {
+  CheckOutlined,
+  LoadingOutlined,
+  DownOutlined,
+} from "@ant-design/icons";
+
+import {
+  type ITaskData,
+  type ITaskListResponse,
+  type ITaskUpdateRequest,
+  StatusButtons,
+  type TTaskStatus,
+} from "../../api/task";
+import { patch } from "../../request";
+import TaskStatus from "./TaskStatus";
+import { useState } from "react";
+import type { MenuItemType } from "antd/es/menu/interface";
+
+type IStatusMenu = MenuItemType & {
+  key: TTaskStatus;
+};
+
+interface IWidget {
+  type?: "button" | "tag";
+  task?: ITaskData;
+  buttonType?: DropdownButtonType;
+  onChange?: (task: ITaskData[]) => void;
+}
+const TaskStatusButton = ({
+  type = "button",
+  task,
+  buttonType = "primary",
+  onChange,
+}: IWidget) => {
+  const intl = useIntl();
+  const [loading, setLoading] = useState(false);
+
+  const setStatus = (setting: ITaskUpdateRequest) => {
+    const url = `/api/v2/task-status/${setting.id}`;
+    console.info("api request", url, setting);
+    setLoading(true);
+    patch<ITaskUpdateRequest, ITaskListResponse>(url, setting)
+      .then((json) => {
+        console.info("api response", json);
+        if (json.ok) {
+          message.success("Success");
+          onChange?.(json.data.rows);
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => setLoading(false));
+  };
+  const handleMenuClick: MenuProps["onClick"] = (e) => {
+    console.log("click", e);
+    if (task?.id) {
+      setStatus({
+        id: task.id,
+        status: e.key,
+        studio_name: "",
+      });
+    }
+  };
+
+  const requested_restart_enable =
+    task?.type === "instance" &&
+    task.status === "running" &&
+    task.pre_task &&
+    task.pre_task?.length > 0;
+  let menuEnable: TTaskStatus[] = [];
+  switch (task?.status) {
+    case "pending":
+      menuEnable = ["published"];
+      break;
+    case "published":
+      menuEnable = ["pending", "running"];
+      break;
+    case "running":
+      menuEnable = [
+        "done",
+        "stop",
+        "quit",
+        requested_restart_enable ? "requested_restart" : "done",
+      ];
+      break;
+    case "done":
+      menuEnable = ["restarted"];
+      break;
+    case "restarted":
+      menuEnable = ["done"];
+      break;
+    case "requested_restart":
+      menuEnable = ["done"];
+      break;
+    case "queue":
+      menuEnable = ["stop"];
+      break;
+    case "stop":
+      menuEnable = ["restarted"];
+      break;
+    case "quit":
+      menuEnable = ["published"];
+      break;
+    case "pause":
+      menuEnable = ["restarted"];
+      break;
+  }
+
+  const items: IStatusMenu[] = StatusButtons.map((item) => {
+    return {
+      key: item,
+      label: intl.formatMessage({
+        id: `buttons.task.status.change.to.${item}`,
+      }),
+      disabled:
+        task?.type === "instance" && !menuEnable.includes(item as TTaskStatus),
+    };
+  });
+
+  const menuProps = {
+    items: items,
+    onClick: handleMenuClick,
+  };
+
+  const confirm: PopconfirmProps["onConfirm"] = (e) => {
+    console.log(e);
+    if (task?.id) {
+      setStatus({
+        id: task.id,
+        status: newStatus,
+        studio_name: "",
+      });
+    }
+  };
+  let newStatus: TTaskStatus = "pending";
+
+  switch (task?.status) {
+    case "pending":
+      newStatus = "published";
+      break;
+    case "published":
+      newStatus = "running";
+      break;
+    case "running":
+      newStatus = "done";
+      break;
+    case "done":
+      newStatus = "restarted";
+      break;
+    case "restarted":
+      newStatus = "done";
+      break;
+    case "requested_restart":
+      newStatus = "done";
+      break;
+    default:
+      break;
+  }
+
+  const buttonText = intl.formatMessage({
+    id: `buttons.task.status.change.to.${newStatus}`,
+    defaultMessage: "unknown",
+  });
+  return type === "button" ? (
+    <Dropdown.Button
+      type={buttonType}
+      trigger={["click"]}
+      icon={<DownOutlined />}
+      menu={menuProps}
+      buttonsRender={([, rightButton]) => {
+        return [
+          <Popconfirm
+            title={intl.formatMessage(
+              { id: "message.task.status.change" },
+              { status: newStatus }
+            )}
+            onConfirm={confirm}
+            okText="Yes"
+            cancelText="No"
+          >
+            <Button
+              type={buttonType}
+              disabled={task?.type === "workflow"}
+              icon={
+                loading ? (
+                  <LoadingOutlined />
+                ) : newStatus === "done" ? (
+                  <CheckOutlined />
+                ) : (
+                  <></>
+                )
+              }
+            >
+              {buttonText}
+            </Button>
+          </Popconfirm>,
+          rightButton,
+        ];
+      }}
+    />
+  ) : (
+    <Dropdown placement="bottomLeft" menu={menuProps}>
+      <span>{loading ? <LoadingOutlined /> : <TaskStatus task={task} />}</span>
+    </Dropdown>
+  );
+};
+
+export default TaskStatusButton;

+ 152 - 0
dashboard-v6/src/components/task/TaskTable.tsx

@@ -0,0 +1,152 @@
+import { useMemo } from "react";
+
+import type { IProject, ITaskData } from "../../api/task";
+import "../article/article.css";
+import TaskTableCell from "./TaskTableCell";
+
+interface ITaskHeading {
+  id: string;
+  title: string;
+  children: number;
+}
+
+interface IWidget {
+  tasks?: ITaskData[];
+  onChange?: (treeData: ITaskData[]) => void;
+}
+
+const TaskTable = ({ tasks, onChange }: IWidget) => {
+  const projects = useMemo<IProject[]>(() => {
+    const projectsId = new Map<string, number>();
+    const 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);
+        }
+      }
+    });
+    return Array.from(projectMap.values());
+  }, [tasks]);
+
+  const tasksTitle = useMemo<ITaskHeading[][]>(() => {
+    const getNodeChildren = (task: ITaskData): number => {
+      const children = tasks?.filter((value) => value.parent_id === task.id);
+      if (children && children.length > 0) {
+        return children.reduce(
+          (acc, cur) => acc + getNodeChildren(cur),
+          children.length
+        );
+      }
+      return 0;
+    };
+
+    const titles1: ITaskHeading[] = [];
+    let titles2: ITaskHeading[] = [];
+    const tRoot = new Map<string, ITaskData>();
+
+    tasks
+      ?.filter((value: ITaskData) => !value.parent_id)
+      .forEach((task) => {
+        tRoot.set(task.title, task);
+      });
+
+    tRoot.forEach((task) => {
+      const children = tasks
+        ?.filter((value1) => value1.parent_id === task.id)
+        .map(
+          (task1): ITaskHeading => ({
+            id: task1.id,
+            title: task1.title ?? "",
+            children: 0,
+          })
+        );
+
+      if (children) {
+        titles2 = [...titles2, ...children];
+      }
+
+      titles1.push({
+        title: task.title ?? "",
+        id: task.id,
+        children: getNodeChildren(task),
+      });
+    });
+
+    return [titles1, titles2];
+  }, [tasks]);
+
+  const dataHeading = useMemo<string[]>(() => {
+    const tRoot = new Map<string, ITaskData>();
+    tasks
+      ?.filter((value: ITaskData) => !value.parent_id)
+      .forEach((task) => {
+        tRoot.set(task.title, task);
+      });
+
+    let titles3: string[] = [];
+    tRoot.forEach((task) => {
+      const children = tasks?.filter((value1) => value1.parent_id === task.id);
+      if (children && children.length > 0) {
+        titles3 = [...titles3, ...children.map((item) => item.title ?? "")];
+      } else {
+        titles3.push(task.title ?? "");
+      }
+    });
+    return titles3;
+  }, [tasks]);
+
+  return (
+    <div className="pcd_article">
+      <table>
+        <thead>
+          {tasksTitle?.map((row, level) => (
+            <tr key={level}>
+              {level === 0 ? (
+                <>
+                  <th rowSpan={2}>project</th>
+                  <th>weight</th>
+                </>
+              ) : undefined}
+              {row.map((task, index) => (
+                <th
+                  key={index}
+                  colSpan={task.children === 0 ? undefined : task.children}
+                  rowSpan={task.children === 0 ? 2 : undefined}
+                >
+                  {task.title}
+                </th>
+              ))}
+            </tr>
+          ))}
+        </thead>
+        <tbody>
+          {projects
+            ?.sort((a, b) => a.sn - b.sn)
+            .map((row, index) => (
+              <tr key={index}>
+                <td>{row.title}</td>
+                <td>{row.weight}</td>
+                {dataHeading?.map((task, id) => {
+                  const taskData = tasks?.find(
+                    (value: ITaskData) =>
+                      value.title === task && value.project_id === row.id
+                  );
+                  return (
+                    <td key={id}>
+                      <TaskTableCell task={taskData} onChange={onChange} />
+                    </td>
+                  );
+                })}
+              </tr>
+            ))}
+        </tbody>
+      </table>
+    </div>
+  );
+};
+
+export default TaskTable;

+ 52 - 0
dashboard-v6/src/components/task/TaskTableCell.tsx

@@ -0,0 +1,52 @@
+import { useState } from "react";
+import type { ITaskData } from "../../api/task";
+import User from "../auth/User";
+import Assignees from "./Assignees";
+import TaskStatusButton from "./TaskStatusButton";
+import { Button } from "antd";
+import TaskEditDrawer from "./TaskEditDrawer";
+
+interface IWidget {
+  task?: ITaskData;
+  onChange?: (treeData: ITaskData[]) => void;
+}
+const TaskTableCell = ({ task, onChange }: IWidget) => {
+  const [active, setActive] = useState(false);
+  const [open, setOpen] = useState(false);
+
+  return (
+    <div
+      onMouseEnter={() => setActive(true)}
+      onMouseLeave={() => setActive(false)}
+    >
+      <div>
+        {task?.executor ? (
+          <User {...task.executor} />
+        ) : task?.assignees ? (
+          <Assignees task={task} />
+        ) : (
+          <></>
+        )}
+      </div>
+      <div>
+        <TaskStatusButton type="tag" task={task} onChange={onChange} />
+        <Button
+          size="small"
+          type="link"
+          style={{ visibility: active ? "visible" : "hidden" }}
+          onClick={() => setOpen(true)}
+        >
+          查看
+        </Button>
+      </div>
+      <TaskEditDrawer
+        taskId={task?.id}
+        openDrawer={open}
+        onClose={() => setOpen(false)}
+        onChange={onChange}
+      />
+    </div>
+  );
+};
+
+export default TaskTableCell;

+ 56 - 0
dashboard-v6/src/components/task/TaskTitle.tsx

@@ -0,0 +1,56 @@
+import { message, Typography } from "antd";
+import type {
+  ITaskData,
+  ITaskResponse,
+  ITaskUpdateRequest,
+} from "../../api/task";
+import { patch } from "../../request";
+
+const { Title } = Typography;
+
+interface IWidget {
+  task?: ITaskData;
+  onChange?: (data: ITaskData[]) => void;
+}
+const TaskTitle = ({ task, onChange }: IWidget) => {
+  return (
+    <Title
+      level={3}
+      editable={{
+        onChange(value) {
+          console.debug("title change", value);
+          if (!task) {
+            console.error("no task");
+            return;
+          }
+          if (value === "") {
+            message.error("标题不能为空");
+            return;
+          }
+          const setting: ITaskUpdateRequest = {
+            id: task.id,
+            studio_name: "",
+            title: value,
+          };
+          const url = `/api/v2/task/${task.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?.([json.data]);
+              } else {
+                message.error(json.message);
+              }
+            }
+          );
+        },
+      }}
+    >
+      {task?.title}
+    </Title>
+  );
+};
+
+export default TaskTitle;

+ 163 - 0
dashboard-v6/src/components/task/Workflow.tsx

@@ -0,0 +1,163 @@
+import React, { useState } from "react";
+import type { IProjectData, ITaskData } from "../../api/task";
+import ProjectList, { type TView } from "./ProjectList";
+import ProjectTask from "./ProjectTask";
+import { Button, Card, Modal, Tree } from "antd";
+import { ArrowLeftOutlined } from "@ant-design/icons";
+import type { Key } from "antd/es/table/interface";
+import { useIntl } from "react-intl";
+
+interface IModal {
+  tiger?: React.ReactNode;
+  studioName?: string;
+  open?: boolean;
+  onClose?: () => void;
+  onSelect?: (data: IProjectData | undefined) => void;
+  onOk?: (data: ITaskData[] | undefined) => void;
+}
+export const WorkflowModal = ({
+  tiger,
+  studioName,
+  open,
+  onSelect,
+  onOk,
+  onClose,
+}: IModal) => {
+  const [innerOpen, setInnerOpen] = useState(open);
+  const [data, setData] = useState<ITaskData[]>();
+
+  const openModal = open ?? innerOpen;
+
+  const showModal = () => {
+    setInnerOpen(true);
+  };
+
+  const handleOk = () => {
+    if (onOk) {
+      onOk(data);
+    } else {
+      setInnerOpen(false);
+    }
+  };
+
+  const handleCancel = () => {
+    if (onClose) {
+      onClose();
+    } else {
+      setInnerOpen(false);
+    }
+  };
+  return (
+    <>
+      <div onClick={() => showModal()}>{tiger}</div>
+      <Modal
+        destroyOnHidden={true}
+        width={1200}
+        style={{ top: 10 }}
+        title={""}
+        open={openModal}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <Workflow
+          studioName={studioName}
+          onData={(data) => setData(data)}
+          onSelect={onSelect}
+        />
+      </Modal>
+    </>
+  );
+};
+
+interface IWidget {
+  studioName?: string;
+  onSelect?: (data: IProjectData | undefined) => void;
+  onData?: (data: ITaskData[] | undefined) => void;
+}
+
+const Workflow = ({ studioName, onSelect, onData }: IWidget) => {
+  const intl = useIntl();
+
+  const [project, setProject] = useState<IProjectData>();
+  const [view, setView] = useState<TView>("studio");
+
+  const selectWorkflow = (selected: IProjectData | undefined) => {
+    onSelect?.(selected);
+    setProject(selected);
+  };
+  return (
+    <div style={{ display: "flex" }}>
+      <div style={{ minWidth: 200, flex: 1 }}>
+        <Tree
+          multiple={false}
+          defaultSelectedKeys={["studio"]}
+          treeData={[
+            {
+              title: intl.formatMessage({ id: "labels.this-studio" }),
+              key: "studio",
+            },
+            {
+              title: intl.formatMessage({ id: "labels.shared" }),
+              key: "shared",
+            },
+            {
+              title: intl.formatMessage({ id: "labels.community" }),
+              key: "community",
+            },
+          ]}
+          onSelect={(selectedKeys: Key[]) => {
+            console.debug("selectedKeys", selectedKeys);
+            if (selectedKeys.length > 0) {
+              selectWorkflow(undefined);
+              setView(selectedKeys[0].toString() as TView);
+            }
+          }}
+        />
+      </div>
+      <div style={{ flex: 5 }}>
+        <div style={{ display: project ? "block" : "none" }}>
+          <Card
+            title={
+              project ? (
+                <>
+                  <Button
+                    type="link"
+                    icon={<ArrowLeftOutlined />}
+                    onClick={() => {
+                      selectWorkflow(undefined);
+                    }}
+                  />
+                  {project.title}
+                </>
+              ) : (
+                "请选择一个工作流"
+              )
+            }
+          >
+            {project ? (
+              <ProjectTask
+                studioName={studioName}
+                projectId={project.id}
+                readonly={view !== "studio"}
+                onChange={onData}
+              />
+            ) : (
+              <></>
+            )}
+          </Card>
+        </div>
+        <div style={{ display: project ? "none" : "block" }}>
+          <ProjectList
+            studioName={studioName}
+            view={view}
+            type="workflow"
+            readonly
+            onSelect={(data: IProjectData) => selectWorkflow(data)}
+          />
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default Workflow;

+ 49 - 0
dashboard-v6/src/components/task/utils.ts

@@ -0,0 +1,49 @@
+import type { ITaskData } from "../../api/task";
+
+// 更新 ITaskData[] 中的函数
+export function update(input: ITaskData[], target: ITaskData[]): void {
+  for (const newItem of input) {
+    const match = target.findIndex((item) => item.id === newItem.id);
+    if (match >= 0) {
+      // 更新当前项的属性
+      target[match] = newItem;
+    } else {
+      // 如果没有找到,递归检查子项
+      for (const item of target) {
+        if (item.children) {
+          update([newItem], item.children);
+        }
+      }
+    }
+  }
+}
+// 更新函数
+export function updateNode(tree: ITaskData[], changed: ITaskData): boolean {
+  for (let i = 0; i < tree.length; i++) {
+    if (tree[i].id === changed.id) {
+      tree[i] = { ...tree[i], ...changed };
+      return true;
+    }
+    if (tree[i].children) {
+      const updated = updateNode(tree[i].children!, changed);
+      if (updated) {
+        console.debug("TaskList children", tree[i].children);
+        return true;
+      }
+    }
+  }
+  return false;
+}
+
+export const treeToList = (tree: readonly ITaskData[]): ITaskData[] => {
+  const output: ITaskData[] = [];
+  const scan = (value: ITaskData) => {
+    value.children?.forEach(scan);
+    value.children = undefined;
+    if (value.type !== "group") {
+      output.push(value);
+    }
+  };
+  tree.forEach(scan);
+  return output;
+};

+ 8 - 2
dashboard-v6/src/layouts/workspace/index.tsx

@@ -26,8 +26,14 @@ const Widget = () => {
         collapsible
         collapsed={collapsed}
       >
-        <div style={{ display: "flex", justifyContent: "space-between" }}>
-          <SignInAvatar />
+        <div
+          style={{
+            display: "flex",
+            justifyContent: "space-between",
+            padding: 4,
+          }}
+        >
+          <SignInAvatar hideName={collapsed} />
           <Button
             type="text"
             icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}

+ 14 - 0
dashboard-v6/src/pages/dashboard/settings/ai-model/edit.tsx

@@ -0,0 +1,14 @@
+import { useParams } from "react-router";
+import AiModelEdit from "../../../../components/ai-model/AiModelEdit";
+
+import { useAppSelector } from "../../../../hooks";
+import { currentUser } from "../../../../reducers/current-user";
+
+const Widget = () => {
+  const currUser = useAppSelector(currentUser);
+  const { id } = useParams();
+
+  return <AiModelEdit studioName={currUser?.realName} modelId={id} />;
+};
+
+export default Widget;

+ 11 - 0
dashboard-v6/src/pages/dashboard/settings/ai-model/index.tsx

@@ -0,0 +1,11 @@
+import AiModelList from "../../../../components/ai-model/AiModelList";
+import { useAppSelector } from "../../../../hooks";
+import { currentUser } from "../../../../reducers/current-user";
+
+const Widget = () => {
+  const currUser = useAppSelector(currentUser);
+
+  return <AiModelList studioName={currUser?.realName} />;
+};
+
+export default Widget;

+ 11 - 0
dashboard-v6/src/pages/dashboard/settings/ai-model/log.tsx

@@ -0,0 +1,11 @@
+import { useParams } from "react-router";
+
+import AiModelLogList from "../../../../components/ai-model/AiModelLogList";
+
+const Widget = () => {
+  const { id } = useParams();
+
+  return <AiModelLogList modelId={id} />;
+};
+
+export default Widget;

+ 27 - 0
dashboard-v6/src/pages/workspace/task/hall.tsx

@@ -0,0 +1,27 @@
+import TaskList from "../../../components/task/TaskList";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <>
+      <title>task hall</title>
+      <TaskList
+        studioName={currUser.realName}
+        status={["published"]}
+        filters={[
+          {
+            field: "assignees_id",
+            operator: "null",
+            value: "",
+          },
+        ]}
+      />
+    </>
+  ) : (
+    <>未登录</>
+  );
+};
+
+export default Widget;

+ 27 - 0
dashboard-v6/src/pages/workspace/task/pending.tsx

@@ -0,0 +1,27 @@
+import TaskList from "../../../components/task/TaskList";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const currUser = useAppSelector(currentUser);
+  return currUser ? (
+    <>
+      <title>task hall</title>
+      <TaskList
+        studioName={currUser.realName}
+        status={["published"]}
+        filters={[
+          {
+            field: "assignees_id",
+            operator: "includes",
+            value: [currUser?.id],
+          },
+        ]}
+      />
+    </>
+  ) : (
+    <>未登录</>
+  );
+};
+
+export default Widget;

+ 15 - 0
dashboard-v6/src/pages/workspace/task/project-edit.tsx

@@ -0,0 +1,15 @@
+import { useParams } from "react-router";
+
+import ProjectEdit from "../../../components/task/ProjectEdit";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const currUser = useAppSelector(currentUser);
+
+  const { projectId } = useParams();
+
+  return <ProjectEdit studioName={currUser?.realName} projectId={projectId} />;
+};
+
+export default Widget;

+ 26 - 0
dashboard-v6/src/pages/workspace/task/project.tsx

@@ -0,0 +1,26 @@
+import { useNavigate, useParams } from "react-router";
+
+import ProjectWithTasks from "../../../components/task/ProjectWithTasks";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const currUser = useAppSelector(currentUser);
+
+  const { projectId } = useParams();
+  const navigate = useNavigate();
+  return (
+    <>
+      <title>project</title>
+      <ProjectWithTasks
+        studioName={currUser?.realName}
+        projectId={projectId}
+        onChange={(id: string) => {
+          navigate(`/workspace/task/project/${id}`);
+        }}
+      />
+    </>
+  );
+};
+
+export default Widget;

+ 16 - 0
dashboard-v6/src/pages/workspace/task/projects.tsx

@@ -0,0 +1,16 @@
+import TaskProjects from "../../../components/task/ProjectTable";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const currUser = useAppSelector(currentUser);
+
+  return (
+    <>
+      <title>project</title>
+      <TaskProjects studioName={currUser?.realName} />
+    </>
+  );
+};
+
+export default Widget;

+ 11 - 0
dashboard-v6/src/pages/workspace/task/tasks.tsx

@@ -0,0 +1,11 @@
+import MyTasks from "../../../components/task/MyTasks";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const currUser = useAppSelector(currentUser);
+
+  return <MyTasks studioName={currUser?.realName} />;
+};
+
+export default Widget;

+ 11 - 0
dashboard-v6/src/pages/workspace/task/workflow.tsx

@@ -0,0 +1,11 @@
+import Workflow from "../../../components/task/Workflow";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const currUser = useAppSelector(currentUser);
+
+  return <Workflow studioName={currUser?.realName} />;
+};
+
+export default Widget;

+ 52 - 0
dashboard-v6/src/routes/anthologyRoutes.ts

@@ -0,0 +1,52 @@
+// src/routes/anthologyRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+import { anthologyLoader, articleLoader } from "../api/article";
+
+const WorkspaceAnthologyList = lazy(
+  () => import("../pages/workspace/anthology")
+);
+const WorkspaceAnthologyShow = lazy(
+  () => import("../pages/workspace/anthology/show")
+);
+const WorkspaceAnthologyEdit = lazy(
+  () => import("../pages/workspace/anthology/edit")
+);
+const WorkspaceArticleShow = lazy(
+  () => import("../pages/workspace/article/show")
+);
+
+const anthologyRoutes: RouteObject[] = [
+  {
+    path: "anthology",
+    handle: { id: "workspace.anthology", crumb: "anthology" },
+    children: [
+      {
+        index: true,
+        Component: WorkspaceAnthologyList,
+      },
+      {
+        path: ":anthologyId",
+        loader: anthologyLoader,
+        handle: {
+          crumb: (match: { data: { title: string } }) => match.data.title,
+        },
+        children: [
+          { index: true, Component: WorkspaceAnthologyShow },
+          {
+            path: "edit",
+            handle: { id: "workspace.anthology.edit", crumb: "edit" },
+            Component: WorkspaceAnthologyEdit,
+          },
+          {
+            path: ":articleId",
+            handle: { id: "workspace.anthology.article", crumb: "article" },
+            Component: WorkspaceArticleShow,
+          },
+        ],
+      },
+    ],
+  },
+];
+
+export default anthologyRoutes;

+ 32 - 0
dashboard-v6/src/routes/articleRoutes.ts

@@ -0,0 +1,32 @@
+// src/routes/articleRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+import { articleLoader } from "../api/article";
+
+const WorkspaceArticleList = lazy(() => import("../pages/workspace/article"));
+const WorkspaceArticleShow = lazy(
+  () => import("../pages/workspace/article/show")
+);
+
+const articleRoutes: RouteObject[] = [
+  {
+    path: "article",
+    handle: { id: "workspace.article", crumb: "article" },
+    children: [
+      {
+        index: true,
+        Component: WorkspaceArticleList,
+      },
+      {
+        path: ":articleId",
+        Component: WorkspaceArticleShow,
+        loader: articleLoader,
+        handle: {
+          crumb: (match: { data: { title: string } }) => match.data.title,
+        },
+      },
+    ],
+  },
+];
+
+export default articleRoutes;

+ 47 - 0
dashboard-v6/src/routes/channelRoutes.ts

@@ -0,0 +1,47 @@
+// src/routes/channelRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+import { channelLoader } from "../api/channel";
+
+const WorkspaceChannel = lazy(
+  () => import("../pages/workspace/channel/list")
+);
+const WorkspaceChannelShow = lazy(
+  () => import("../pages/workspace/channel/show")
+);
+const WorkspaceChannelSetting = lazy(
+  () => import("../pages/workspace/channel/setting")
+);
+
+const channelRoutes: RouteObject[] = [
+  {
+    path: "channel",
+    handle: { id: "workspace.channel", crumb: "channel" },
+    children: [
+      {
+        index: true,
+        Component: WorkspaceChannel,
+      },
+      {
+        path: ":channelId",
+        loader: channelLoader,
+        handle: {
+          crumb: (match: { data: { name: string } }) => match.data.name,
+        },
+        children: [
+          {
+            index: true,
+            Component: WorkspaceChannelShow,
+          },
+          {
+            path: "setting",
+            Component: WorkspaceChannelSetting,
+            handle: { id: "workspace.channel.setting", crumb: "setting" },
+          },
+        ],
+      },
+    ],
+  },
+];
+
+export default channelRoutes;

+ 49 - 0
dashboard-v6/src/routes/settingsRoutes.ts

@@ -0,0 +1,49 @@
+// src/routes/settingsRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+
+const DashboardSettingsAIModelIndex = lazy(
+  () => import("../pages/dashboard/settings/ai-model/index")
+);
+const DashboardSettingsAIModelEdit = lazy(
+  () => import("../pages/dashboard/settings/ai-model/edit")
+);
+const DashboardSettingsAIModelLog = lazy(
+  () => import("../pages/dashboard/settings/ai-model/log")
+);
+
+const settingsRoutes: RouteObject[] = [
+  {
+    path: "settings",
+    handle: { id: "workspace.settings", crumb: "settings" },
+    children: [
+      {
+        path: "ai-model",
+        children: [
+          {
+            index: true,
+            handle: { id: "workspace.setting.model", crumb: "model" },
+            Component: DashboardSettingsAIModelIndex,
+          },
+          {
+            path: ":id",
+            children: [
+              {
+                path: "edit",
+                Component: DashboardSettingsAIModelEdit,
+                handle: { id: "workspace.setting.model.edit", crumb: "edit" },
+              },
+              {
+                path: "log",
+                Component: DashboardSettingsAIModelLog,
+                handle: { id: "workspace.setting.model.log", crumb: "log" },
+              },
+            ],
+          },
+        ],
+      },
+    ],
+  },
+];
+
+export default settingsRoutes;

+ 62 - 0
dashboard-v6/src/routes/taskRouters.ts

@@ -0,0 +1,62 @@
+// src/routes/taskRouters.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+
+// 懒加载页面组件
+const hall = lazy(() => import("../pages/workspace/task/hall"));
+const pending = lazy(() => import("../pages/workspace/task/pending"));
+const list = lazy(() => import("../pages/workspace/task/tasks"));
+const projects = lazy(() => import("../pages/workspace/task/projects"));
+const project = lazy(() => import("../pages/workspace/task/project"));
+const projectEdit = lazy(() => import("../pages/workspace/task/project-edit"));
+const workflows = lazy(() => import("../pages/workspace/task/workflow"));
+
+const taskRoutes: RouteObject[] = [
+  {
+    path: "task",
+    handle: { id: "workspace.task", crumb: "task" },
+    children: [
+      {
+        path: "hall",
+        Component: hall,
+        handle: { id: "workspace.task.hall", crumb: "hall" }, // 同理也缺这个
+      },
+      {
+        path: "pending",
+        Component: pending,
+        handle: { id: "workspace.task.pending", crumb: "pending" }, // 同理也缺这个
+      },
+      {
+        path: "list",
+        Component: list,
+        handle: { id: "workspace.task.list", crumb: "list" },
+      },
+      {
+        path: "project",
+        handle: { id: "workspace.task.project", crumb: "project" },
+        children: [
+          { index: true, Component: projects },
+          {
+            path: ":projectId",
+            Component: project,
+            handle: { id: "workspace.task.project", crumb: "project" }, // ✅ 加这里
+            children: [
+              {
+                path: "edit",
+                Component: projectEdit,
+                handle: { id: "workspace.task.project", crumb: "edit" }, // ✅ edit 页也需要
+              },
+            ],
+          },
+        ],
+      },
+      {
+        path: "workflows",
+        Component: workflows,
+        handle: { id: "workspace.task.workflows", crumb: "workflows" }, // 同理
+      },
+    ],
+  },
+];
+
+export default taskRoutes;

+ 41 - 0
dashboard-v6/src/routes/termRoutes.ts

@@ -0,0 +1,41 @@
+// src/routes/termRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+import { termLoader } from "../api/Term";
+
+const WorkspaceTerm = lazy(() => import("../pages/workspace/term/list"));
+const WorkspaceTermShow = lazy(() => import("../pages/workspace/term/show"));
+const WorkspaceTermEdit = lazy(() => import("../pages/workspace/term/edit"));
+
+const termRoutes: RouteObject[] = [
+  {
+    path: "term",
+    handle: { id: "workspace.term", crumb: "term" },
+    children: [
+      {
+        index: true,
+        Component: WorkspaceTerm,
+      },
+      {
+        path: ":id",
+        loader: termLoader,
+        handle: {
+          crumb: (match: { data: { word: string } }) => match.data.word,
+        },
+        children: [
+          {
+            index: true,
+            Component: WorkspaceTermShow,
+          },
+          {
+            path: "edit",
+            Component: WorkspaceTermEdit,
+            handle: { id: "workspace.term.edit", crumb: "edit" },
+          },
+        ],
+      },
+    ],
+  },
+];
+
+export default termRoutes;

+ 54 - 0
dashboard-v6/src/routes/tipitakaRoutes.ts

@@ -0,0 +1,54 @@
+// src/routes/tipitakaRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+
+const WorkspaceTipitaka = lazy(
+  () => import("../pages/workspace/tipitaka/bypath")
+);
+const WorkspaceTipitakaChapter = lazy(
+  () => import("../pages/workspace/tipitaka/chapter")
+);
+
+const tipitakaRoutes: RouteObject[] = [
+  {
+    path: "tipitaka",
+    handle: { id: "workspace.tipitaka", crumb: "tipitaka" },
+    children: [
+      {
+        path: "lib",
+        Component: WorkspaceTipitaka,
+        children: [
+          {
+            path: ":root",
+            Component: WorkspaceTipitaka,
+            children: [
+              {
+                path: ":path",
+                Component: WorkspaceTipitaka,
+                children: [
+                  {
+                    path: ":tag",
+                    Component: WorkspaceTipitaka,
+                    handle: { id: "workspace.tipitaka.tag", crumb: "tag" },
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      },
+      {
+        path: "chapter",
+        children: [
+          {
+            path: ":id",
+            Component: WorkspaceTipitakaChapter,
+            handle: { id: "workspace.tipitaka.chapter", crumb: "chapter" },
+          },
+        ],
+      },
+    ],
+  },
+];
+
+export default tipitakaRoutes;