visuddhinanda 1 месяц назад
Родитель
Сommit
7353fc0333
53 измененных файлов с 4499 добавлено и 1411 удалено
  1. 6 2
      dashboard-v6/backup/components/article/ArticlePrevDrawer.tsx
  2. 45 4
      dashboard-v6/src/Router.tsx
  3. 278 1
      dashboard-v6/src/api/Article.ts
  4. 0 0
      dashboard-v6/src/api/anthology.ts
  5. 10 0
      dashboard-v6/src/api/api-health.ts
  6. 82 0
      dashboard-v6/src/components/anthology/AddToAnthology.tsx
  7. 72 0
      dashboard-v6/src/components/anthology/AnthologiesAtArticle.tsx
  8. 56 0
      dashboard-v6/src/components/anthology/AnthologyCard.tsx
  9. 47 0
      dashboard-v6/src/components/anthology/AnthologyDetail.tsx
  10. 198 0
      dashboard-v6/src/components/anthology/AnthologyInfoEdit.tsx
  11. 70 14
      dashboard-v6/src/components/anthology/AnthologyList.tsx
  12. 56 0
      dashboard-v6/src/components/anthology/AnthologyStudioList.tsx
  13. 60 0
      dashboard-v6/src/components/anthology/AnthologyTocEdit.tsx
  14. 6 6
      dashboard-v6/src/components/anthology/EditableTocTree.tsx
  15. 33 0
      dashboard-v6/src/components/anthology/components/AnthologyInfo.tsx
  16. 78 0
      dashboard-v6/src/components/anthology/hooks/useAnthology.tsx
  17. 91 0
      dashboard-v6/src/components/article/AnchorNav.tsx
  18. 0 226
      dashboard-v6/src/components/article/Article.tsx
  19. 139 0
      dashboard-v6/src/components/article/ArticleCreate.tsx
  20. 126 0
      dashboard-v6/src/components/article/ArticleDrawer copy.tsx
  21. 2 20
      dashboard-v6/src/components/article/ArticleDrawer.tsx
  22. 303 0
      dashboard-v6/src/components/article/ArticleEdit.tsx
  23. 74 0
      dashboard-v6/src/components/article/ArticleEditDrawer.tsx
  24. 59 0
      dashboard-v6/src/components/article/ArticleEditTools.tsx
  25. 596 0
      dashboard-v6/src/components/article/ArticleList.tsx
  26. 87 0
      dashboard-v6/src/components/article/ArticlePrevDrawer.tsx
  27. 247 0
      dashboard-v6/src/components/article/ArticleReader.tsx
  28. 333 0
      dashboard-v6/src/components/article/ArticleReaderTest.tsx
  29. 15 52
      dashboard-v6/src/components/article/TypeAnthology.tsx
  30. 15 16
      dashboard-v6/src/components/article/TypeArticle.tsx
  31. 0 335
      dashboard-v6/src/components/article/TypeArticleReader.tsx
  32. 0 172
      dashboard-v6/src/components/article/TypeCSPara.tsx
  33. 0 235
      dashboard-v6/src/components/article/TypeCourse.tsx
  34. 0 198
      dashboard-v6/src/components/article/TypePage.tsx
  35. 0 38
      dashboard-v6/src/components/article/TypeSeries.tsx
  36. 0 29
      dashboard-v6/src/components/article/TypeTask.tsx
  37. 40 0
      dashboard-v6/src/components/article/WordCount.tsx
  38. 44 38
      dashboard-v6/src/components/article/components/ArticleReaderToolbar.tsx
  39. 110 0
      dashboard-v6/src/components/article/hooks/useArticle.ts
  40. 147 0
      dashboard-v6/src/components/article/hooks/useArticleList.ts
  41. 108 0
      dashboard-v6/src/components/article/hooks/useArticleListControlled.ts
  42. 226 0
      dashboard-v6/src/components/article/hooks/useArticleMutations.ts
  43. 429 0
      dashboard-v6/src/components/general/NetworkStatus.tsx
  44. 4 2
      dashboard-v6/src/components/navigation/MainMenu.tsx
  45. 5 3
      dashboard-v6/src/layouts/workspace/index.tsx
  46. 0 13
      dashboard-v6/src/pages/workspace/anthology/anthology.tsx
  47. 18 0
      dashboard-v6/src/pages/workspace/anthology/edit.tsx
  48. 54 0
      dashboard-v6/src/pages/workspace/anthology/index.tsx
  49. 30 0
      dashboard-v6/src/pages/workspace/anthology/show.tsx
  50. 54 0
      dashboard-v6/src/pages/workspace/article/index.tsx
  51. 36 7
      dashboard-v6/src/reducers/net-status.ts
  52. 9 0
      dashboard-v6/src/routes/testRoutes.tsx
  53. 1 0
      dashboard-v6/src/types/index.ts

+ 6 - 2
dashboard-v6/backup/components/article/ArticlePrevDrawer.tsx

@@ -1,8 +1,12 @@
 import { Drawer, Typography } from "antd";
 import React, { useEffect, useState } from "react";
-import { put } from "../../request";
-import type { IArticleDataResponse, IArticleResponse } from "../../api/Article";
+
 import ArticleView from "./ArticleView";
+import {
+  IArticleDataResponse,
+  IArticleResponse,
+} from "../../../src/api/Article";
+import { put } from "../../../src/request";
 
 const { Paragraph } = Typography;
 

+ 45 - 4
dashboard-v6/src/Router.tsx

@@ -4,6 +4,7 @@ import { RouterProvider } from "react-router/dom";
 import { channelLoader } from "./api/Channel";
 import { testRoutes } from "./routes/testRoutes";
 import { buildRouteConfig } from "./routes/buildRoutes";
+import { anthologyLoader } from "./api/Article";
 
 const RootLayout = lazy(() => import("./layouts/Root"));
 const AnonymousLayout = lazy(() => import("./layouts/anonymous"));
@@ -36,11 +37,17 @@ const WorkspaceTermEdit = lazy(() => import("./pages/workspace/term/edit"));
 const WorkspaceEditChapter = lazy(
   () => import("./pages/workspace/editor/chapter")
 );
-
+// 文集
 const WorkspaceAnthologyList = lazy(
-  () => import("./pages/workspace/anthology/anthology")
+  () => import("./pages/workspace/anthology")
+);
+const WorkspaceAnthologyShow = lazy(
+  () => import("./pages/workspace/anthology/show")
 );
 
+// 文章
+const WorkspaceArticleList = lazy(() => import("./pages/workspace/article"));
+
 // ↓ 新增:TestLayout
 const TestLayout = lazy(() => import("./layouts/test"));
 
@@ -95,8 +102,42 @@ const router = createBrowserRouter(
             },
             {
               path: "anthology",
-              Component: WorkspaceAnthologyList,
-              handle: { id: "workspace.anthology", crumb: "anthology" },
+              handle: {
+                id: "workspace.anthology",
+                crumb: "anthology",
+              },
+              children: [
+                {
+                  index: true,
+                  Component: WorkspaceAnthologyList,
+                },
+                {
+                  path: ":id",
+                  Component: WorkspaceAnthologyShow,
+                  loader: anthologyLoader,
+                  handle: {
+                    crumb: (match: { data: { title: string } }) =>
+                      match.data.title,
+                  },
+                },
+              ],
+            },
+            {
+              path: "article",
+              handle: {
+                id: "workspace.article",
+                crumb: "article",
+              },
+              children: [
+                {
+                  index: true,
+                  Component: WorkspaceArticleList,
+                },
+                {
+                  path: ":id",
+                  Component: WorkspaceAnthologyShow,
+                },
+              ],
             },
             {
               path: "tipitaka",

+ 278 - 1
dashboard-v6/src/api/Article.ts

@@ -1,7 +1,11 @@
+//src/api/article.ts
+
 import type { IStudio, IStudioApiResponse, IUser, TRole } from "./Auth";
 import type { IChannel } from "./Channel";
-import { get } from "../request";
+import { delete_, get, post, put } from "../request";
 import type { ITocPathNode } from "./pali-text";
+import type { LoaderFunctionArgs } from "react-router";
+import type { ListNodeData } from "../components/article/components/EditableTree";
 
 export type TContentType = "text" | "markdown" | "html" | "json";
 
@@ -37,6 +41,17 @@ export type ArticleType =
  * sent-original/id
  */
 
+export interface IAnthologyData {
+  id: string;
+  title: string;
+  subTitle: string;
+  summary: string;
+  articles: ListNodeData[];
+  studio: IStudio;
+  created_at: string;
+  updated_at: string;
+}
+
 export interface IArticleListApiResponse {
   article: string;
   title: string;
@@ -278,6 +293,248 @@ export interface IArticleFtsListResponse {
   };
 }
 
+// ─────────────────────────────────────────────
+// Query param types
+// ─────────────────────────────────────────────
+
+export interface IFetchArticleParams {
+  /** 频道 ID 列表,后端用 `_` 分隔;anthology 有 default_channel 时可不传 */
+  channelIds?: string[];
+  /** 文集 UUID,影响 path / toc 生成和 channel 回退逻辑 */
+  anthologyId?: string;
+  /** 课程 ID,影响 channel 选择(答案频道 / 用户作业频道) */
+  courseId?: string;
+  /** 读写模式,后端默认 read */
+  mode?: ArticleMode;
+  /** 渲染格式,后端默认 react */
+  format?: "react" | "text" | "markdown" | "html";
+  /** 是否显示原文,后端默认 true */
+  origin?: boolean;
+  /** 是否显示段落编号,后端默认 false */
+  paragraph?: boolean;
+}
+
+// ─────────────────────────────────────────────
+// Article CRUD
+// ─────────────────────────────────────────────
+
+/**
+ * 将 IFetchArticleParams 序列化为 query string(不含 ? 前缀)
+ *
+ * 与后端默认值一致的参数不附加,保持 URL 简洁:
+ *   mode      默认 read
+ *   format    默认 react
+ *   origin    默认 true
+ *   paragraph 默认 false
+ */
+const buildArticleQuery = (params: IFetchArticleParams): string => {
+  const { channelIds, anthologyId, courseId, mode, format, origin, paragraph } =
+    params;
+
+  const parts: string[] = [];
+
+  if (mode && mode !== "read") parts.push(`mode=${mode}`);
+  if (format && format !== "react") parts.push(`format=${format}`);
+  if (origin === false) parts.push(`origin=false`);
+  if (paragraph === true) parts.push(`paragraph=true`);
+  if (channelIds && channelIds.length > 0)
+    parts.push(`channel=${channelIds.join("_")}`);
+  if (anthologyId) parts.push(`anthology=${anthologyId}`);
+  if (courseId) parts.push(`course=${courseId}`);
+
+  return parts.join("&");
+};
+
+/**
+ * 获取单篇文章
+ *
+ * 合并了原 fetchArticle / fetchArticleOriginText / fetchParentArticle,
+ * 通过 params 区分场景:
+ *
+ * ```ts
+ * // 普通阅读(无参)
+ * fetchArticle(id)
+ *
+ * // 取父节点(原 fetchParentArticle)
+ * fetchArticle(parentId)
+ *
+ * // 带文集上下文,返回 path / toc
+ * fetchArticle(id, { anthologyId })
+ *
+ * // 带频道
+ * fetchArticle(id, { channelIds: ['ch1', 'ch2'] })
+ *
+ * // 取原文纯文本(原 fetchArticleOriginText)
+ * fetchArticle(id, { format: 'text' })   // origin 后端默认 true,无需显式传
+ *
+ * // 编辑模式
+ * fetchArticle(id, { mode: 'edit', anthologyId })
+ *
+ * // 课程场景
+ * fetchArticle(id, { courseId, channelIds })
+ * ```
+ *
+ * GET /v2/article/:articleId?[mode=]&[format=]&[origin=]&[paragraph=]
+ *                             &[channel=]&[anthology=]&[course=]
+ */
+export const fetchArticle = (
+  articleId: string,
+  params: IFetchArticleParams = {}
+): Promise<IArticleResponse> => {
+  const query = buildArticleQuery(params);
+  const url = `/api/v2/article/${articleId}${query ? `?${query}` : ""}`;
+  return get<IArticleResponse>(url);
+};
+
+/**
+ * 创建文章
+ *
+ * POST /v2/article
+ */
+export const createArticle = (
+  data: IArticleCreateRequest
+): Promise<IArticleResponse> => {
+  return post<IArticleCreateRequest, IArticleResponse>(`/api/v2/article`, data);
+};
+
+/**
+ * 更新文章
+ *
+ * PUT /v2/article/:articleId
+ */
+export const updateArticle = (
+  articleId: string,
+  data: IArticleDataRequest
+): Promise<IArticleResponse> => {
+  return put<IArticleDataRequest, IArticleResponse>(
+    `/api/v2/article/${articleId}`,
+    data
+  );
+};
+
+/**
+ * 删除文章
+ *
+ * DELETE /v2/article/:id
+ */
+export const deleteArticle = (id: string): Promise<IDeleteResponse> => {
+  return delete_<IDeleteResponse>(`/api/v2/article/${id}`);
+};
+
+// ─────────────────────────────────────────────
+// Article list(Studio 管理视图)
+// ─────────────────────────────────────────────
+import type { SortOrder } from "antd/es/table/interface";
+
+/** 排序字段,对应后端 order 参数 */
+export type TArticleSortField = "updated_at" | "created_at" | "title";
+
+/** view=template:按 studio_name 获取模板文章 */
+interface IListArticleTemplateParams {
+  view: "template";
+  studioName: string;
+}
+
+/** view=studio 时 anthology 的过滤选项
+ *  - 不传        不按文集过滤
+ *  - 'all'       全部(含已归集和未归集)
+ *  - 'none'      未归入任何我的文集的文章
+ *  - UUID string 指定文集内的文章
+ */
+type TAnthologyFilter = "all" | "none" | string;
+
+/** view=studio:当前用户 studio 下的文章(支持协作、文集过滤、分页搜索) */
+interface IListArticleStudioParams {
+  view: "studio";
+  /** studio 名称,对应后端 name 参数 */
+  studioName: string;
+  /** 'my'(默认)= 自己的文章;'collab' = 协作文章 */
+  view2?: "my" | "collab";
+  /** 文集过滤,不传则不过滤 */
+  anthology?: TAnthologyFilter;
+}
+
+/** view=public:公开文章列表 */
+interface IListArticlePublicParams {
+  view: "public";
+}
+
+/** 所有 view 共享的分页 / 搜索 / 排序参数 */
+interface IListArticleCommonParams {
+  current?: number;
+  pageSize?: number;
+  keyword?: string;
+  /** subtitle 精确匹配 */
+  subtitle?: string;
+  /** 是否同时返回 content 字段,对应后端 content=true */
+  withContent?: boolean;
+  /** 排序字段 */
+  orderBy?: TArticleSortField;
+  /** 排序方向,对应 antd SortOrder */
+  sortOrder?: SortOrder;
+}
+
+export type IListArticleParams = IListArticleCommonParams &
+  (
+    | IListArticleTemplateParams
+    | IListArticleStudioParams
+    | IListArticlePublicParams
+  );
+
+/**
+ * 获取文章列表
+ *
+ * GET /v2/article?view=template|studio|public
+ *                &[studio_name=|name=]
+ *                &[view2=my|collab]
+ *                &[anthology=all|none|<uuid>]
+ *                &[search=]&[subtitle=]&[content=true]
+ *                &limit=&offset=
+ *                &[order=]&[dir=]
+ */
+export const fetchArticleList = (
+  params: IListArticleParams
+): Promise<IArticleListResponse> => {
+  const {
+    current = 1,
+    pageSize = 20,
+    keyword,
+    subtitle,
+    withContent,
+    orderBy,
+    sortOrder,
+  } = params;
+
+  const offset = (current - 1) * pageSize;
+  const parts: string[] = [`view=${params.view}`];
+
+  // view 专属参数
+  if (params.view === "template") {
+    parts.push(`studio_name=${params.studioName}`);
+  } else if (params.view === "studio") {
+    parts.push(`name=${params.studioName}`);
+    if (params.view2 && params.view2 !== "my")
+      parts.push(`view2=${params.view2}`);
+    if (params.anthology !== undefined)
+      parts.push(`anthology=${params.anthology}`);
+  }
+
+  // 公共参数
+  parts.push(`limit=${pageSize}`, `offset=${offset}`);
+  if (keyword) parts.push(`search=${keyword}`);
+  if (subtitle) parts.push(`subtitle=${subtitle}`);
+  if (withContent) parts.push(`content=true`);
+
+  // 排序:SortOrder → 后端 order/dir 参数
+  if (orderBy && sortOrder) {
+    const dir = sortOrder === "ascend" ? "asc" : "desc";
+    parts.push(`order=${orderBy}`, `dir=${dir}`);
+  }
+
+  const url = `/v2/article?${parts.join("&")}`;
+  return get<IArticleListResponse>(url);
+};
+
 // src/api/Article.ts 新增部分
 
 export const fetchChapterArticle = (
@@ -312,3 +569,23 @@ export const fetchNextParaChunk = (
   if (channelId) url += `&channels=${channelId}`;
   return get<IArticleResponse>(url);
 };
+
+export const fetchAnthology = (id: string): Promise<IAnthologyResponse> => {
+  return get<IAnthologyResponse>(`/api/v2/anthology/${id}`);
+};
+
+export async function anthologyLoader({ params }: LoaderFunctionArgs) {
+  const id = params.id;
+
+  if (!id) {
+    throw new Response("Missing channelId", { status: 400 });
+  }
+
+  const res = await fetchAnthology(id);
+
+  if (!res.ok) {
+    throw new Response("Channel not found", { status: 404 });
+  }
+
+  return res.data;
+}

+ 0 - 0
dashboard-v6/src/api/anthology.ts


+ 10 - 0
dashboard-v6/src/api/api-health.ts

@@ -0,0 +1,10 @@
+// src/api/api-health.ts
+
+import { get } from "../request";
+
+export interface IHealth {
+  createdAt: string;
+}
+export const apiServerHealth = (): Promise<IHealth> => {
+  return get<IHealth>("/api/v2/health-check");
+};

+ 82 - 0
dashboard-v6/src/components/anthology/AddToAnthology.tsx

@@ -0,0 +1,82 @@
+import { message } from "antd";
+import React, { useState } from "react";
+import { post } from "../../request";
+import AnthologyModal from "../anthology/AnthologyModal";
+import type {
+  IArticleMapAddRequest,
+  IArticleMapAddResponse,
+} from "../../api/Article";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  studioName?: string;
+  articleIds?: string[];
+  open?: boolean; // 外部控制
+  onClose?: (open: boolean) => void;
+  onFinally?: () => void;
+}
+
+const AddToAnthologyWidget = ({
+  trigger,
+  studioName,
+  open,
+  articleIds,
+  onClose,
+  onFinally,
+}: IWidget) => {
+  const user = useAppSelector(currentUser);
+
+  /** 是否受控 */
+  const isControlled = open !== undefined;
+
+  /** 内部状态(仅非受控使用) */
+  const [innerOpen, setInnerOpen] = useState(false);
+
+  /** 最终状态来源 */
+  const isOpen = isControlled ? open : innerOpen;
+
+  /** 状态修改统一入口 */
+  const setOpen = (next: boolean) => {
+    if (!isControlled) {
+      setInnerOpen(next);
+    }
+    onClose?.(next);
+  };
+
+  /** 选择文集 */
+  const handleSelect = (id: string) => {
+    if (!articleIds) return;
+
+    post<IArticleMapAddRequest, IArticleMapAddResponse>("/v2/article-map", {
+      anthology_id: id,
+      article_id: articleIds,
+      operation: "add",
+    })
+      .then((json) => {
+        if (json.ok) {
+          message.success(json.data);
+          setOpen(false); // 成功后关闭
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch(console.error)
+      .finally(() => {
+        onFinally?.();
+      });
+  };
+
+  return (
+    <AnthologyModal
+      studioName={studioName ?? user?.realName}
+      trigger={trigger && <span onClick={() => setOpen(true)}>{trigger}</span>}
+      open={isOpen}
+      onClose={setOpen}
+      onSelect={handleSelect}
+    />
+  );
+};
+
+export default AddToAnthologyWidget;

+ 72 - 0
dashboard-v6/src/components/anthology/AnthologiesAtArticle.tsx

@@ -0,0 +1,72 @@
+import { Space, Typography, message } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import type { IArticleMapListResponse } from "../../api/Article";
+
+const { Link, Paragraph } = Typography;
+interface IList {
+  key?: string;
+  label?: string;
+}
+interface IWidget {
+  articleId?: string;
+  anthologyId?: string | null;
+  onClick?: (key: string, e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
+}
+const AnthologiesAtArticleWidget = ({
+  articleId,
+  anthologyId,
+  onClick,
+}: IWidget) => {
+  const [list, setList] = useState<IList[]>();
+  useEffect(() => {
+    //查询这个article 有多少文集
+    const url = `/api/v2/article-map?view=article&id=${articleId}`;
+    console.log("url", url);
+    get<IArticleMapListResponse>(url).then((json) => {
+      if (json.ok) {
+        const anthologies: IList[] = json.data.rows.map((item) => {
+          return {
+            key: item.collection?.id,
+            label: item.collection?.title,
+          };
+        });
+        console.log("anthologies", anthologies);
+        setList(anthologies.filter((value) => value.key !== anthologyId));
+      } else {
+        message.error("获取文集列表失败");
+      }
+    });
+  }, [anthologyId, articleId]);
+
+  let title = "";
+  if (anthologyId) {
+    title = "其他文集";
+  } else {
+    title = "文集列表";
+  }
+
+  return (
+    <Paragraph style={{ display: list && list.length > 0 ? "block" : "none" }}>
+      <Space>
+        {title}
+        {list?.map((item, index) => {
+          return (
+            <Link
+              key={index}
+              onClick={(e) => {
+                if (onClick && item.key) {
+                  onClick(item.key, e);
+                }
+              }}
+            >
+              {item.label}
+            </Link>
+          );
+        })}
+      </Space>
+    </Paragraph>
+  );
+};
+
+export default AnthologiesAtArticleWidget;

+ 56 - 0
dashboard-v6/src/components/anthology/AnthologyCard.tsx

@@ -0,0 +1,56 @@
+import { Link } from "react-router";
+import { Row, Col } from "antd";
+import { Card } from "antd";
+import { Typography } from "antd";
+
+import StudioName from "../auth/Studio";
+import type { IAnthologyData } from "../../api/Article";
+
+const { Title, Text } = Typography;
+
+export interface IArticleData {
+  id: string;
+  title: string;
+  subTitle: string;
+  summary: string;
+  created_at: string;
+  updated_at: string;
+}
+
+interface IWidgetAnthologyCard {
+  data: IAnthologyData;
+}
+
+const AnthologyCardWidget = (prop: IWidgetAnthologyCard) => {
+  const articleList = prop.data.articles.map((item, id) => {
+    return <div key={id}>{item.title}</div>;
+  });
+  return (
+    <>
+      <Card
+        hoverable
+        variant={"borderless"}
+        style={{ width: "100%", borderRadius: 8 }}
+      >
+        <Title level={4}>
+          <Link to={`/anthology/${prop.data.id}`}>{prop.data.title}</Link>
+        </Title>
+        <div>
+          <Text type="secondary">{prop.data.subTitle}</Text>
+        </div>
+        <div>
+          <Text>{prop.data.summary}</Text>
+        </div>
+        <Link to={`/blog/${prop.data.studio.studioName}/anthology`}>
+          <StudioName data={prop.data.studio} />
+        </Link>
+        <Row>
+          <Col flex={"100px"}>Content</Col>
+          <Col flex={"auto"}>{articleList}</Col>
+        </Row>
+      </Card>
+    </>
+  );
+};
+
+export default AnthologyCardWidget;

+ 47 - 0
dashboard-v6/src/components/anthology/AnthologyDetail.tsx

@@ -0,0 +1,47 @@
+import { Divider, Typography } from "antd";
+
+import AnthologyTocTree from "../anthology/AnthologyTocTree";
+import { useIntl } from "react-intl";
+import ArticleSkeleton from "../article/components/ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+import AnthologyInfo from "./components/AnthologyInfo";
+import { useAnthology } from "./hooks/useAnthology";
+
+const { Title } = Typography;
+
+interface Props {
+  id?: string;
+  channels?: string[];
+  onArticleClick?: (anthologyId: string, id: string, target: string) => void;
+}
+
+const AnthologyDetailWidget = ({ id, channels, onArticleClick }: Props) => {
+  const { data, loading, errorCode } = useAnthology(id);
+  const intl = useIntl();
+
+  return (
+    <div style={{ padding: 12 }}>
+      {loading && <ArticleSkeleton />}
+      {!loading && errorCode && <ErrorResult code={errorCode} />}
+      {!loading && !errorCode && (
+        <>
+          <AnthologyInfo data={data} />
+          <Divider />
+          <Title level={5}>
+            {intl.formatMessage({ id: "labels.table-of-content" })}
+          </Title>
+
+          <AnthologyTocTree
+            anthologyId={id}
+            channels={channels}
+            onClick={(anthologyId, id, target) =>
+              onArticleClick?.(anthologyId, id, target)
+            }
+          />
+        </>
+      )}
+    </div>
+  );
+};
+
+export default AnthologyDetailWidget;

+ 198 - 0
dashboard-v6/src/components/anthology/AnthologyInfoEdit.tsx

@@ -0,0 +1,198 @@
+import { Form, message } from "antd";
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormSelect,
+  ProFormText,
+  type RequestOptionsType,
+} from "@ant-design/pro-components";
+import MDEditor from "@uiw/react-md-editor";
+
+import { get, put } from "../../request";
+import type {
+  IAnthologyDataRequest,
+  IAnthologyDataResponse,
+  IAnthologyResponse,
+} from "../../api/Article";
+import LangSelect from "../general/LangSelect";
+import PublicitySelect from "../studio/PublicitySelect";
+import { useState } from "react";
+import type { DefaultOptionType } from "antd/lib/select";
+import type { IApiResponseChannelList } from "../../api/Channel";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+
+interface IFormData {
+  title: string;
+  subtitle: string;
+  summary?: string;
+  lang: string;
+  status: number;
+  defaultChannel?: string;
+}
+
+interface IWidget {
+  anthologyId?: string;
+  studioName?: string;
+  onLoad?: (data: IAnthologyDataResponse) => void;
+}
+const AnthologyInfoEditWidget = ({
+  studioName,
+  anthologyId,
+  onLoad,
+}: IWidget) => {
+  const intl = useIntl();
+  const [channelOption, setChannelOption] = useState<DefaultOptionType[]>([]);
+  const [currChannel, setCurrChannel] = useState<RequestOptionsType>();
+  const [data, setData] = useState<IAnthologyDataResponse>();
+
+  const user = useAppSelector(currentUser);
+
+  return anthologyId ? (
+    <ProForm<IFormData>
+      onFinish={async (values: IFormData) => {
+        const url = `/api/v2/anthology/${anthologyId}`;
+        console.log("url", url);
+        console.log("values", values);
+        const res = await put<IAnthologyDataRequest, IAnthologyResponse>(url, {
+          title: values.title,
+          subtitle: values.subtitle,
+          summary: values.summary,
+          status: values.status,
+          lang: values.lang,
+          default_channel: values.defaultChannel,
+        });
+        console.log(res);
+        if (res.ok) {
+          if (typeof onLoad !== "undefined") {
+            onLoad(res.data);
+          }
+          message.success(
+            intl.formatMessage({
+              id: "flashes.success",
+            })
+          );
+        } else {
+          message.error(res.message);
+        }
+      }}
+      request={async () => {
+        const url = `/api/v2/anthology/${anthologyId}`;
+        console.log("url", url);
+        const res = await get<IAnthologyResponse>(url);
+        console.log("文集get", res);
+        if (res.ok) {
+          setData(res.data);
+          if (typeof onLoad !== "undefined") {
+            onLoad(res.data);
+          }
+          if (res.data.default_channel) {
+            const channel = {
+              value: res.data.default_channel.id,
+              label: res.data.default_channel.name,
+            };
+            setCurrChannel(channel);
+            setChannelOption([channel]);
+          }
+
+          return {
+            title: res.data.title,
+            subtitle: res.data.subtitle,
+            summary: res.data.summary ? res.data.summary : undefined,
+            lang: res.data.lang,
+            status: res.data.status,
+            defaultChannel: res.data.default_channel?.id,
+          };
+        } else {
+          return {
+            title: "",
+            subtitle: "",
+            summary: "",
+            lang: "",
+            status: 0,
+            defaultChannel: "",
+          };
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          required
+          label={intl.formatMessage({
+            id: "forms.fields.title.label",
+          })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "forms.message.title.required",
+              }),
+            },
+          ]}
+        />
+        <ProFormText
+          width="md"
+          name="subtitle"
+          label={intl.formatMessage({
+            id: "forms.fields.subtitle.label",
+          })}
+        />
+      </ProForm.Group>
+
+      <ProForm.Group>
+        <LangSelect width="md" />
+        <PublicitySelect
+          width="md"
+          disable={["public_no_list"]}
+          readonly={
+            user?.roles?.includes("basic") ||
+            data?.studio.roles?.includes("basic")
+              ? true
+              : false
+          }
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormSelect
+          options={channelOption}
+          width="md"
+          name="defaultChannel"
+          label={"默认版本"}
+          showSearch
+          debounceTime={300}
+          request={async ({ keyWords }) => {
+            console.log("keyWord", keyWords);
+            if (typeof keyWords === "undefined") {
+              return currChannel ? [currChannel] : [];
+            }
+            const url = `/v2/channel?view=studio&name=${studioName}`;
+            console.log("url", url);
+            const json = await get<IApiResponseChannelList>(url);
+            const textbookList = json.data.rows.map((item) => {
+              return {
+                value: item.uid,
+                label: `${item.studio.nickName}/${item.name}`,
+              };
+            });
+            console.log("json", textbookList);
+            return textbookList;
+          }}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <Form.Item
+          name="summary"
+          label={intl.formatMessage({ id: "forms.fields.summary.label" })}
+        >
+          <MDEditor />
+        </Form.Item>
+      </ProForm.Group>
+    </ProForm>
+  ) : (
+    <></>
+  );
+};
+
+export default AnthologyInfoEditWidget;

+ 70 - 14
dashboard-v6/src/components/anthology/AnthologyList.tsx

@@ -47,6 +47,12 @@ interface IWidget {
   showCreate?: boolean;
   showOption?: boolean;
   onTitleClick?: (id: string) => void;
+  // 受控参数(可选),不传则组件内部自治
+  tab?: string;
+  page?: number;
+  pageSize?: number;
+  onTabChange?: (tab: string) => void;
+  onPageChange?: (page: number, pageSize: number) => void;
 }
 const AnthologyListWidget = ({
   title,
@@ -54,18 +60,28 @@ const AnthologyListWidget = ({
   showCreate = true,
   showOption = true,
   onTitleClick,
+  tab,
+  page,
+  pageSize,
+  onTabChange,
+  onPageChange,
 }: IWidget) => {
   const intl = useIntl();
   const [openCreate, setOpenCreate] = useState(false);
 
-  const [activeKey, setActiveKey] = useState<React.Key | undefined>("my");
+  // 受控/非受控:外部传入则用外部值,否则用内部 state
+  const [internalTab, setInternalTab] = useState<string>("my");
+  const [internalPage, setInternalPage] = useState<number>(1);
+  const [internalPageSize, setInternalPageSize] = useState<number>(10);
+
+  const currentTab = tab !== undefined ? tab : internalTab;
+  const currentPage = page !== undefined ? page : internalPage;
+  const currentPageSize = pageSize !== undefined ? pageSize : internalPageSize;
+
   const [myNumber, setMyNumber] = useState<number>(0);
   const [collaborationNumber, setCollaborationNumber] = useState<number>(0);
 
   useEffect(() => {
-    /**
-     * 获取各种课程的数量
-     */
     const url = `/api/v2/anthology-my-number?studio=${studioName}`;
     console.log("url", url);
     get<IResNumberResponse>(url).then((json) => {
@@ -76,6 +92,27 @@ const AnthologyListWidget = ({
     });
   }, [studioName]);
 
+  const handleTabChange = (key: string) => {
+    console.log("show course", key);
+    // 切 tab 时重置页码到第1页(由 key 变化触发 ProTable 重新挂载来实现)
+    if (onTabChange) {
+      onTabChange(key);
+    } else {
+      setInternalTab(key);
+      setInternalPage(1); // 非受控模式下手动重置
+    }
+    // 注意:不需要 ref.current?.reload(),params 变化会自动触发
+  };
+
+  const handlePageChange = (newPage: number, newPageSize: number) => {
+    if (onPageChange) {
+      onPageChange(newPage, newPageSize);
+    } else {
+      setInternalPage(newPage);
+      setInternalPageSize(newPageSize);
+    }
+  };
+
   const showDeleteConfirm = (id: string, title: string) => {
     Modal.confirm({
       icon: <ExclamationCircleOutlined />,
@@ -136,6 +173,11 @@ const AnthologyListWidget = ({
       <ProTable<IItem>
         headerTitle={title}
         actionRef={ref}
+        // key 变化时强制重新挂载,使 defaultCurrent 重新生效
+        // tab 切换或 pageSize 改变时都会重置到第1页
+        key={`${currentTab}-${currentPageSize}`}
+        // params 变化会自动触发 request,用于将 tab 传递给 request 函数
+        params={{ tab: currentTab }}
         columns={[
           {
             title: intl.formatMessage({
@@ -282,11 +324,14 @@ const AnthologyListWidget = ({
         ]}
         request={async (params = {}, sorter, filter) => {
           console.log(params, sorter, filter);
-          let url = `/api/v2/anthology?view=studio&view2=${activeKey}&name=${studioName}`;
+          // tab 从 params 读取(由 ProTable 的 params prop 注入)
+          // current 和 pageSize 由 ProTable 内部管理,直接从 params 读
+          const tab = params.tab ?? currentTab;
+          let url = `/api/v2/anthology?view=studio&view2=${tab}&name=${studioName}`;
           const offset =
             ((params.current ? params.current : 1) - 1) *
-            (params.pageSize ? params.pageSize : 20);
-          url += `&limit=${params.pageSize}&offset=${offset}`;
+            (params.pageSize ? params.pageSize : currentPageSize);
+          url += `&limit=${params.pageSize ?? currentPageSize}&offset=${offset}`;
           url += params.keyword ? "&search=" + params.keyword : "";
 
           url += getSorterUrl(sorter);
@@ -314,9 +359,19 @@ const AnthologyListWidget = ({
         rowKey="id"
         bordered
         pagination={{
+          // 用 defaultCurrent / defaultPageSize(非受控)避免与 ProTable 内部状态冲突
+          // F5 重入时从 URL 读到的 currentPage 作为初始值正确生效
+          defaultCurrent: currentPage,
+          defaultPageSize: currentPageSize,
           showQuickJumper: true,
           showSizeChanger: true,
-          pageSize: 10,
+        }}
+        // 用 table 级别的 onChange 捕获分页事件,只在用户操作时触发一次,不会循环
+        onChange={(pagination) => {
+          handlePageChange(
+            pagination.current ?? 1,
+            pagination.pageSize ?? currentPageSize
+          );
         }}
         search={false}
         options={{
@@ -349,14 +404,17 @@ const AnthologyListWidget = ({
         ]}
         toolbar={{
           menu: {
-            activeKey,
+            activeKey: currentTab,
             items: [
               {
                 key: "my",
                 label: (
                   <span>
                     {intl.formatMessage({ id: "labels.this-studio" })}
-                    <StatusBadge count={myNumber} active={activeKey === "my"} />
+                    <StatusBadge
+                      count={myNumber}
+                      active={currentTab === "my"}
+                    />
                   </span>
                 ),
               },
@@ -367,16 +425,14 @@ const AnthologyListWidget = ({
                     {intl.formatMessage({ id: "labels.collaboration" })}
                     <StatusBadge
                       count={collaborationNumber}
-                      active={activeKey === "collaboration"}
+                      active={currentTab === "collaboration"}
                     />
                   </span>
                 ),
               },
             ],
             onChange(key) {
-              console.log("show course", key);
-              setActiveKey(key);
-              ref.current?.reload();
+              handleTabChange(key as string);
             },
           },
         }}

+ 56 - 0
dashboard-v6/src/components/anthology/AnthologyStudioList.tsx

@@ -0,0 +1,56 @@
+import { Link } from "react-router";
+import { useState, useEffect } from "react";
+import { List, Space, Card } from "antd";
+
+import StudioName from "../auth/Studio";
+import type { IAnthologyStudioListApiResponse } from "../../api/Article";
+import type { IStudioApiResponse } from "../../api/Auth";
+import { get } from "../../request";
+
+interface IAnthologyStudioData {
+  count: number;
+  studio: IStudioApiResponse;
+}
+/*
+interface IWidgetAnthologyList {
+	data: IAnthologyData[];
+}
+*/
+const AnthologyStudioListWidget = () => {
+  const [tableData, setTableData] = useState<IAnthologyStudioData[]>([]);
+  useEffect(() => {
+    console.log("useEffect");
+    const url = `/api/v2/anthology?view=studio_list`;
+    get<IAnthologyStudioListApiResponse>(url).then(function (json) {
+      const newTree: IAnthologyStudioData[] = json.data.rows.map((item) => {
+        return {
+          count: item.count,
+          studio: item.studio,
+        };
+      });
+      setTableData(newTree);
+    });
+  }, []);
+
+  return (
+    <Card title="作者" size="small">
+      <List
+        itemLayout="vertical"
+        size="small"
+        dataSource={tableData}
+        renderItem={(item) => (
+          <List.Item>
+            <Link to={`/blog/${item.studio.realName}/anthology`}>
+              <Space>
+                <StudioName data={item.studio} />
+                <span>({item.count})</span>
+              </Space>
+            </Link>
+          </List.Item>
+        )}
+      />
+    </Card>
+  );
+};
+
+export default AnthologyStudioListWidget;

+ 60 - 0
dashboard-v6/src/components/anthology/AnthologyTocEdit.tsx

@@ -0,0 +1,60 @@
+import { Divider, Typography } from "antd";
+
+import AnthologyTocTree from "./AnthologyTocTree";
+import { useIntl } from "react-intl";
+import ArticleSkeleton from "../article/components/ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+import AnthologyInfo from "./components/AnthologyInfo";
+import { useAnthology } from "./hooks/useAnthology";
+import EditableTocTree from "./EditableTocTree";
+
+const { Title } = Typography;
+
+interface Props {
+  id?: string;
+  channels?: string[];
+  editorStudioName?: string;
+  onArticleClick?: (anthologyId: string, id: string, target: string) => void;
+}
+
+const AnthologyTocEdit = ({
+  id,
+  channels,
+  editorStudioName,
+  onArticleClick,
+}: Props) => {
+  const { data, loading, errorCode } = useAnthology(id);
+  const intl = useIntl();
+
+  return (
+    <div style={{ padding: 12 }}>
+      {loading && <ArticleSkeleton />}
+      {!loading && errorCode && <ErrorResult code={errorCode} />}
+      {!loading && !errorCode && (
+        <>
+          <AnthologyInfo data={data} />
+          <Divider />
+          <Title level={5}>
+            {intl.formatMessage({ id: "labels.table-of-content" })}
+          </Title>
+
+          <AnthologyTocTree
+            anthologyId={id}
+            channels={channels}
+            onClick={(anthologyId, id, target) =>
+              onArticleClick?.(anthologyId, id, target)
+            }
+          />
+          <EditableTocTree
+            studioName={data?.studio.realName}
+            editorStudioName={editorStudioName}
+            anthologyId={id}
+            anthology={data}
+          />
+        </>
+      )}
+    </div>
+  );
+};
+
+export default AnthologyTocEdit;

+ 6 - 6
dashboard-v6/src/components/anthology/EditableTocTree.tsx

@@ -28,14 +28,14 @@ import ArticleDrawer from "../article/ArticleDrawer";
 interface IWidget {
   anthologyId?: string;
   studioName?: string;
-  myStudioName?: string;
-  anthology?: IAnthologyDataResponse;
+  editorStudioName?: string;
+  anthology?: IAnthologyDataResponse | null;
 }
-const EditableTocTreeWidget = ({
+const EditableTocTree = ({
   anthologyId,
   anthology,
   studioName,
-  myStudioName,
+  editorStudioName,
 }: IWidget) => {
   const [tocData, setTocData] = useState<ListNodeData[]>([]);
   const [addArticle, setAddArticle] = useState<TreeNodeData>();
@@ -109,7 +109,7 @@ const EditableTocTreeWidget = ({
         addOnArticle={addArticle}
         addFileButton={
           <ArticleListModal
-            studioName={myStudioName}
+            studioName={editorStudioName}
             trigger={<Button icon={<FolderOpenOutlined />}>添加</Button>}
             multiple={false}
             onSelect={(id: string, title: string) => {
@@ -213,4 +213,4 @@ const EditableTocTreeWidget = ({
   );
 };
 
-export default EditableTocTreeWidget;
+export default EditableTocTree;

+ 33 - 0
dashboard-v6/src/components/anthology/components/AnthologyInfo.tsx

@@ -0,0 +1,33 @@
+import { Space, Typography } from "antd";
+import type { IAnthologyDataResponse } from "../../../api/Article";
+import Studio from "../../auth/Studio";
+import TimeShow from "../../general/TimeShow";
+import Marked from "../../general/Marked";
+
+const { Title, Paragraph, Text } = Typography;
+
+interface IWidget {
+  data?: IAnthologyDataResponse | null;
+}
+const AnthologyInfo = ({ data }: IWidget) => {
+  return (
+    <div>
+      <Title level={4}>{data?.title}</Title>
+
+      <Text type="secondary">{data?.subtitle}</Text>
+
+      <Paragraph>
+        <Space>
+          <Studio data={data?.studio} />
+          <TimeShow updatedAt={data?.updated_at} />
+        </Space>
+      </Paragraph>
+
+      <Paragraph>
+        <Marked text={data?.summary} />
+      </Paragraph>
+    </div>
+  );
+};
+
+export default AnthologyInfo;

+ 78 - 0
dashboard-v6/src/components/anthology/hooks/useAnthology.tsx

@@ -0,0 +1,78 @@
+// hooks/useAnthology.ts
+/**
+ * useAnthology
+ *
+ * 获取合集详情数据
+ *
+ * @param id 合集 ID(undefined 时不发请求)
+ * @returns
+ *   - data      原始响应数据 IAnthologyDataResponse,未请求或失败时为 null
+ *   - loading   请求进行中
+ *   - errorCode 请求失败时的错误码,无错误时为 null
+ *   - refresh   手动重新请求
+ *
+ * @example
+ * const { data, loading, errorCode, refresh } = useAnthology(id);
+ *
+ * if (loading) return <Skeleton />;
+ * if (errorCode) return <ErrorResult code={errorCode} />;
+ * if (data) return <View title={data.title} />;
+ */
+import { useState, useEffect, useCallback } from "react";
+import {
+  fetchAnthology,
+  type IAnthologyDataResponse,
+} from "../../../api/Article";
+
+interface UseAnthologyResult {
+  data: IAnthologyDataResponse | null;
+  loading: boolean;
+  errorCode: number | null;
+  refresh: () => void;
+}
+
+export const useAnthology = (id?: string): UseAnthologyResult => {
+  const [data, setData] = useState<IAnthologyDataResponse | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+  const [tick, setTick] = useState(0);
+
+  const refresh = useCallback(() => setTick((t) => t + 1), []);
+
+  useEffect(() => {
+    if (!id) return;
+
+    let active = true;
+
+    const fetchData = async () => {
+      setLoading(true);
+      setErrorCode(null);
+
+      try {
+        const res = await fetchAnthology(id);
+        if (!active) return;
+
+        if (!res.ok) {
+          setErrorCode(-1);
+          return;
+        }
+
+        setData(res.data);
+      } catch (err) {
+        if (active) {
+          setErrorCode(err as number);
+        }
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [id, tick]);
+
+  return { data, loading, errorCode, refresh };
+};

+ 91 - 0
dashboard-v6/src/components/article/AnchorNav.tsx

@@ -0,0 +1,91 @@
+import { Anchor } from "antd";
+import { useEffect, useState, useRef } from "react";
+import { convertToPlain } from "../../utils";
+
+const { Link } = Anchor;
+
+interface HeadingNode {
+  key: string;
+  label: string;
+  level: number;
+  children?: HeadingNode[];
+}
+
+interface Props {
+  open?: boolean;
+  containerSelector?: string; // 可指定扫描范围
+}
+
+/** 构建树结构 */
+function buildTree(list: HeadingNode[]): HeadingNode[] {
+  const root: HeadingNode = { key: "root", label: "", level: 0, children: [] };
+  const stack = [root];
+
+  for (const node of list) {
+    while (stack.length && stack[stack.length - 1].level >= node.level) {
+      stack.pop();
+    }
+
+    const parent = stack[stack.length - 1];
+    parent.children ??= [];
+    parent.children.push(node);
+
+    stack.push(node);
+  }
+
+  return root.children ?? [];
+}
+
+/** 递归渲染 */
+function renderLinks(nodes: HeadingNode[]): React.ReactNode {
+  return nodes.map((node) => (
+    <Link key={node.key} href={node.key} title={node.label}>
+      {node.children && renderLinks(node.children)}
+    </Link>
+  ));
+}
+
+const AnchorNavWidget = ({ open = false, containerSelector }: Props) => {
+  const [tree, setTree] = useState<HeadingNode[]>([]);
+  const containerRef = useRef<HTMLElement | null>(null);
+
+  /** 获取容器 */
+  useEffect(() => {
+    containerRef.current = containerSelector
+      ? document.querySelector(containerSelector)
+      : document.body;
+  }, [containerSelector]);
+
+  /** 扫描 heading */
+  useEffect(() => {
+    if (!open || !containerRef.current) return;
+
+    const headings = Array.from(
+      containerRef.current.querySelectorAll("h1,h2,h3,h4,h5,h6")
+    );
+
+    const list: HeadingNode[] = headings
+      .map((el) => {
+        if (!el.id) return null;
+
+        return {
+          key: `#${el.id}`,
+          label: convertToPlain(el.innerHTML).slice(0, 30),
+          level: Number(el.tagName[1]),
+        };
+      })
+      .filter(Boolean) as HeadingNode[];
+
+    setTree(buildTree(list));
+  }, [open]);
+
+  if (!open || tree.length === 0) return null;
+
+  return (
+    <div className="article_anchor paper_zh">
+      <Anchor offsetTop={50}>{renderLinks(tree)}</Anchor>
+    </div>
+  );
+};
+
+export default AnchorNavWidget;

+ 0 - 226
dashboard-v6/src/components/article/Article.tsx

@@ -1,226 +0,0 @@
-import type { IArticleDataResponse } from "../../api/Article";
-import TypeArticle from "./TypeArticle";
-import TypeAnthology from "./TypeAnthology";
-import TypeTerm from "./TypeTerm";
-import TypePali from "./TypePali";
-import "./article.css";
-import TypePage from "./TypePage";
-import TypeCSPara from "./TypeCSPara";
-import type { ISearchParams } from "../../pages/library/article/show";
-import TypeCourse from "./TypeCourse";
-import { useEffect, useState } from "react";
-import { fullUrl } from "../../utils";
-import TypeSeries from "./TypeSeries";
-import DiscussionCount from "../discussion/DiscussionCount";
-import TypeTask from "./TypeTask";
-
-interface IWidget {
-  type?: ArticleType;
-  articleId?: string;
-  mode?: ArticleMode | null;
-  channelId?: string | null;
-  parentChannels?: string[];
-  book?: string | null;
-  para?: string | null;
-  anthologyId?: string | null;
-  courseId?: string | null;
-  active?: boolean;
-  focus?: string | null;
-  hideInteractive?: boolean;
-  hideTitle?: boolean;
-  isSubWindow?: boolean;
-  onArticleChange?: (
-    type: ArticleType,
-    id: string,
-    target: string,
-    param?: ISearchParams[]
-  ) => void;
-  onLoad?: Function;
-  onAnthologySelect?: Function;
-  onTitle?: Function;
-  onArticleEdit?: Function;
-}
-const ArticleWidget = ({
-  type,
-  book,
-  para,
-  channelId,
-  parentChannels,
-  articleId,
-  anthologyId,
-  courseId,
-  mode = "read",
-  active = false,
-  focus,
-  hideInteractive = false,
-  hideTitle = false,
-  isSubWindow = false,
-  onArticleChange,
-  onLoad,
-  onAnthologySelect,
-  onTitle,
-  onArticleEdit,
-}: IWidget) => {
-  const [currId, setCurrId] = useState(articleId);
-  useEffect(() => setCurrId(articleId), [articleId]);
-
-  return (
-    <div>
-      <DiscussionCount courseId={type === "textbook" ? courseId : undefined} />
-      {type === "article" ? (
-        <TypeArticle
-          isSubWindow={isSubWindow}
-          type={type}
-          articleId={onArticleChange ? articleId : currId}
-          channelId={channelId}
-          parentChannels={parentChannels}
-          mode={mode}
-          anthologyId={anthologyId}
-          active={active}
-          hideInteractive={hideInteractive}
-          hideTitle={hideTitle}
-          onArticleEdit={(value: IArticleDataResponse) => {
-            if (typeof onArticleEdit !== "undefined") {
-              onArticleEdit(value);
-            }
-          }}
-          onArticleChange={onArticleChange}
-          onLoad={(data: IArticleDataResponse) => {
-            if (typeof onLoad !== "undefined") {
-              onLoad(data);
-            }
-            if (typeof onTitle !== "undefined") {
-              onTitle(data.title);
-            }
-          }}
-          onAnthologySelect={(id: string) => {
-            if (typeof onAnthologySelect !== "undefined") {
-              onAnthologySelect(id);
-            }
-          }}
-        />
-      ) : type === "anthology" ? (
-        <TypeAnthology
-          type={type}
-          articleId={onArticleChange ? articleId : currId}
-          channelId={channelId}
-          mode={mode}
-          onArticleChange={(type: ArticleType, id: string, target: string) => {
-            if (typeof onArticleChange !== "undefined") {
-              onArticleChange(type, id, target);
-            }
-          }}
-          onTitle={(value: string) => {
-            if (typeof onTitle !== "undefined") {
-              onTitle(value);
-            }
-          }}
-        />
-      ) : type === "term" ? (
-        <TypeTerm
-          articleId={onArticleChange ? articleId : currId}
-          channelId={channelId}
-          mode={mode}
-          onArticleChange={(type: ArticleType, id: string, target: string) => {
-            if (typeof onArticleChange !== "undefined") {
-              onArticleChange(type, id, target);
-            }
-          }}
-        />
-      ) : type === "chapter" || type === "para" ? (
-        <TypePali
-          type={type}
-          articleId={onArticleChange ? articleId : currId}
-          channelId={channelId}
-          mode={mode}
-          book={book}
-          para={para}
-          focus={focus}
-          onArticleChange={(
-            type: ArticleType,
-            id: string,
-            target: string,
-            param?: ISearchParams[]
-          ) => {
-            if (typeof onArticleChange !== "undefined") {
-              onArticleChange(type, id, target, param);
-            }
-          }}
-          onLoad={(data: IArticleDataResponse) => {
-            if (typeof onLoad !== "undefined") {
-              onLoad(data);
-            }
-          }}
-          onTitle={(value: string) => {
-            if (typeof onTitle !== "undefined") {
-              onTitle(value);
-            }
-          }}
-        />
-      ) : type === "series" ? (
-        <TypeSeries
-          articleId={onArticleChange ? articleId : currId}
-          channelId={channelId}
-          onArticleChange={(
-            type: ArticleType,
-            id: string,
-            target: string,
-            param: ISearchParams[]
-          ) => {
-            if (typeof onArticleChange !== "undefined") {
-              onArticleChange(type, id, target, param);
-            }
-          }}
-        />
-      ) : type === "page" ? (
-        <TypePage
-          articleId={onArticleChange ? articleId : currId}
-          channelId={channelId}
-          focus={focus}
-          mode={mode}
-          onArticleChange={(type: ArticleType, id: string, target: string) => {
-            if (typeof onArticleChange !== "undefined") {
-              onArticleChange(type, id, target);
-            } else {
-              if (target === "_blank") {
-                let url = `/article/page/${id}?mode=${mode}`;
-                if (channelId) {
-                  url += `&channel=${channelId}`;
-                }
-                window.open(fullUrl(url), "_blank");
-              } else {
-                setCurrId(id);
-              }
-            }
-          }}
-        />
-      ) : type === "cs-para" ? (
-        <TypeCSPara
-          articleId={onArticleChange ? articleId : currId}
-          channelId={channelId}
-          mode={mode}
-          onArticleChange={(type: ArticleType, id: string, target: string) => {
-            if (typeof onArticleChange !== "undefined") {
-              onArticleChange(type, id, target);
-            }
-          }}
-        />
-      ) : type === "textbook" ? (
-        <TypeCourse
-          type={type}
-          articleId={onArticleChange ? articleId : currId}
-          channelId={channelId}
-          courseId={courseId}
-          mode={mode}
-          onArticleChange={onArticleChange}
-        />
-      ) : type === "task" ? (
-        <TypeTask articleId={articleId} />
-      ) : (
-        <></>
-      )}
-    </div>
-  );
-};
-
-export default ArticleWidget;

+ 139 - 0
dashboard-v6/src/components/article/ArticleCreate.tsx

@@ -0,0 +1,139 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormSelect,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { Alert, Space, message } from "antd";
+
+import { get, post } from "../../request";
+import type {
+  IAnthologyListResponse,
+  IArticleCreateRequest,
+  IArticleDataResponse,
+  IArticleResponse,
+} from "../../api/Article";
+import LangSelect from "../general/LangSelect";
+import { useEffect, useRef, useState } from "react";
+
+interface IFormData {
+  title: string;
+  lang: string;
+  studio: string;
+  anthologyId?: string;
+  parentId?: string;
+}
+
+interface IWidget {
+  studio?: string;
+  anthologyId?: string;
+  parentId?: string | null;
+  compact?: boolean;
+  onSuccess?: (data: IArticleDataResponse) => void;
+}
+const ArticleCreateWidget = ({
+  studio,
+  parentId,
+  compact = true,
+  onSuccess,
+}: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+  const [parent, setParent] = useState<IArticleDataResponse>();
+  console.log("parentId", parentId);
+  useEffect(() => {
+    if (parentId) {
+      get<IArticleResponse>(`/v2/article/${parentId}`).then((json) => {
+        console.log("article", json);
+
+        if (json.ok) {
+          setParent(json.data);
+        }
+      });
+    }
+  }, []);
+
+  return (
+    <Space orientation="vertical">
+      {parentId ? (
+        <Alert
+          title={`从文章 ${parent?.title} 创建子文章`}
+          type="info"
+          closable
+        />
+      ) : undefined}
+      <ProForm<IFormData>
+        formRef={formRef}
+        onFinish={async (values: IFormData) => {
+          console.log(values);
+          if (typeof studio === "undefined") {
+            return;
+          }
+          values.studio = studio;
+          values.parentId = parentId ? parentId : undefined;
+          const res = await post<IArticleCreateRequest, IArticleResponse>(
+            `/v2/article`,
+            values
+          );
+          console.log(res);
+          if (res.ok) {
+            message.success(intl.formatMessage({ id: "flashes.success" }));
+            if (typeof onSuccess !== "undefined") {
+              onSuccess(res.data);
+              formRef.current?.resetFields(["title"]);
+            }
+          } 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.Group>
+          <LangSelect />
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProFormSelect
+            name={"anthologyId"}
+            label={"加入文集"}
+            hidden={compact}
+            width={"md"}
+            showSearch
+            debounceTime={300}
+            request={async ({ keyWords }) => {
+              console.log("keyWord", keyWords);
+              let url = `/v2/anthology?view=studio&view2=my&name=${studio}`;
+              url += keyWords ? "&search=" + keyWords : "";
+              const res = await get<IAnthologyListResponse>(url);
+              const result = res.data.rows.map((item) => {
+                return {
+                  value: item.uid,
+                  label: item.title,
+                };
+              });
+              console.log("json", result);
+              return result;
+            }}
+          />
+        </ProForm.Group>
+      </ProForm>
+    </Space>
+  );
+};
+
+export default ArticleCreateWidget;

+ 126 - 0
dashboard-v6/src/components/article/ArticleDrawer copy.tsx

@@ -0,0 +1,126 @@
+import { Button, Drawer, Space, Typography } from "antd";
+import React, { useEffect, useState } from "react";
+import { Link } from "react-router";
+
+import Article from "./Article";
+import type { IArticleDataResponse } from "../../api/Article";
+const { Text } = Typography;
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  title?: string;
+  type?: ArticleType;
+  book?: string;
+  para?: string;
+  channelId?: string;
+  articleId?: string;
+  anthologyId?: string;
+  mode?: ArticleMode;
+  open?: boolean;
+  onClose?: () => void;
+  onTitleChange?: (value: string) => void;
+  onArticleEdit?: (value: IArticleDataResponse) => void;
+}
+
+const ArticleDrawerWidget = ({
+  trigger,
+  title,
+  type,
+  book,
+  para,
+  channelId,
+  articleId,
+  anthologyId,
+  mode,
+  open,
+  onClose,
+  onTitleChange,
+  onArticleEdit,
+}: IWidget) => {
+  const [openDrawer, setOpenDrawer] = useState(open);
+  const [drawerTitle, setDrawerTitle] = useState(title);
+  useEffect(() => {
+    setOpenDrawer(open);
+  }, [open]);
+  useEffect(() => {
+    setDrawerTitle(title);
+  }, [title]);
+  const showDrawer = () => {
+    setOpenDrawer(true);
+  };
+
+  const onDrawerClose = () => {
+    setOpenDrawer(false);
+    if (document.getElementsByTagName("body")[0].hasAttribute("style")) {
+      document.getElementsByTagName("body")[0].removeAttribute("style");
+    }
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  const getUrl = (openMode?: string): string => {
+    let url = `/article/${type}/${articleId}?mode=`;
+    url += openMode ? openMode : mode ? mode : "read";
+    url += channelId ? `&channel=${channelId}` : "";
+    url += book ? `&book=${book}` : "";
+    url += para ? `&par=${para}` : "";
+    return url;
+  };
+
+  return (
+    <>
+      <span onClick={() => showDrawer()}>{trigger}</span>
+      <Drawer
+        title={
+          <Text
+            editable={{
+              onChange: (value: string) => {
+                setDrawerTitle(value);
+                if (typeof onTitleChange !== "undefined") {
+                  onTitleChange(value);
+                }
+              },
+            }}
+          >
+            {drawerTitle}
+          </Text>
+        }
+        width={1000}
+        placement="right"
+        onClose={onDrawerClose}
+        open={openDrawer}
+        destroyOnHidden={true}
+        extra={
+          <Space>
+            <Button>
+              <Link to={getUrl()}>在单页面中打开</Link>
+            </Button>
+            <Button>
+              <Link to={getUrl("edit")}>翻译模式</Link>
+            </Button>
+          </Space>
+        }
+      >
+        <Article
+          active={true}
+          type={type as ArticleType}
+          book={book}
+          para={para}
+          channelId={channelId}
+          articleId={articleId}
+          anthologyId={anthologyId}
+          mode={mode}
+          onArticleEdit={(value: IArticleDataResponse) => {
+            setDrawerTitle(value.title_text);
+            if (typeof onArticleEdit !== "undefined") {
+              onArticleEdit(value);
+            }
+          }}
+        />
+      </Drawer>
+    </>
+  );
+};
+
+export default ArticleDrawerWidget;

+ 2 - 20
dashboard-v6/src/components/article/ArticleDrawer.tsx

@@ -7,7 +7,6 @@ import type {
   ArticleType,
   IArticleDataResponse,
 } from "../../api/Article";
-import Article from "./Article";
 const { Text } = Typography;
 
 interface IWidget {
@@ -34,12 +33,10 @@ const ArticleDrawerWidget = ({
   para,
   channelId,
   articleId,
-  anthologyId,
   mode,
   open,
   onClose,
   onTitleChange,
-  onArticleEdit,
 }: IWidget) => {
   const [openDrawer, setOpenDrawer] = useState(open);
   const [drawerTitle, setDrawerTitle] = useState(title);
@@ -90,7 +87,7 @@ const ArticleDrawerWidget = ({
             {drawerTitle}
           </Text>
         }
-        width={1000}
+        size={1000}
         placement="right"
         onClose={onDrawerClose}
         open={openDrawer}
@@ -106,22 +103,7 @@ const ArticleDrawerWidget = ({
           </Space>
         }
       >
-        <Article
-          active={true}
-          type={type as ArticleType}
-          book={book}
-          para={para}
-          channelId={channelId}
-          articleId={articleId}
-          anthologyId={anthologyId}
-          mode={mode}
-          onArticleEdit={(value: IArticleDataResponse) => {
-            setDrawerTitle(value.title_text);
-            if (typeof onArticleEdit !== "undefined") {
-              onArticleEdit(value);
-            }
-          }}
-        />
+        <>mock</>
       </Drawer>
     </>
   );

+ 303 - 0
dashboard-v6/src/components/article/ArticleEdit.tsx

@@ -0,0 +1,303 @@
+import { useRef, useState } from "react";
+
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormSwitch,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+
+import { Alert, Button, Form, message, Result } from "antd";
+
+import { get, put } from "../../request";
+import type {
+  IArticleDataRequest,
+  IArticleDataResponse,
+  IArticleResponse,
+} from "../../api/Article";
+import LangSelect from "../general/LangSelect";
+import PublicitySelect from "../studio/PublicitySelect";
+
+import MDEditor from "@uiw/react-md-editor";
+
+import ArticleEditTools from "./ArticleEditTools";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import "./article.css";
+import type { IStudio } from "../../api/Auth";
+import ArticlePrevDrawer from "./ArticlePrevDrawer";
+
+interface IFormData {
+  uid: string;
+  title: string;
+  subtitle: string;
+  summary?: string | null;
+  content?: string;
+  content_type?: string;
+  status: number;
+  lang: string;
+  to_tpl?: boolean;
+}
+
+interface IWidget {
+  studioName?: string;
+  articleId?: string;
+  anthologyId?: string;
+  resetButton?: "reset" | "cancel";
+  onReady?: (
+    title: string,
+    readonly: boolean,
+    studioName?: string,
+    parentId?: string
+  ) => void;
+  onChange?: (data: IArticleDataResponse) => void;
+  onCancel?: () => void;
+  onSubmit?: (data: IArticleDataResponse) => void;
+}
+
+const ArticleEditWidget = ({
+  studioName,
+  articleId,
+  anthologyId,
+  resetButton = "reset",
+  onReady,
+  onChange,
+  onCancel,
+  onSubmit,
+}: IWidget) => {
+  const intl = useIntl();
+  const [unauthorized, setUnauthorized] = useState(false);
+  const [readonly, setReadonly] = useState(false);
+  const [content, setContent] = useState<string>();
+  const [owner, setOwner] = useState<IStudio>();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+  const [title, setTitle] = useState<string>();
+  const user = useAppSelector(currentUser);
+
+  return unauthorized ? (
+    <Result
+      status="403"
+      title="无权访问"
+      subTitle="您无权访问该内容。您可能没有登录,或者内容的所有者没有给您所需的权限。"
+      extra={<></>}
+    />
+  ) : (
+    <>
+      {readonly ? (
+        <Alert
+          message={`该资源为只读,如果需要修改,请联络拥有者${owner?.nickName}分配权限。`}
+          type="warning"
+          closable
+          action={
+            <Button disabled size="small" type="text">
+              详情
+            </Button>
+          }
+        />
+      ) : undefined}
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <span></span>
+        <ArticleEditTools
+          studioName={studioName}
+          articleId={articleId}
+          title={title}
+        />
+      </div>
+      <ProForm<IFormData>
+        formRef={formRef}
+        submitter={{
+          // 完全自定义整个区域
+          render: (props) => {
+            console.log(props);
+            return [
+              <Button
+                key="rest"
+                onClick={() => {
+                  if (resetButton === "reset") {
+                    props.form?.resetFields();
+                  } else {
+                    if (typeof onCancel !== "undefined") {
+                      onCancel();
+                    }
+                  }
+                }}
+              >
+                {resetButton === "reset" ? "重置" : "取消"}
+              </Button>,
+              <Button
+                type="primary"
+                key="submit"
+                onClick={() => props.form?.submit?.()}
+              >
+                提交
+              </Button>,
+            ];
+          },
+        }}
+        onFinish={async (values: IFormData) => {
+          const request: IArticleDataRequest = {
+            uid: articleId ? articleId : "",
+            title: values.title,
+            subtitle: values.subtitle,
+            summary: values.summary,
+            content: values.content,
+            content_type: "markdown",
+            status: values.status,
+            lang: values.lang,
+            to_tpl: values.to_tpl,
+            anthology_id: anthologyId,
+          };
+          const url = `/v2/article/${articleId}`;
+          console.info("save url", url, request);
+          put<IArticleDataRequest, IArticleResponse>(url, request)
+            .then((res) => {
+              console.debug("save response", res);
+              if (res.ok) {
+                if (typeof onChange !== "undefined") {
+                  onChange(res.data);
+                }
+                if (typeof onSubmit !== "undefined") {
+                  onSubmit(res.data);
+                }
+                formRef.current?.setFieldValue("content", res.data.content);
+                message.success(intl.formatMessage({ id: "flashes.success" }));
+              } else {
+                message.error(res.message);
+              }
+            })
+            .catch((e: IArticleResponse) => {
+              message.error(e.message);
+            });
+        }}
+        request={async () => {
+          const url = `/v2/article/${articleId}`;
+          console.info("url", url);
+          const res = await get<IArticleResponse>(url);
+          console.log("article", res);
+          let mTitle: string,
+            mReadonly = false;
+          if (res.ok) {
+            setOwner(res.data.studio);
+            mReadonly = res.data.role === "editor" ? false : true;
+            setReadonly(mReadonly);
+            mTitle = res.data.title;
+            setContent(res.data.content);
+            setTitle(res.data.title);
+          } else {
+            setUnauthorized(true);
+            mTitle = "无权访问";
+          }
+          if (typeof onReady !== "undefined") {
+            onReady(
+              mTitle,
+              mReadonly,
+              res.data.studio?.realName,
+              res.data.parent_uid
+            );
+          }
+          return {
+            uid: res.data.uid,
+            title: res.data.title,
+            subtitle: res.data.subtitle,
+            summary: res.data.summary,
+            content: res.data.content,
+            content_type: res.data.content_type,
+            lang: res.data.lang,
+            status: res.data.status,
+            studio: res.data.studio,
+          };
+        }}
+      >
+        <ProForm.Group>
+          <ProFormText
+            width="md"
+            name="title"
+            required
+            label={intl.formatMessage({
+              id: "forms.fields.title.label",
+            })}
+            rules={[
+              {
+                required: true,
+                message: intl.formatMessage({
+                  id: "forms.message.title.required",
+                }),
+              },
+            ]}
+          />
+          <ProFormText
+            width="md"
+            name="subtitle"
+            label={intl.formatMessage({
+              id: "forms.fields.subtitle.label",
+            })}
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <LangSelect width="md" />
+          <PublicitySelect
+            width="md"
+            disable={["public_no_list"]}
+            readonly={
+              user?.roles?.includes("basic") || owner?.roles?.includes("basic")
+                ? true
+                : false
+            }
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProFormTextArea
+            name="summary"
+            width="lg"
+            label={intl.formatMessage({
+              id: "forms.fields.summary.label",
+            })}
+          />
+        </ProForm.Group>
+
+        <Form.Item
+          name="content"
+          style={{ width: "100%" }}
+          label={
+            <>
+              {intl.formatMessage({
+                id: "forms.fields.content.label",
+              })}
+              {articleId ? (
+                <ArticlePrevDrawer
+                  trigger={<Button>预览</Button>}
+                  articleId={articleId}
+                  content={content}
+                />
+              ) : undefined}
+            </>
+          }
+        >
+          <MDEditor
+            className="pcd_md_editor paper_zh"
+            onChange={(value: unknown) => {
+              if (typeof value === "string") {
+                setContent(value);
+              }
+            }}
+            height={450}
+            minHeight={200}
+            style={{ width: "100%" }}
+          />
+        </Form.Item>
+
+        <ProForm.Group>
+          <ProFormSwitch
+            name="to_tpl"
+            label="转换为模版"
+            disabled={anthologyId ? false : true}
+          />
+        </ProForm.Group>
+      </ProForm>
+    </>
+  );
+};
+
+export default ArticleEditWidget;

+ 74 - 0
dashboard-v6/src/components/article/ArticleEditDrawer.tsx

@@ -0,0 +1,74 @@
+import { Drawer } from "antd";
+import React, { useEffect, useState } from "react";
+import type { IArticleDataResponse } from "../../api/Article";
+
+import ArticleEdit from "./ArticleEdit";
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  articleId?: string;
+  anthologyId?: string;
+  open?: boolean;
+  onClose?: Function;
+  onChange?: Function;
+}
+
+const ArticleEditDrawerWidget = ({
+  trigger,
+  articleId,
+  anthologyId,
+  open,
+  onClose,
+  onChange,
+}: IWidget) => {
+  const [openDrawer, setOpenDrawer] = useState(open);
+  const [title, setTitle] = useState("loading");
+  const [readonly, setReadonly] = useState(false);
+  const [_studioName, setStudioName] = useState<string>();
+
+  useEffect(() => setOpenDrawer(open), [open]);
+  const showDrawer = () => {
+    setOpenDrawer(true);
+  };
+
+  const onDrawerClose = () => {
+    setOpenDrawer(false);
+    if (document.getElementsByTagName("body")[0].hasAttribute("style")) {
+      document.getElementsByTagName("body")[0].removeAttribute("style");
+    }
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <span onClick={() => showDrawer()}>{trigger}</span>
+      <Drawer
+        title={title + (readonly ? "(只读)" : "")}
+        width={1000}
+        placement="right"
+        onClose={onDrawerClose}
+        open={openDrawer}
+        destroyOnHidden={true}
+      >
+        <ArticleEdit
+          anthologyId={anthologyId}
+          articleId={articleId}
+          onReady={(title: string, readonly: boolean, studio?: string) => {
+            setTitle(title);
+            setReadonly(readonly);
+            setStudioName(studio);
+          }}
+          onChange={(data: IArticleDataResponse) => {
+            if (typeof onChange !== "undefined") {
+              onChange(data);
+            }
+          }}
+        />
+      </Drawer>
+    </>
+  );
+};
+
+export default ArticleEditDrawerWidget;

+ 59 - 0
dashboard-v6/src/components/article/ArticleEditTools.tsx

@@ -0,0 +1,59 @@
+import { Link } from "react-router";
+import { useIntl } from "react-intl";
+import { TeamOutlined } from "@ant-design/icons";
+import { Button, Space } from "antd";
+
+import { ArticleTplModal } from "../template/Builder/ArticleTpl";
+import ShareModal from "../share/ShareModal";
+import { EResType } from "../share/Share";
+import AddToAnthology from "./AddToAnthology";
+import Builder from "../template/Builder/Builder";
+
+interface IWidget {
+  studioName?: string;
+  articleId?: string;
+  title?: string;
+}
+const ArticleEditToolsWidget = ({
+  studioName,
+  articleId,
+  title = "title",
+}: IWidget) => {
+  const intl = useIntl();
+  return (
+    <Space>
+      <Builder trigger={<Button type="link">{"<t>"}</Button>} />
+      {articleId ? (
+        <AddToAnthology
+          trigger={<Button type="link">加入文集</Button>}
+          studioName={studioName}
+          articleIds={[articleId]}
+        />
+      ) : undefined}
+      {articleId ? (
+        <ShareModal
+          trigger={
+            <Button type="link" icon={<TeamOutlined />}>
+              {intl.formatMessage({
+                id: "buttons.share",
+              })}
+            </Button>
+          }
+          resId={articleId}
+          resType={EResType.article}
+        />
+      ) : undefined}
+      <Link to={`/article/article/${articleId}`} target="_blank">
+        {intl.formatMessage({ id: "buttons.open.in.tab" })}
+      </Link>
+      <ArticleTplModal
+        title={title}
+        type="article"
+        articleId={articleId}
+        trigger={<Button type="link">获取模版</Button>}
+      />
+    </Space>
+  );
+};
+
+export default ArticleEditToolsWidget;

+ 596 - 0
dashboard-v6/src/components/article/ArticleList.tsx

@@ -0,0 +1,596 @@
+import { Link } from "react-router";
+import { useIntl } from "react-intl";
+import {
+  Button,
+  Popover,
+  Dropdown,
+  Typography,
+  Modal,
+  message,
+  Space,
+  Table,
+  Badge,
+} from "antd";
+import { type ActionType, ProTable } from "@ant-design/pro-components";
+import {
+  PlusOutlined,
+  DeleteOutlined,
+  TeamOutlined,
+  ExclamationCircleOutlined,
+  FolderAddOutlined,
+  ReconciliationOutlined,
+} from "@ant-design/icons";
+
+import ArticleCreate from "./ArticleCreate";
+import { delete_, get } from "../../request";
+import type { IArticleListResponse, IDeleteResponse } from "../../api/Article";
+import { PublicityValueEnum } from "../studio/table";
+import { useEffect, useRef, useState } from "react";
+
+import Share from "../share/Share";
+
+import AnthologySelect from "../anthology/AnthologySelect";
+import StudioName from "../auth/Studio";
+
+import { getSorterUrl } from "../../utils";
+import TransferCreate from "../transfer/TransferCreate";
+import { TransferOutLinedIcon } from "../../assets/icon";
+import type { IStudio, IUser } from "../../api/Auth";
+import { EResType } from "../share/utils";
+import TplBuilder from "../tpl-builder/TplBuilder";
+import AddToAnthology from "../anthology/AddToAnthology";
+import StatusBadge from "../general/StatusBadge";
+
+const { Text } = Typography;
+
+interface IArticleNumberResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    my: number;
+    collaboration: number;
+  };
+}
+
+interface DataItem {
+  sn: number;
+  id: string;
+  title: string;
+  subtitle: string;
+  summary?: string | null;
+  anthologyCount?: number;
+  anthologyTitle?: string;
+  publicity: number;
+  studio?: IStudio;
+  editor?: IUser;
+  updated_at?: string;
+}
+
+interface IWidget {
+  studioName?: string;
+  editable?: boolean;
+  multiple?: boolean;
+  onSelect?: (
+    id: string,
+    title: string,
+    event: React.MouseEvent<HTMLElement, MouseEvent>
+  ) => void;
+  // 受控参数(可选),不传则组件内部自治
+  tab?: string;
+  page?: number;
+  pageSize?: number;
+  onTabChange?: (tab: string) => void;
+  onPageChange?: (page: number, pageSize: number) => void;
+}
+
+const ArticleList = ({
+  studioName,
+  multiple = true,
+  editable = false,
+  onSelect,
+  tab,
+  page,
+  pageSize,
+  onTabChange,
+  onPageChange,
+}: IWidget) => {
+  const intl = useIntl(); //i18n
+  const [openCreate, setOpenCreate] = useState(false);
+  const [anthologyId, setAnthologyId] = useState<string>();
+  const [myNumber, setMyNumber] = useState<number>(0);
+  const [collaborationNumber, setCollaborationNumber] = useState<number>(0);
+  const [transfer, setTransfer] = useState<string[]>();
+  const [transferName, setTransferName] = useState<string>();
+  const [transferOpen, setTransferOpen] = useState(false);
+
+  // 受控/非受控:外部传入则用外部值,否则用内部 state
+  const [internalTab, setInternalTab] = useState<string>("my");
+  const [internalPage, setInternalPage] = useState<number>(1);
+  const [internalPageSize, setInternalPageSize] = useState<number>(10);
+
+  const currentTab = tab !== undefined ? tab : internalTab;
+  const currentPage = page !== undefined ? page : internalPage;
+  const currentPageSize = pageSize !== undefined ? pageSize : internalPageSize;
+
+  const handleTabChange = (key: string) => {
+    console.log("show course", key);
+    if (onTabChange) {
+      onTabChange(key);
+    } else {
+      setInternalTab(key);
+      setInternalPage(1);
+    }
+    setAnthologyId(undefined);
+  };
+
+  const handlePageChange = (newPage: number, newPageSize: number) => {
+    if (onPageChange) {
+      onPageChange(newPage, newPageSize);
+    } else {
+      setInternalPage(newPage);
+      setInternalPageSize(newPageSize);
+    }
+  };
+
+  useEffect(() => {
+    /**
+     * 获取各种课程的数量
+     */
+    const url = `/api/v2/article-my-number?studio=${studioName}`;
+    console.log("url", url);
+    get<IArticleNumberResponse>(url).then((json) => {
+      if (json.ok) {
+        setMyNumber(json.data.my);
+        setCollaborationNumber(json.data.collaboration);
+      }
+    });
+  }, [studioName]);
+
+  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() {
+        console.log("delete", id);
+        return delete_<IDeleteResponse>(`/api/v2/article/${id}`)
+          .then((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);
+
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [shareResId, setShareResId] = useState<string>("");
+  const [shareResType, setShareResType] = useState<EResType>(EResType.article);
+  const showShareModal = (resId: string, resType: EResType) => {
+    setShareResId(resId);
+    setShareResType(resType);
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <ProTable<DataItem>
+        actionRef={ref}
+        // key 变化时强制重新挂载,使 defaultCurrent 重新生效
+        // tab 切换或 pageSize 改变时都会重置到第1页
+        key={`${currentTab}-${currentPageSize}`}
+        // params 变化会自动触发 request,用于将 tab 传递给 request 函数
+        params={{ tab: currentTab }}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 50,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.title.label",
+            }),
+            dataIndex: "title",
+            key: "title",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            render: (_text, row) => {
+              return (
+                <>
+                  <div key={1}>
+                    <Typography.Link
+                      onClick={(
+                        event: React.MouseEvent<HTMLElement, MouseEvent>
+                      ) => {
+                        if (typeof onSelect !== "undefined") {
+                          onSelect(row.id, row.title, event);
+                        }
+                      }}
+                    >
+                      {row.title}
+                    </Typography.Link>
+                  </div>
+                  <div key={2}>
+                    <Text type="secondary">{row.subtitle}</Text>
+                  </div>
+                  {currentTab !== "my" ? (
+                    <div key={3}>
+                      <Text type="secondary">
+                        <StudioName data={row.studio} />
+                      </Text>
+                    </div>
+                  ) : undefined}
+                </>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "columns.library.anthology.title",
+            }),
+            dataIndex: "subtitle",
+            key: "subtitle",
+            render: (_text, row) => {
+              return (
+                <Space>
+                  {row.anthologyTitle}
+                  {row.anthologyCount ? (
+                    <Badge color="geekblue" count={row.anthologyCount} />
+                  ) : undefined}
+                </Space>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.summary.label",
+            }),
+            dataIndex: "summary",
+            key: "summary",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.publicity.label",
+            }),
+            dataIndex: "publicity",
+            key: "publicity",
+            width: 100,
+            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: 120,
+            valueType: "option",
+            hideInTable: !editable,
+            render: (_text, row, index) => {
+              return [
+                <Dropdown.Button
+                  trigger={["click", "contextMenu"]}
+                  key={index}
+                  type="link"
+                  menu={{
+                    items: [
+                      {
+                        key: "tpl",
+                        label: (
+                          <TplBuilder
+                            title={row.title}
+                            tpl="article"
+                            articleId={row.id}
+                            trigger={<>模版</>}
+                          />
+                        ),
+                        icon: <ReconciliationOutlined />,
+                      },
+                      {
+                        key: "share",
+                        label: intl.formatMessage({
+                          id: "buttons.share",
+                        }),
+                        icon: <TeamOutlined />,
+                      },
+                      {
+                        key: "addToAnthology",
+                        label: (
+                          <AddToAnthology
+                            trigger={<Button type="link">加入文集</Button>}
+                            studioName={studioName}
+                            articleIds={[row.id]}
+                          />
+                        ),
+                        icon: <FolderAddOutlined />,
+                      },
+                      {
+                        key: "transfer",
+                        label: intl.formatMessage({
+                          id: "columns.studio.transfer.title",
+                        }),
+                        icon: <TransferOutLinedIcon />,
+                      },
+                      {
+                        key: "remove",
+                        label: intl.formatMessage({
+                          id: "buttons.delete",
+                        }),
+                        icon: <DeleteOutlined />,
+                        danger: true,
+                      },
+                    ],
+                    onClick: (e) => {
+                      switch (e.key) {
+                        case "share":
+                          showShareModal(row.id, EResType.article);
+                          break;
+                        case "remove":
+                          showDeleteConfirm(row.id, row.title);
+                          break;
+                        case "transfer":
+                          setTransfer([row.id]);
+                          setTransferName(row.title);
+                          setTransferOpen(true);
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                >
+                  <Link
+                    key={index}
+                    to={`/article/article/${row.id}`}
+                    target="_blank"
+                  >
+                    {intl.formatMessage({
+                      id: "buttons.view",
+                    })}
+                  </Link>
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        rowSelection={
+          multiple
+            ? {
+                // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+                // 注释该行则默认不显示下拉选项
+                selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+              }
+            : undefined
+        }
+        tableAlertRender={({ selectedRowKeys, onCleanSelected }) => (
+          <Space size={24}>
+            <span>
+              {intl.formatMessage({ id: "buttons.selected" })}
+              {selectedRowKeys.length}
+              <Button type="link" onClick={onCleanSelected}>
+                {intl.formatMessage({ id: "buttons.unselect" })}
+              </Button>
+            </span>
+          </Space>
+        )}
+        tableAlertOptionRender={({ selectedRowKeys, onCleanSelected }) => {
+          return (
+            <Space>
+              <Button
+                type="link"
+                onClick={() => {
+                  const resId = selectedRowKeys.map((item) => item.toString());
+                  setTransfer(resId);
+                  setTransferName(resId.length + "个文章");
+                  setTransferOpen(true);
+                }}
+              >
+                转让
+              </Button>
+              <AddToAnthology
+                studioName={studioName}
+                trigger={<Button type="link">加入文集</Button>}
+                articleIds={selectedRowKeys.map((item) => item.toString())}
+                onFinally={() => {
+                  onCleanSelected();
+                }}
+              />
+            </Space>
+          );
+        }}
+        request={async (params = {}, sorter) => {
+          // tab 从 params 读取(由 ProTable 的 params prop 注入)
+          const tab = params.tab ?? currentTab;
+          let url = `/api/v2/article?view=studio&view2=${tab}&name=${studioName}`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : currentPageSize);
+          url += `&limit=${params.pageSize ?? currentPageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+
+          if (typeof anthologyId !== "undefined") {
+            url += "&anthology=" + anthologyId;
+          }
+
+          url += getSorterUrl(sorter);
+          console.log("url", url);
+          const res = await get<IArticleListResponse>(url);
+          const items: DataItem[] = res.data.rows.map((item, id) => {
+            return {
+              sn: id + offset + 1,
+              id: item.uid,
+              title: item.title,
+              subtitle: item.subtitle,
+              summary: item.summary,
+              anthologyCount: item.anthology_count,
+              anthologyTitle: item.anthology_first?.title,
+              publicity: item.status,
+              updated_at: item.updated_at,
+              studio: item.studio,
+              editor: item.editor,
+            };
+          });
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          // 用 defaultCurrent / defaultPageSize(非受控)避免与 ProTable 内部状态冲突
+          defaultCurrent: currentPage,
+          defaultPageSize: currentPageSize,
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        // 用 table 级别的 onChange 捕获分页事件,只在用户操作时触发一次,不会循环
+        onChange={(pagination) => {
+          handlePageChange(
+            pagination.current ?? 1,
+            pagination.pageSize ?? currentPageSize
+          );
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          currentTab === "my" ? (
+            <AnthologySelect
+              studioName={studioName}
+              onSelect={(value: string) => {
+                setAnthologyId(value);
+                ref.current?.reload();
+              }}
+            />
+          ) : undefined,
+          <Popover
+            content={
+              <ArticleCreate
+                studio={studioName}
+                anthologyId={anthologyId}
+                onSuccess={() => {
+                  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: currentTab,
+            items: [
+              {
+                key: "my",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.this-studio" })}
+                    <StatusBadge
+                      count={myNumber}
+                      active={currentTab === "my"}
+                    />
+                  </span>
+                ),
+              },
+              {
+                key: "collaboration",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.collaboration" })}
+                    <StatusBadge
+                      count={collaborationNumber}
+                      active={currentTab === "collaboration"}
+                    />
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              handleTabChange(key as string);
+            },
+          },
+        }}
+      />
+
+      <Modal
+        destroyOnHidden={true}
+        width={700}
+        title={intl.formatMessage({ id: "labels.collaboration" })}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <Share resId={shareResId} resType={shareResType} />
+      </Modal>
+
+      <TransferCreate
+        studioName={studioName}
+        resId={transfer}
+        resType="article"
+        resName={transferName}
+        open={transferOpen}
+        onOpenChange={(visible: boolean) => setTransferOpen(visible)}
+      />
+    </>
+  );
+};
+
+export default ArticleList;

+ 87 - 0
dashboard-v6/src/components/article/ArticlePrevDrawer.tsx

@@ -0,0 +1,87 @@
+import { Drawer, Typography } from "antd";
+import React, { useEffect, useState } from "react";
+import { put } from "../../request";
+import type { IArticleDataResponse, IArticleResponse } from "../../api/Article";
+import ArticleLayout from "./components/ArticleLayout";
+
+const { Paragraph } = Typography;
+
+interface IArticlePrevRequest {
+  content: string;
+}
+interface IWidget {
+  trigger?: React.ReactNode;
+  title?: React.ReactNode;
+  content?: string;
+  articleId: string;
+}
+
+const ArticlePrevDrawerWidget = ({
+  trigger,
+  title,
+  content,
+  articleId,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [open, setOpen] = useState(false);
+  const [errorMsg, setErrorMsg] = useState<string>();
+
+  const showDrawer = () => {
+    setOpen(true);
+  };
+
+  const onClose = () => {
+    setOpen(false);
+  };
+
+  useEffect(() => {
+    put<IArticlePrevRequest, IArticleResponse>(
+      `/v2/article-preview/${articleId}`,
+      {
+        content: content ? content : "",
+      }
+    )
+      .then((res) => {
+        console.log("save response", res);
+        if (res.ok) {
+          setArticleData(res.data);
+        } else {
+          setErrorMsg(res.message);
+        }
+      })
+      .catch((e: IArticleResponse) => {
+        setErrorMsg(e.message);
+      });
+  }, [articleId, content]);
+
+  return (
+    <>
+      <span onClick={() => showDrawer()}>{trigger}</span>
+      <Drawer
+        title={title}
+        width={900}
+        placement="right"
+        onClose={onClose}
+        open={open}
+        destroyOnHidden={true}
+      >
+        <Paragraph type="danger">{errorMsg}</Paragraph>
+        {articleData ? (
+          <ArticleLayout
+            title={articleData.title}
+            subTitle={articleData.subtitle}
+            summary={articleData.summary}
+            content={articleData.content ? articleData.content : ""}
+            html={articleData.html ? [articleData.html] : []}
+            created_at={articleData.created_at}
+            updated_at={articleData.updated_at}
+          />
+        ) : (
+          <></>
+        )}
+      </Drawer>
+    </>
+  );
+};
+
+export default ArticlePrevDrawerWidget;

+ 247 - 0
dashboard-v6/src/components/article/ArticleReader.tsx

@@ -0,0 +1,247 @@
+import { useEffect, useState } from "react";
+import { Divider, Space, Tag } from "antd";
+
+import { get } from "../../request";
+import type {
+  ArticleMode,
+  ArticleType,
+  IArticleNavData,
+  IArticleNavResponse,
+} from "../../api/Article";
+
+import "./article.css";
+
+import ErrorResult from "../general/ErrorResult";
+
+import TypeArticleReaderToolbar from "./components/ArticleReaderToolbar";
+import type { TTarget } from "../../types";
+import TocTree from "./components/TocTree";
+import PaliText from "../general/PaliText";
+import type { IFirstAnthology } from "./components/ArticleLayout";
+import ArticleSkeleton from "./components/ArticleSkeleton";
+import ArticleLayout from "./components/ArticleLayout";
+import NavigateButton from "./components/NavigateButton";
+import TocPath from "../tipitaka/TocPath";
+import { useArticle } from "./hooks/useArticle";
+
+interface IWidget {
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  parentChannels?: string[];
+  anthologyId?: string | null;
+  active?: boolean;
+  hideInteractive?: boolean;
+  hideTitle?: boolean;
+  isSubWindow?: boolean;
+  onArticleChange?: (type: ArticleType, id: string, target?: TTarget) => void;
+  onAnthologySelect?: (
+    id: string,
+    e: React.MouseEvent<HTMLElement, MouseEvent>
+  ) => void;
+  onEdit?: () => void;
+}
+const ArticleReader = ({
+  articleId,
+  channelId,
+  anthologyId,
+  mode = "read",
+  hideInteractive = false,
+  hideTitle = false,
+  isSubWindow = false,
+  onArticleChange,
+  onAnthologySelect,
+  onEdit,
+}: IWidget) => {
+  const [nav, setNav] = useState<IArticleNavData>();
+  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+
+  const { data, loading, errorCode, refresh } = useArticle(articleId, {
+    mode: srcDataMode,
+    channelIds: channelId ? channelId?.split("_") : [],
+  });
+
+  const articleData = data;
+
+  let articleHtml = ["<span />"];
+  if (articleData?.html) {
+    articleHtml = [articleData.html];
+  } else if (articleData?.content) {
+    articleHtml = [articleData.content];
+  } else {
+    articleHtml = [""];
+  }
+
+  useEffect(() => {
+    const url = `/v2/nav-article/${articleId}_${anthologyId}`;
+    console.info("api request", url);
+    get<IArticleNavResponse>(url)
+      .then((json) => {
+        console.debug("api response", json);
+        if (json.ok) {
+          setNav(json.data);
+        }
+      })
+      .catch((e) => {
+        console.error(e);
+      });
+  }, [anthologyId, articleId]);
+
+  let anthology: IFirstAnthology | undefined;
+  if (articleData?.anthology_count && articleData.anthology_first) {
+    anthology = {
+      id: articleData.anthology_first.uid,
+      title: articleData.anthology_first.title,
+      count: articleData?.anthology_count,
+    };
+  }
+
+  const title = articleData?.title_text ?? articleData?.title;
+
+  let endOfChapter = false;
+  if (nav?.curr && nav?.next) {
+    if (nav?.curr?.level > nav?.next?.level) {
+      endOfChapter = true;
+    }
+  }
+
+  let topOfChapter = false;
+  if (nav?.curr && nav?.prev) {
+    if (nav?.curr?.level > nav?.prev?.level) {
+      topOfChapter = true;
+    }
+  }
+
+  return (
+    <div>
+      {loading ? (
+        <ArticleSkeleton />
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} />
+      ) : (
+        <>
+          <TypeArticleReaderToolbar
+            title={title}
+            articleId={articleId}
+            anthologyId={anthologyId}
+            role={articleData?.role}
+            isSubWindow={isSubWindow}
+            onRefresh={refresh}
+            onEdit={() => {
+              if (typeof onEdit !== "undefined") {
+                onEdit();
+              }
+            }}
+            onAnthologySelect={(
+              id: string,
+              e: React.MouseEvent<HTMLElement, MouseEvent>
+            ) => {
+              if (typeof onAnthologySelect !== "undefined") {
+                onAnthologySelect(id, e);
+              }
+            }}
+          />
+          <TocPath
+            data={articleData?.path}
+            channels={[]}
+            onChange={(node, e) => {
+              let newType: ArticleType = "article";
+              if (node.level === 0) {
+                newType = "anthology";
+              }
+              if (typeof onArticleChange !== "undefined") {
+                if (node.key) {
+                  const newArticleId = node.key;
+                  const target = e.ctrlKey || e.metaKey ? "_blank" : "_self";
+                  onArticleChange(newType, newArticleId, target);
+                }
+              }
+            }}
+          />
+          <ArticleLayout
+            title={title}
+            subTitle={articleData?.subtitle}
+            summary={articleData?.summary}
+            content={articleData ? articleData.content : ""}
+            html={articleHtml}
+            created_at={articleData?.created_at}
+            updated_at={articleData?.updated_at}
+            anthology={anthology}
+            hideTitle={hideTitle}
+          />
+          <Divider />
+          <TocTree
+            treeData={articleData?.toc?.map((item) => {
+              const strTitle = item.title ? item.title : item.pali_title;
+              const key = item.key
+                ? item.key
+                : `${item.book}-${item.paragraph}`;
+              const progress = item.progress?.map((item, id) => (
+                <Tag key={id}>{Math.round(item * 100) + "%"}</Tag>
+              ));
+              return {
+                key: key,
+                title: (
+                  <Space>
+                    <PaliText text={strTitle === "" ? "[unnamed]" : strTitle} />
+                    {progress}
+                  </Space>
+                ),
+                level: item.level,
+              };
+            })}
+            onClick={(
+              id: string,
+              e: React.MouseEvent<HTMLSpanElement, MouseEvent>
+            ) => {
+              const target = e.ctrlKey || e.metaKey ? "_blank" : "_self";
+              if (typeof onArticleChange !== "undefined") {
+                onArticleChange("article", id, target);
+              }
+            }}
+          />
+          <Divider />
+          <NavigateButton
+            prevTitle={nav?.prev?.title}
+            nextTitle={nav?.next?.title}
+            topOfChapter={topOfChapter}
+            endOfChapter={endOfChapter}
+            path={articleData?.path}
+            onNext={() => {
+              if (onArticleChange && nav?.next?.article_id) {
+                onArticleChange("article", nav?.next?.article_id);
+              }
+            }}
+            onPrev={() => {
+              if (onArticleChange && nav?.prev?.article_id) {
+                onArticleChange("article", nav?.prev?.article_id);
+              }
+            }}
+            onPathChange={(key: string) => {
+              if (typeof onArticleChange !== "undefined") {
+                const node = articleData?.path?.find(
+                  (value) => value.key === key
+                );
+                if (node) {
+                  let newType: ArticleType = "article";
+                  if (node.level === 0) {
+                    newType = "anthology";
+                  }
+                  if (node.key) {
+                    onArticleChange(newType, node.key, "_self");
+                  }
+                }
+              }
+            }}
+          />
+          {hideInteractive ? <></> : <></>}
+        </>
+      )}
+    </div>
+  );
+  /**
+   * <InteractiveArea resType={"article"} resId={articleId} />
+   */
+};
+
+export default ArticleReader;

+ 333 - 0
dashboard-v6/src/components/article/ArticleReaderTest.tsx

@@ -0,0 +1,333 @@
+import { useState } from "react";
+import {
+  Card,
+  Form,
+  Input,
+  Select,
+  Switch,
+  Button,
+  Divider,
+  Tag,
+  Space,
+  Typography,
+  Row,
+  Col,
+  Badge,
+  theme,
+  ConfigProvider,
+} from "antd";
+import {
+  PlayCircleOutlined,
+  ReloadOutlined,
+  CodeOutlined,
+  EyeOutlined,
+} from "@ant-design/icons";
+import ArticleReader from "./ArticleReader";
+import type { ArticleMode } from "../../api/Article";
+
+const { darkAlgorithm } = theme;
+const { Title, Text } = Typography;
+const { useToken } = theme;
+
+interface ITestParams {
+  articleId: string;
+  mode: ArticleMode;
+  channelId: string;
+  anthologyId: string;
+  hideInteractive: boolean;
+  hideTitle: boolean;
+  isSubWindow: boolean;
+  parentChannels: string;
+}
+
+const DEFAULT_PARAMS: ITestParams = {
+  articleId: "",
+  mode: "read",
+  channelId: "",
+  anthologyId: "",
+  hideInteractive: false,
+  hideTitle: false,
+  isSubWindow: false,
+  parentChannels: "",
+};
+
+const ARTICLE_MODE_OPTIONS: {
+  label: string;
+  value: ArticleMode;
+  color: string;
+}[] = [
+  { label: "read", value: "read", color: "blue" },
+  { label: "edit", value: "edit", color: "orange" },
+  { label: "wbw", value: "wbw", color: "purple" },
+  { label: "auto", value: "auto", color: "green" },
+];
+
+const BOOL_FIELDS: { name: keyof ITestParams; label: string }[] = [
+  { name: "hideInteractive", label: "hideInteractive" },
+  { name: "hideTitle", label: "hideTitle" },
+  { name: "isSubWindow", label: "isSubWindow" },
+];
+
+const ArticleReaderTest = () => {
+  const { token } = useToken();
+  const [form] = Form.useForm<ITestParams>();
+  const [activeParams, setActiveParams] = useState<ITestParams | null>(null);
+  const [renderKey, setRenderKey] = useState(0);
+  const [showParams, setShowParams] = useState(false);
+
+  const handleRun = () => {
+    form.validateFields().then((values) => {
+      setActiveParams({ ...values });
+      setRenderKey((k) => k + 1);
+    });
+  };
+
+  const handleReset = () => {
+    form.resetFields();
+    setActiveParams(null);
+  };
+
+  return (
+    <div style={{ padding: token.paddingLG }}>
+      {/* Header */}
+      <Space align="center" style={{ marginBottom: token.marginLG }}>
+        <Badge status="processing" />
+        <Title level={4} style={{ margin: 0 }}>
+          {"<ArticleReader />"} Testbench
+        </Title>
+      </Space>
+
+      <Row gutter={token.marginLG}>
+        {/* Control Panel */}
+
+        <Col xs={24} lg={8}>
+          {/* 左侧栏 — 局部 dark 主题 */}
+          <ConfigProvider theme={{ algorithm: darkAlgorithm }}>
+            <Card
+              title={
+                <Space>
+                  <CodeOutlined />
+                  <span>Props</span>
+                </Space>
+              }
+              size="small"
+            >
+              <Form
+                form={form}
+                layout="vertical"
+                initialValues={DEFAULT_PARAMS}
+              >
+                <Form.Item
+                  label="articleId"
+                  name="articleId"
+                  rules={[{ required: true, message: "articleId 必填" }]}
+                >
+                  <Input placeholder="e.g. article-001" />
+                </Form.Item>
+
+                <Form.Item label="mode" name="mode">
+                  <Select
+                    options={ARTICLE_MODE_OPTIONS.map((opt) => ({
+                      value: opt.value,
+                      label: <Tag color={opt.color}>{opt.label}</Tag>,
+                    }))}
+                  />
+                </Form.Item>
+
+                <Form.Item label="channelId" name="channelId">
+                  <Input placeholder="e.g. channel_001" />
+                </Form.Item>
+
+                <Form.Item label="anthologyId" name="anthologyId">
+                  <Input placeholder="e.g. anthology-001" />
+                </Form.Item>
+
+                <Form.Item
+                  label={
+                    <Space size={4}>
+                      <span>parentChannels</span>
+                      <Text
+                        type="secondary"
+                        style={{ fontSize: token.fontSizeSM }}
+                      >
+                        (逗号分隔)
+                      </Text>
+                    </Space>
+                  }
+                  name="parentChannels"
+                >
+                  <Input placeholder="e.g. ch1,ch2,ch3" />
+                </Form.Item>
+
+                <Divider />
+
+                {BOOL_FIELDS.map(({ name, label }) => (
+                  <Form.Item
+                    key={name}
+                    name={name}
+                    valuePropName="checked"
+                    style={{ marginBottom: token.marginSM }}
+                  >
+                    <Space
+                      style={{ justifyContent: "space-between", width: "100%" }}
+                    >
+                      <Text>{label}</Text>
+                      <Switch size="small" />
+                    </Space>
+                  </Form.Item>
+                ))}
+
+                <Divider />
+
+                <Space orientation="vertical" style={{ width: "100%" }}>
+                  <Button
+                    type="primary"
+                    icon={<PlayCircleOutlined />}
+                    onClick={handleRun}
+                    block
+                  >
+                    RUN
+                  </Button>
+                  <Button icon={<ReloadOutlined />} onClick={handleReset} block>
+                    Reset
+                  </Button>
+                </Space>
+              </Form>
+            </Card>
+          </ConfigProvider>
+          {/* Active params display */}
+          {activeParams && (
+            <Card
+              size="small"
+              style={{ marginTop: token.marginSM }}
+              title={
+                <Space
+                  style={{ justifyContent: "space-between", width: "100%" }}
+                >
+                  <Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
+                    ACTIVE PROPS
+                  </Text>
+                  <Button
+                    type="link"
+                    size="small"
+                    icon={<EyeOutlined />}
+                    onClick={() => setShowParams((v) => !v)}
+                  >
+                    {showParams ? "收起" : "展开"}
+                  </Button>
+                </Space>
+              }
+            >
+              {showParams ? (
+                <pre
+                  style={{
+                    margin: 0,
+                    fontSize: token.fontSizeSM,
+                    whiteSpace: "pre-wrap",
+                    wordBreak: "break-all",
+                  }}
+                >
+                  {JSON.stringify(activeParams, null, 2)}
+                </pre>
+              ) : (
+                <Space wrap size={[4, 4]}>
+                  {Object.entries(activeParams)
+                    .filter(([, v]) => v !== "" && v !== false)
+                    .map(([k, v]) => (
+                      <Tag key={k}>
+                        {k}={String(v)}
+                      </Tag>
+                    ))}
+                </Space>
+              )}
+            </Card>
+          )}
+        </Col>
+
+        {/* Preview Panel */}
+        <Col xs={24} lg={16}>
+          <Card
+            title={
+              <Space>
+                <EyeOutlined />
+                <span>Preview</span>
+                {activeParams && (
+                  <Tag
+                    color={
+                      ARTICLE_MODE_OPTIONS.find(
+                        (o) => o.value === activeParams.mode
+                      )?.color
+                    }
+                  >
+                    {activeParams.mode}
+                  </Tag>
+                )}
+              </Space>
+            }
+            size="small"
+            style={{ minHeight: 480 }}
+          >
+            {!activeParams ? (
+              <div
+                style={{
+                  display: "flex",
+                  alignItems: "center",
+                  justifyContent: "center",
+                  minHeight: 360,
+                  gap: token.marginSM,
+                }}
+              >
+                <PlayCircleOutlined
+                  style={{ fontSize: 32, color: token.colorTextDisabled }}
+                />
+                <Text type="secondary">填写参数后点击 RUN 渲染组件</Text>
+              </div>
+            ) : (
+              <ArticleReader
+                key={renderKey}
+                articleId={activeParams.articleId || undefined}
+                mode={activeParams.mode}
+                channelId={activeParams.channelId || null}
+                anthologyId={activeParams.anthologyId || null}
+                parentChannels={
+                  activeParams.parentChannels
+                    ? activeParams.parentChannels
+                        .split(",")
+                        .map((s) => s.trim())
+                        .filter(Boolean)
+                    : []
+                }
+                hideInteractive={activeParams.hideInteractive}
+                hideTitle={activeParams.hideTitle}
+                isSubWindow={activeParams.isSubWindow}
+                onArticleChange={(type, id, target) => {
+                  console.log("[onArticleChange]", { type, id, target });
+                }}
+                onAnthologySelect={(id) => {
+                  console.log("[onAnthologySelect]", { id });
+                }}
+                onEdit={() => {
+                  console.log("[onEdit]");
+                }}
+              />
+            )}
+          </Card>
+
+          <Text
+            type="secondary"
+            style={{
+              display: "block",
+              marginTop: token.marginXS,
+              fontSize: token.fontSizeSM,
+            }}
+          >
+            💡 onArticleChange / onAnthologySelect / onEdit
+            回调输出至浏览器控制台
+          </Text>
+        </Col>
+      </Row>
+    </div>
+  );
+};
+
+export default ArticleReaderTest;

+ 15 - 52
dashboard-v6/src/components/article/TypeAnthology.tsx

@@ -1,13 +1,10 @@
-import type { ArticleMode, ArticleType } from "./Article";
-import AnthologyDetail from "./AnthologyDetail";
 import "./article.css";
-import { useState, useMemo } from "react";
-import ErrorResult from "../general/ErrorResult";
-import ArticleSkeleton from "./ArticleSkeleton";
+
+import type { ArticleMode } from "../../api/Article";
+import AnthologyDetail from "../anthology/AnthologyDetail";
 
 interface IWidget {
-  type?: ArticleType;
-  articleId?: string;
+  id?: string;
   mode?: ArticleMode | null;
   channelId?: string | null;
   onArticleChange?: (
@@ -16,55 +13,21 @@ interface IWidget {
     target: string,
     extra?: { anthologyId?: string }
   ) => void;
-  onFinal?: () => void;
-  onLoad?: () => void;
-  onTitle?: (title: string) => void;
 }
 
-const TypeAnthologyWidget = ({
-  channelId,
-  articleId,
-  onArticleChange,
-  onTitle,
-}: IWidget) => {
-  const [loading, setLoading] = useState(false);
-  const [errorCode, setErrorCode] = useState<number | null>(null);
-
-  /** ✅ 避免每次 render 都 split */
-  const channels = useMemo(
-    () => (channelId ? channelId.split("_") : undefined),
-    [channelId]
-  );
+const TypeAnthologyWidget = ({ channelId, id, onArticleChange }: IWidget) => {
+  const channels = channelId ? channelId.split("_") : undefined;
 
   return (
-    <div>
-      {loading && <ArticleSkeleton />}
-
-      {!loading && errorCode && <ErrorResult code={errorCode} />}
-
-      {!errorCode && (
-        <AnthologyDetail
-          visible={!loading}
-          channels={channels}
-          aid={articleId}
-          onArticleClick={(anthologyId, articleId, target) => {
-            onArticleChange?.("article", articleId, target, {
-              anthologyId,
-            });
-          }}
-          onLoading={setLoading}
-          onError={(error: unknown) => {
-            console.error(error);
-            //TODO get real error code
-            setErrorCode(404);
-            //setErrorCode(message); //old code
-          }}
-          onTitle={(value) => {
-            onTitle?.(value);
-          }}
-        />
-      )}
-    </div>
+    <AnthologyDetail
+      channels={channels}
+      id={id}
+      onArticleClick={(anthologyId, articleId, target) => {
+        onArticleChange?.("article", articleId, target, {
+          anthologyId,
+        });
+      }}
+    />
   );
 };
 

+ 15 - 16
dashboard-v6/src/components/article/TypeArticle.tsx

@@ -1,13 +1,17 @@
 import { useState } from "react";
 import { Modal } from "antd";
 import { ExclamationCircleOutlined } from "@ant-design/icons";
-import type { IArticleDataResponse } from "../../api/Article";
-import type { ArticleMode, ArticleType } from "./Article";
-import TypeArticleReader from "./TypeArticleReader";
+import type {
+  ArticleMode,
+  ArticleType,
+  IArticleDataResponse,
+} from "../../api/Article";
+
+import TypeArticleReader from "./ArticleReader";
 import ArticleEdit from "./ArticleEdit";
+import type { TTarget } from "../../types";
 
 interface IWidget {
-  type?: ArticleType;
   articleId?: string;
   mode?: ArticleMode | null;
   channelId?: string | null;
@@ -17,13 +21,15 @@ interface IWidget {
   hideInteractive?: boolean;
   hideTitle?: boolean;
   isSubWindow?: boolean;
-  onArticleChange?: Function;
-  onArticleEdit?: Function;
-  onLoad?: Function;
-  onAnthologySelect?: Function;
+  onArticleChange?: (type: ArticleType, id: string, target?: TTarget) => void;
+  onArticleEdit?: (value: IArticleDataResponse) => void;
+  onLoad?: (data: IArticleDataResponse) => void;
+  onAnthologySelect?: (
+    id: string,
+    e: React.MouseEvent<HTMLElement, MouseEvent>
+  ) => void;
 }
 const TypeArticleWidget = ({
-  type,
   channelId,
   parentChannels,
   articleId,
@@ -34,7 +40,6 @@ const TypeArticleWidget = ({
   hideTitle = false,
   isSubWindow = false,
   onArticleChange,
-  onLoad,
   onAnthologySelect,
   onArticleEdit,
 }: IWidget) => {
@@ -66,7 +71,6 @@ const TypeArticleWidget = ({
       ) : (
         <TypeArticleReader
           isSubWindow={isSubWindow}
-          type={type}
           channelId={channelId}
           parentChannels={parentChannels}
           articleId={articleId}
@@ -76,11 +80,6 @@ const TypeArticleWidget = ({
           hideInteractive={hideInteractive}
           hideTitle={hideTitle}
           onArticleChange={onArticleChange}
-          onLoad={(data: IArticleDataResponse) => {
-            if (typeof onLoad !== "undefined") {
-              onLoad(data);
-            }
-          }}
           onAnthologySelect={(
             id: string,
             e: React.MouseEvent<HTMLElement, MouseEvent>

+ 0 - 335
dashboard-v6/src/components/article/TypeArticleReader.tsx

@@ -1,335 +0,0 @@
-import { useEffect, useState } from "react";
-import { Divider, message, Space, Tag } from "antd";
-
-import { get } from "../../request";
-import type {
-  IAnthologyResponse,
-  IArticleDataResponse,
-  IArticleNavData,
-  IArticleNavResponse,
-  IArticleResponse,
-} from "../../api/Article";
-import ArticleView, { type IFirstAnthology } from "./ArticleView";
-import TocTree from "./TocTree";
-import PaliText from "../template/Wbw/PaliText";
-import type { ITocPathNode } from "../../../src/components/tipitaka/TocPath";
-import type { ArticleMode, ArticleType } from "./Article";
-import "./article.css";
-import ArticleSkeleton from "./ArticleSkeleton";
-import ErrorResult from "../general/ErrorResult";
-import NavigateButton from "./NavigateButton";
-import InteractiveArea from "../discussion/InteractiveArea";
-import TypeArticleReaderToolbar from "./TypeArticleReaderToolbar";
-import type { IChannel } from "../channel/Channel";
-
-interface IWidget {
-  type?: ArticleType;
-  articleId?: string;
-  mode?: ArticleMode | null;
-  channelId?: string | null;
-  parentChannels?: string[];
-  anthologyId?: string | null;
-  active?: boolean;
-  hideInteractive?: boolean;
-  hideTitle?: boolean;
-  isSubWindow?: boolean;
-  onArticleChange?: Function;
-  onLoad?: Function;
-  onAnthologySelect?: Function;
-  onEdit?: Function;
-}
-const TypeArticleReaderWidget = ({
-  type,
-  channelId,
-  parentChannels,
-  articleId,
-  anthologyId,
-  mode = "read",
-  active = false,
-  hideInteractive = false,
-  hideTitle = false,
-  isSubWindow = false,
-  onArticleChange,
-  onLoad,
-  onAnthologySelect,
-  onEdit,
-}: IWidget) => {
-  const [articleData, setArticleData] = useState<IArticleDataResponse>();
-  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
-  const [extra, setExtra] = useState(<></>);
-  const [loading, setLoading] = useState(false);
-  const [errorCode, setErrorCode] = useState<number>();
-  const [currPath, setCurrPath] = useState<ITocPathNode[]>();
-  const [nav, setNav] = useState<IArticleNavData>();
-  const [_defaultChannel, setDefaultChannel] = useState<IChannel | null>();
-
-  const channels = channelId?.split("_");
-
-  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
-
-  //创建时获取文集channel
-  useEffect(() => {
-    if (!anthologyId) {
-      return;
-    }
-    if (channelId) {
-      return;
-    }
-    const url = `/v2/anthology/${anthologyId}`;
-    console.info("api request", url);
-
-    get<IAnthologyResponse>(url).then((json) => {
-      if (json.ok) {
-        if (json.data.default_channel) {
-          if (typeof onArticleChange === "undefined") {
-            //自控
-            setDefaultChannel(json.data.default_channel);
-          } else {
-            //外控
-            onArticleChange("article", articleId, null);
-          }
-        } else {
-          setDefaultChannel(null);
-        }
-      }
-    });
-  }, []);
-
-  useEffect(() => {
-    console.log("srcDataMode", srcDataMode);
-    if (!active) {
-      return;
-    }
-
-    if (typeof type === "undefined") {
-      return;
-    }
-
-    let mChannels: string[] = [];
-    if (channelId) {
-      mChannels.push(channelId);
-    }
-    if (parentChannels) {
-      mChannels = [...parentChannels, ...mChannels];
-    }
-    let url = `/v2/article/${articleId}?mode=${srcDataMode}`;
-    if (mChannels.length > 0) {
-      url += `&channel=${mChannels.join("_")}`;
-    }
-    url += anthologyId ? `&anthology=${anthologyId}` : "";
-    console.info("article api request", url);
-    setLoading(true);
-    get<IArticleResponse>(url)
-      .then((json) => {
-        console.info("article api response", json);
-        if (json.ok) {
-          setArticleData(json.data);
-          setCurrPath(json.data.path);
-          if (json.data.html) {
-            setArticleHtml([json.data.html]);
-          } else if (json.data.content) {
-            setArticleHtml([json.data.content]);
-          } else {
-            setArticleHtml([""]);
-          }
-          setExtra(
-            <TocTree
-              treeData={json.data.toc?.map((item) => {
-                const strTitle = item.title ? item.title : item.pali_title;
-                const key = item.key
-                  ? item.key
-                  : `${item.book}-${item.paragraph}`;
-                const progress = item.progress?.map((item, id) => (
-                  <Tag key={id}>{Math.round(item * 100) + "%"}</Tag>
-                ));
-                return {
-                  key: key,
-                  title: (
-                    <Space>
-                      <PaliText
-                        text={strTitle === "" ? "[unnamed]" : strTitle}
-                      />
-                      {progress}
-                    </Space>
-                  ),
-                  level: item.level,
-                };
-              })}
-              onClick={(
-                id: string,
-                e: React.MouseEvent<HTMLSpanElement, MouseEvent>
-              ) => {
-                const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
-                if (typeof onArticleChange !== "undefined") {
-                  onArticleChange("article", id, target);
-                }
-              }}
-            />
-          );
-
-          if (typeof onLoad !== "undefined") {
-            onLoad(json.data);
-          }
-        } else {
-          console.error("json", json);
-          message.error(json.message);
-        }
-      })
-      .finally(() => {
-        setLoading(false);
-      })
-      .catch((e) => {
-        console.error(e);
-        setErrorCode(e);
-      });
-  }, [active, type, articleId, srcDataMode, channelId, anthologyId]);
-
-  useEffect(() => {
-    const url = `/v2/nav-article/${articleId}_${anthologyId}`;
-    console.info("api request", url);
-    get<IArticleNavResponse>(url)
-      .then((json) => {
-        console.debug("api response", json);
-        if (json.ok) {
-          setNav(json.data);
-        }
-      })
-      .catch((e) => {
-        console.error(e);
-      });
-  }, [anthologyId, articleId]);
-
-  let anthology: IFirstAnthology | undefined;
-  if (articleData?.anthology_count && articleData.anthology_first) {
-    anthology = {
-      id: articleData.anthology_first.uid,
-      title: articleData.anthology_first.title,
-      count: articleData?.anthology_count,
-    };
-  }
-
-  const title = articleData?.title_text ?? articleData?.title;
-
-  let endOfChapter = false;
-  if (nav?.curr && nav?.next) {
-    if (nav?.curr?.level > nav?.next?.level) {
-      endOfChapter = true;
-    }
-  }
-
-  let topOfChapter = false;
-  if (nav?.curr && nav?.prev) {
-    if (nav?.curr?.level > nav?.prev?.level) {
-      topOfChapter = true;
-    }
-  }
-
-  return (
-    <div>
-      {loading ? (
-        <ArticleSkeleton />
-      ) : errorCode ? (
-        <ErrorResult code={errorCode} />
-      ) : (
-        <>
-          <TypeArticleReaderToolbar
-            title={title}
-            articleId={articleId}
-            anthologyId={anthologyId}
-            role={articleData?.role}
-            isSubWindow={isSubWindow}
-            onEdit={() => {
-              if (typeof onEdit !== "undefined") {
-                onEdit();
-              }
-            }}
-            onAnthologySelect={(
-              id: string,
-              e: React.MouseEvent<HTMLElement, MouseEvent>
-            ) => {
-              if (typeof onAnthologySelect !== "undefined") {
-                onAnthologySelect(id, e);
-              }
-            }}
-          />
-          <ArticleView
-            id={articleData?.uid}
-            title={title}
-            subTitle={articleData?.subtitle}
-            summary={articleData?.summary}
-            content={articleData ? articleData.content : ""}
-            html={articleHtml}
-            path={currPath}
-            created_at={articleData?.created_at}
-            updated_at={articleData?.updated_at}
-            channels={channels}
-            type={type}
-            articleId={articleId}
-            anthology={anthology}
-            hideTitle={hideTitle}
-            onPathChange={(
-              node: ITocPathNode,
-              e: React.MouseEvent<
-                HTMLSpanElement | HTMLAnchorElement,
-                MouseEvent
-              >
-            ) => {
-              let newType = type;
-              if (node.level === 0) {
-                newType = "anthology";
-              } else {
-                newType = "article";
-              }
-              if (typeof onArticleChange !== "undefined") {
-                const newArticleId = node.key;
-                const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
-                onArticleChange(newType, newArticleId, target);
-              }
-            }}
-          />
-          <Divider />
-          {extra}
-          <Divider />
-          <NavigateButton
-            prevTitle={nav?.prev?.title}
-            nextTitle={nav?.next?.title}
-            topOfChapter={topOfChapter}
-            endOfChapter={endOfChapter}
-            path={currPath}
-            onNext={() => {
-              if (typeof onArticleChange !== "undefined") {
-                onArticleChange("article", nav?.next?.article_id);
-              }
-            }}
-            onPrev={() => {
-              if (typeof onArticleChange !== "undefined") {
-                onArticleChange("article", nav?.prev?.article_id);
-              }
-            }}
-            onPathChange={(key: string) => {
-              if (typeof onArticleChange !== "undefined") {
-                const node = currPath?.find((value) => value.key === key);
-                if (node) {
-                  let newType = type;
-                  if (node.level === 0) {
-                    newType = "anthology";
-                  } else {
-                    newType = "article";
-                  }
-                  onArticleChange(newType, node.key, "_self");
-                }
-              }
-            }}
-          />
-          {hideInteractive ? (
-            <></>
-          ) : (
-            <InteractiveArea resType={"article"} resId={articleId} />
-          )}
-        </>
-      )}
-    </div>
-  );
-};
-
-export default TypeArticleReaderWidget;

+ 0 - 172
dashboard-v6/src/components/article/TypeCSPara.tsx

@@ -1,172 +0,0 @@
-import { useEffect, useState } from "react";
-import { message } from "antd";
-
-import { get } from "../../request";
-import type { ICSParaNavData, ICSParaNavResponse } from "../../api/Article";
-import type { ArticleMode, ArticleType } from "./Article";
-import TypePali from "./TypePali";
-import NavigateButton from "./NavigateButton";
-import ArticleSkeleton from "./ArticleSkeleton";
-import ErrorResult from "../general/ErrorResult";
-import "./article.css";
-import type { ISearchParams } from "../../pages/library/article/show";
-
-interface IParam {
-  articleId?: string;
-  mode?: ArticleMode | null;
-  channelId?: string | null;
-  book?: string | null;
-  para?: string | null;
-}
-interface IWidget {
-  articleId?: string;
-  mode?: ArticleMode | null;
-  channelId?: string | null;
-  onArticleChange?: (
-    type: ArticleType,
-    id: string,
-    target: string,
-    param?: ISearchParams[]
-  ) => void;
-}
-const TypeCSParaWidget = ({
-  channelId,
-  articleId,
-  mode = "read",
-  onArticleChange,
-}: IWidget) => {
-  /**
-   * 页面加载
-   * M 缅文页码
-   * P PTS页码
-   * V vri页码
-   * T 泰文页码
-   * O 其他
-   * para 缅文段落号
-   * url 格式 /article/page/M-dīghanikāya-2-10
-   * 书名在 dashboard\src\components\fts\book_name.ts
-   */
-
-  const [paramPali, setParamPali] = useState<IParam>();
-  const [nav, setNav] = useState<ICSParaNavData>();
-  const [errorCode, setErrorCode] = useState<number>();
-  const [errorMessage, setErrorMessage] = useState<string>();
-
-  useEffect(() => {
-    if (typeof articleId === "undefined") {
-      console.error("articleId 不能为空");
-      return;
-    }
-
-    const pageParam = articleId.split("_");
-    if (pageParam.length !== 3) {
-      console.error("pageParam 必须为三个");
-      return;
-    }
-
-    const url = `/v2/nav-cs-para/${articleId}`;
-    console.log("url", url);
-    get<ICSParaNavResponse>(url)
-      .then((json) => {
-        if (json.ok) {
-          const data = json.data;
-          setNav(data);
-          const begin = data.curr.start;
-          const end = data.end;
-          const para: number[] = [];
-          for (let index = begin; index <= end; index++) {
-            para.push(index);
-          }
-          setParamPali({
-            articleId: `${data.curr.book}-${data.curr.start}`,
-            book: data.curr.book.toString(),
-            para: para.join(),
-            mode: mode,
-            channelId: channelId,
-          });
-        } else {
-          message.error(json.message);
-        }
-      })
-      .finally(() => {})
-      .catch((e) => {
-        console.error(e);
-        setErrorCode(e);
-        if (e === 404) {
-          setErrorMessage(`该页面不存在。`);
-        }
-      });
-  }, [articleId, channelId, mode]);
-
-  return (
-    <div>
-      {paramPali ? (
-        <>
-          <TypePali
-            type={"para"}
-            hideNav
-            {...paramPali}
-            onArticleChange={(
-              type: ArticleType,
-              id: string,
-              target: string,
-              param?: ISearchParams[] | undefined
-            ) => {
-              if (typeof onArticleChange !== "undefined") {
-                onArticleChange(type, id, target, param);
-              }
-            }}
-          />
-          <NavigateButton
-            prevTitle={nav?.prev?.content.slice(0, 10)}
-            nextTitle={nav?.next?.content.slice(0, 10)}
-            onNext={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
-              if (typeof onArticleChange !== "undefined") {
-                if (typeof articleId === "undefined") {
-                  return;
-                }
-                const pageParam = articleId.split("_");
-                if (pageParam.length !== 3) {
-                  return;
-                }
-                const id = `${pageParam[0]}-${pageParam[1]}-${
-                  parseInt(pageParam[2]) + 1
-                }`;
-                let target = "_self";
-                if (event.ctrlKey || event.metaKey) {
-                  target = "_blank";
-                }
-                onArticleChange("cs-para", id, target);
-              }
-            }}
-            onPrev={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
-              if (typeof onArticleChange !== "undefined") {
-                if (typeof articleId === "undefined") {
-                  return;
-                }
-                const pageParam = articleId.split("_");
-                if (pageParam.length < 3) {
-                  return;
-                }
-                const id = `${pageParam[0]}-${pageParam[1]}-${
-                  parseInt(pageParam[2]) - 1
-                }`;
-                let target = "_self";
-                if (event.ctrlKey || event.metaKey) {
-                  target = "_blank";
-                }
-                onArticleChange("cs-para", id, target);
-              }
-            }}
-          />
-        </>
-      ) : errorCode ? (
-        <ErrorResult code={errorCode} message={errorMessage} />
-      ) : (
-        <ArticleSkeleton />
-      )}
-    </div>
-  );
-};
-
-export default TypeCSParaWidget;

+ 0 - 235
dashboard-v6/src/components/article/TypeCourse.tsx

@@ -1,235 +0,0 @@
-import { useEffect, useMemo, useState } from "react";
-import { get } from "../../request";
-import store from "../../store";
-
-import type {
-  ICourseCurrUserResponse,
-  ICourseDataResponse,
-  ICourseMemberListResponse,
-  ICourseResponse,
-  ICourseUser,
-} from "../../api/Course";
-
-import { signIn } from "../../reducers/course-user";
-import {
-  type ITextbook,
-  memberRefresh,
-  refresh,
-} from "../../reducers/current-course";
-
-import "./article.css";
-
-import type { ArticleMode, ArticleType } from "./Article";
-import TypeArticle from "./TypeArticle";
-import { Link, useNavigate, useSearchParams } from "react-router";
-
-import SelectChannel from "../course/SelectChannel";
-import { Space, Tag, Typography } from "antd";
-import { useIntl } from "react-intl";
-import type { ISearchParams } from "../../pages/library/article/show";
-
-const { Text } = Typography;
-
-interface IWidget {
-  type?: ArticleType;
-  articleId?: string;
-  mode?: ArticleMode | null;
-  channelId?: string | null;
-  book?: string | null;
-  para?: string | null;
-  courseId?: string | null;
-  exerciseId?: string;
-  userName?: string;
-  active?: boolean;
-  onArticleChange?: (
-    type: ArticleType,
-    id: string,
-    target: string,
-    param?: ISearchParams[]
-  ) => void;
-  onFinal?: () => void;
-  onLoad?: () => void;
-  onLoading?: (loading: boolean) => void;
-  onError?: (msg: string) => void;
-}
-
-const TypeCourseWidget = ({
-  type,
-  channelId,
-  articleId,
-  courseId,
-  mode = "read",
-  onArticleChange,
-}: IWidget) => {
-  const intl = useIntl();
-  const navigate = useNavigate();
-
-  const [anthologyId, setAnthologyId] = useState<string>();
-  const [course, setCourse] = useState<ICourseDataResponse>();
-  const [currUser, setCurrUser] = useState<ICourseUser>();
-  const [channelPickerOpen, setChannelPickerOpen] = useState(false);
-
-  const [searchParams, setSearchParams] = useSearchParams();
-
-  /** ---------------- 课程用户信息 ---------------- */
-  useEffect(() => {
-    if (type !== "textbook" || !courseId) return;
-
-    let ignore = false;
-
-    (async () => {
-      const res = await get<ICourseCurrUserResponse>(
-        `/v2/course-curr?course_id=${courseId}`
-      );
-
-      if (!res.ok || ignore) return;
-
-      setCurrUser(res.data);
-
-      if (!res.data.channel_id) setChannelPickerOpen(true);
-
-      store.dispatch(
-        signIn({
-          channelId: res.data.channel_id,
-          role: res.data.role,
-        })
-      );
-
-      /** 老师加载成员列表 */
-      if (res.data.role && res.data.role !== "student") {
-        const list = await get<ICourseMemberListResponse>(
-          `/v2/course-member?view=course&id=${courseId}`
-        );
-
-        if (list.ok) {
-          store.dispatch(memberRefresh(list.data.rows));
-        }
-      }
-    })();
-
-    return () => {
-      ignore = true;
-    };
-  }, [courseId, type]);
-
-  /** ---------------- 同步 URL 参数 ---------------- */
-  const paramsString = searchParams.toString();
-
-  useEffect(() => {
-    if (!currUser && !course) return;
-
-    const output: Record<string, string> = { mode: mode ?? "read" };
-
-    new URLSearchParams(paramsString).forEach((value, key) => {
-      if (key !== "mode" && key !== "channel") {
-        output[key] = value;
-      }
-    });
-
-    if (currUser?.role === "student") {
-      if (currUser.channel_id) {
-        output.channel = currUser.channel_id;
-      }
-    } else if (course?.channel_id) {
-      output.channel = course.channel_id;
-    }
-
-    setSearchParams(output);
-  }, [currUser, course, mode, paramsString]);
-
-  /** ---------------- 课程信息 ---------------- */
-  useEffect(() => {
-    if (!courseId) return;
-
-    let ignore = false;
-
-    (async () => {
-      const json = await get<ICourseResponse>(`/v2/course/${courseId}`);
-
-      if (!json.ok || ignore) return;
-
-      setAnthologyId(json.data.anthology_id);
-      setCourse(json.data);
-
-      if (articleId) {
-        const ic: ITextbook = {
-          course: json.data,
-          courseId,
-          articleId,
-          channelId: json.data.channel_id,
-        };
-
-        store.dispatch(refresh(ic));
-      }
-    })();
-
-    return () => {
-      ignore = true;
-    };
-  }, [articleId, courseId]);
-
-  /** ---------------- 计算 channelId ---------------- */
-  const channelsId = useMemo(() => {
-    if (!currUser || !course) return "";
-
-    if (currUser.role === "student") {
-      return currUser.channel_id
-        ? `${currUser.channel_id}_${course.channel_id}`
-        : (course.channel_id ?? "");
-    }
-
-    return course.channel_id ?? "";
-  }, [currUser, course]);
-
-  /** ---------------- loading ---------------- */
-  if (!anthologyId || !currUser) return <>loading</>;
-
-  return (
-    <>
-      {currUser.role === "student" && !currUser.channel_id && (
-        <SelectChannel
-          courseId={courseId}
-          open={channelPickerOpen}
-          onOpenChange={setChannelPickerOpen}
-          onSelected={() => window.location.reload()}
-        />
-      )}
-
-      <Space>
-        <Text>
-          课程:
-          <Link to={`/course/show/${course?.id}`} target="_blank">
-            {course?.title}
-          </Link>
-        </Text>
-
-        <Tag>
-          {intl.formatMessage({
-            id: `auth.role.${currUser.role}`,
-          })}
-        </Tag>
-      </Space>
-
-      <TypeArticle
-        type="article"
-        articleId={articleId}
-        channelId={channelsId}
-        mode={mode}
-        anthologyId={anthologyId}
-        active
-        onArticleChange={(type: ArticleType, id: string, target: string) => {
-          if (type === "article" && courseId && channelId) {
-            onArticleChange?.("textbook", id, target, [
-              { key: "course", value: courseId },
-              { key: "channel", value: channelId },
-            ]);
-          } else {
-            navigate(`/course/show/${courseId}`);
-          }
-        }}
-      />
-    </>
-  );
-};
-
-export default TypeCourseWidget;

+ 0 - 198
dashboard-v6/src/components/article/TypePage.tsx

@@ -1,198 +0,0 @@
-import { useEffect, useState } from "react";
-import { Alert, message } from "antd";
-import { useIntl } from "react-intl";
-
-import { get } from "../../request";
-import type { IPageNavData, IPageNavResponse } from "../../api/Article";
-import type { ArticleMode, ArticleType } from "./Article";
-import { bookName } from "../fts/book_name";
-import TypePali from "./TypePali";
-import NavigateButton from "./NavigateButton";
-import ArticleSkeleton from "./ArticleSkeleton";
-import ErrorResult from "../general/ErrorResult";
-import "./article.css";
-import { fullUrl } from "../../utils";
-import type { ISearchParams } from "../../pages/library/article/show";
-
-interface IParam {
-  articleId?: string;
-  mode?: ArticleMode | null;
-  channelId?: string | null;
-  book?: string | null;
-  para?: string | null;
-}
-interface IWidget {
-  articleId?: string;
-  mode?: ArticleMode | null;
-  channelId?: string | null;
-  focus?: string | null;
-  onArticleChange?: (
-    type: ArticleType,
-    id: string,
-    target: string,
-    param?: ISearchParams[]
-  ) => void;
-  onFinal?: Function;
-  onLoad?: Function;
-}
-const TypePageWidget = ({
-  channelId,
-  articleId,
-  focus,
-  mode = "read",
-  onArticleChange,
-}: IWidget) => {
-  /**
-   * 页面加载
-   * M 缅文页码
-   * P PTS页码
-   * V vri页码
-   * T 泰文页码
-   * O 其他
-   * para 缅文段落号
-   * url 格式 /article/page/M-dīghanikāya-2-10
-   * 书名在 dashboard\src\components\fts\book_name.ts
-   */
-
-  const [paramPali, setParamPali] = useState<IParam>();
-  const [nav, setNav] = useState<IPageNavData>();
-  const [errorCode, setErrorCode] = useState<number>();
-  const [errorMessage, setErrorMessage] = useState<string>();
-  const [pageInfo, setPageInfo] = useState<string>();
-  const [currId, setCurrId] = useState(articleId);
-
-  const intl = useIntl();
-
-  useEffect(() => setCurrId(articleId), [articleId]);
-
-  useEffect(() => {
-    if (typeof currId === "undefined") {
-      return;
-    }
-
-    const pageParam = currId.split("_");
-    if (pageParam.length < 4) {
-      return;
-    }
-    //查询书号
-    const booksId = bookName
-      .filter((value) => value.term === pageParam[1])
-      .map((item) => item.id)
-      .join("_");
-    const url = `/v2/nav-page/${pageParam[0].toUpperCase()}-${booksId}-${
-      pageParam[2]
-    }-${pageParam[3]}`;
-    setPageInfo(
-      `版本:` +
-        intl.formatMessage({
-          id: `labels.page.number.type.` + pageParam[0].toUpperCase(),
-        }) +
-        ` 书名:${pageParam[1]} 卷号:${pageParam[2]} 页码:${pageParam[3]}`
-    );
-    console.log("url", url);
-    get<IPageNavResponse>(url)
-      .then((json) => {
-        if (json.ok) {
-          const data = json.data;
-          setNav(data);
-          const begin = data.curr.paragraph;
-          const end = data.next.paragraph;
-          const para: number[] = [];
-          for (let index = begin; index <= end; index++) {
-            para.push(index);
-          }
-          setParamPali({
-            articleId: `${data.curr.book}-${data.curr.paragraph}`,
-            book: data.curr.book.toString(),
-            para: para.join(),
-            mode: mode,
-            channelId: channelId,
-          });
-        } else {
-          message.error(json.message);
-        }
-      })
-      .finally(() => {})
-      .catch((e) => {
-        console.error(e);
-        setErrorCode(e);
-        if (e === 404) {
-          setErrorMessage(`该页面不存在。页面信息:${pageInfo}`);
-        }
-      });
-  }, [currId, channelId, intl, mode, pageInfo]);
-
-  const seek = (
-    event: React.MouseEvent<HTMLElement, MouseEvent>,
-    page: number
-  ) => {
-    if (typeof currId === "undefined") {
-      return;
-    }
-    const pageParam = currId.split("_");
-    if (pageParam.length < 4) {
-      return;
-    }
-    const id = `${pageParam[0]}_${pageParam[1]}_${pageParam[2]}_${
-      parseInt(pageParam[3]) + page
-    }`;
-    let target = "_self";
-    if (event.ctrlKey || event.metaKey) {
-      target = "_blank";
-    }
-    if (typeof onArticleChange !== "undefined") {
-      onArticleChange("page", id, target);
-    } else {
-      if (target === "_blank") {
-        let url = `/article/page/${id}?mode=${mode}`;
-        if (channelId) {
-          url += `&channel=${channelId}`;
-        }
-        window.open(fullUrl(url), "_blank");
-      } else {
-        setCurrId(id);
-      }
-    }
-  };
-
-  return (
-    <div>
-      {pageInfo ? <Alert title={pageInfo} type="info" closable /> : undefined}
-      {paramPali ? (
-        <>
-          <TypePali
-            type={"para"}
-            hideNav
-            {...paramPali}
-            focus={focus}
-            onArticleChange={(
-              type: ArticleType,
-              id: string,
-              target: string
-            ) => {
-              if (typeof onArticleChange !== "undefined") {
-                onArticleChange(type, id, target);
-              }
-            }}
-          />
-          <NavigateButton
-            prevTitle={nav?.prev.page.toString()}
-            nextTitle={nav?.next.page.toString()}
-            onNext={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
-              seek(event, 1);
-            }}
-            onPrev={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
-              seek(event, -1);
-            }}
-          />
-        </>
-      ) : errorCode ? (
-        <ErrorResult code={errorCode} message={errorMessage} />
-      ) : (
-        <ArticleSkeleton />
-      )}
-    </div>
-  );
-};
-
-export default TypePageWidget;

+ 0 - 38
dashboard-v6/src/components/article/TypeSeries.tsx

@@ -1,38 +0,0 @@
-import { Typography } from "antd";
-import PaliTextToc from "./PaliTextToc";
-
-const { Title } = Typography;
-
-interface IWidget {
-  articleId?: string;
-  channelId?: string | null;
-  onArticleChange?: Function;
-}
-const TypeSeriesWidget = ({ articleId, onArticleChange }: IWidget) => {
-  return (
-    <div>
-      <Title level={3}>
-        {"丛书:"}
-        {articleId}
-      </Title>
-      <Title level={4}>{"书目列表"}</Title>
-      <PaliTextToc
-        series={articleId}
-        onClick={(
-          id: string,
-          e: React.MouseEvent<HTMLSpanElement, MouseEvent>
-        ) => {
-          if (typeof onArticleChange !== "undefined") {
-            if (e.ctrlKey || e.metaKey) {
-              onArticleChange("chapter", id, "_blank");
-            } else {
-              onArticleChange("chapter", id, "_self");
-            }
-          }
-        }}
-      />
-    </div>
-  );
-};
-
-export default TypeSeriesWidget;

+ 0 - 29
dashboard-v6/src/components/article/TypeTask.tsx

@@ -1,29 +0,0 @@
-import type { ArticleMode, ArticleType } from "./Article";
-import type { ITaskData } from "../../api/task";
-import Task from "../task/Task";
-import { openDiscussion } from "../discussion/DiscussionButton";
-
-interface IWidget {
-  type?: ArticleType;
-  articleId?: string;
-  mode?: ArticleMode | null;
-  channelId?: string | null;
-  onArticleChange?: (data: ITaskData) => void;
-  onLoad?: (data: ITaskData) => void;
-}
-const TypeTask = ({ articleId }: IWidget) => {
-  return (
-    <div>
-      <Task
-        taskId={articleId}
-        onDiscussion={() => {
-          if (articleId) {
-            openDiscussion(articleId, "task", false);
-          }
-        }}
-      />
-    </div>
-  );
-};
-
-export default TypeTask;

+ 40 - 0
dashboard-v6/src/components/article/WordCount.tsx

@@ -0,0 +1,40 @@
+import { Descriptions, Modal } from "antd";
+import { LoadingOutlined } from "@ant-design/icons";
+import { useArticle } from "./hooks/useArticle";
+
+interface IWidget {
+  open?: boolean;
+  articleId?: string;
+  onClose?: () => void;
+}
+
+const WordCount = ({ open = false, articleId, onClose }: IWidget) => {
+  const { data, loading } = useArticle(articleId, {
+    format: "text",
+    origin: true,
+  });
+
+  const wordAll = data?.html?.length ?? 0;
+
+  return (
+    <Modal
+      destroyOnHidden={true}
+      width={700}
+      title="字数统计"
+      open={open}
+      footer={false}
+      onOk={onClose}
+      onCancel={onClose}
+    >
+      {loading ? (
+        <LoadingOutlined />
+      ) : (
+        <Descriptions title="字数">
+          <Descriptions.Item label="全部字符">{wordAll}</Descriptions.Item>
+        </Descriptions>
+      )}
+    </Modal>
+  );
+};
+
+export default WordCount;

+ 44 - 38
dashboard-v6/src/components/article/TypeArticleReaderToolbar.tsx → dashboard-v6/src/components/article/components/ArticleReaderToolbar.tsx

@@ -1,4 +1,4 @@
-import { Button, Dropdown, Tooltip } from "antd";
+import { Button, Dropdown, Space } from "antd";
 import {
   ReloadOutlined,
   MoreOutlined,
@@ -9,17 +9,20 @@ import {
   InfoCircleOutlined,
 } from "@ant-design/icons";
 
-import { useAppSelector } from "../../hooks";
-import { currentUser } from "../../reducers/current-user";
-import AddToAnthology from "./AddToAnthology";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
 import { useState } from "react";
-import { fullUrl } from "../../utils";
-import { ArticleTplModal } from "../template/Builder/ArticleTpl";
-import AnthologiesAtArticle from "./AnthologiesAtArticle";
-import type { TRole } from "../../api/Auth";
+import { fullUrl } from "../../../utils";
+
+import type { TRole } from "../../../api/Auth";
 import { useIntl } from "react-intl";
-import { TabIcon } from "../../assets/icon";
-import WordCount from "./WordCount";
+import { TabIcon } from "../../../assets/icon";
+
+import AnthologiesAtArticle from "../../anthology/AnthologiesAtArticle";
+import AddToAnthology from "../../anthology/AddToAnthology";
+import TplBuilder from "../../tpl-builder/TplBuilder";
+import WordCount from "../WordCount";
 
 interface IWidget {
   articleId?: string;
@@ -27,8 +30,12 @@ interface IWidget {
   title?: string;
   role?: TRole;
   isSubWindow?: boolean;
-  onEdit?: Function;
-  onAnthologySelect?: Function;
+  onRefresh?: () => void;
+  onEdit?: () => void;
+  onAnthologySelect?: (
+    id: string,
+    e: React.MouseEvent<HTMLElement, MouseEvent>
+  ) => void;
 }
 const TypeArticleReaderToolbarWidget = ({
   articleId,
@@ -36,6 +43,7 @@ const TypeArticleReaderToolbarWidget = ({
   title,
   role = "reader",
   isSubWindow = false,
+  onRefresh,
   onEdit,
   onAnthologySelect,
 }: IWidget) => {
@@ -59,36 +67,33 @@ const TypeArticleReaderToolbarWidget = ({
             <AnthologiesAtArticle
               articleId={articleId}
               anthologyId={anthologyId}
-              onClick={(
-                id: string,
-                e: React.MouseEvent<HTMLElement, MouseEvent>
-              ) => {
-                if (typeof onAnthologySelect !== "undefined") {
+              onClick={(id, e) => {
+                if (onAnthologySelect) {
                   onAnthologySelect(id, e);
                 }
               }}
             />
           )}
         </div>
-        <div>
-          <Tooltip
-            title={intl.formatMessage({
+        <Space>
+          {/** 编辑按钮 */}
+          <Button
+            type="primary"
+            disabled={!editable}
+            icon={<EditOutlined />}
+            onClick={() => {
+              if (typeof onEdit !== "undefined") {
+                onEdit();
+              }
+            }}
+          >
+            {intl.formatMessage({
               id: "buttons.edit",
             })}
-          >
-            <Button
-              type="link"
-              size="small"
-              disabled={!editable}
-              icon={<EditOutlined />}
-              onClick={() => {
-                if (typeof onEdit !== "undefined") {
-                  onEdit();
-                }
-              }}
-            />
-          </Tooltip>
-          <Button type="link" size="small" icon={<ReloadOutlined />} />
+          </Button>
+          {/** 刷新按钮 */}
+          <Button type="link" icon={<ReloadOutlined />} onClick={onRefresh} />
+          {/** 更多 */}
           <Dropdown
             menu={{
               items: [
@@ -155,10 +160,11 @@ const TypeArticleReaderToolbarWidget = ({
                   case "add_to_anthology":
                     setAddToAnthologyOpen(true);
                     break;
-                  case "fork":
+                  case "fork": {
                     const url = `/studio/${user?.realName}/article/create?parent=${articleId}`;
                     window.open(fullUrl(url), "_blank");
                     break;
+                  }
                   case "tpl":
                     setTplOpen(true);
                     break;
@@ -182,7 +188,7 @@ const TypeArticleReaderToolbarWidget = ({
               type="link"
             />
           </Dropdown>
-        </div>
+        </Space>
       </div>
       {articleId ? (
         <AddToAnthology
@@ -192,9 +198,9 @@ const TypeArticleReaderToolbarWidget = ({
         />
       ) : undefined}
 
-      <ArticleTplModal
+      <TplBuilder
         title={title}
-        type="article"
+        tpl="article"
         articleId={articleId}
         open={tplOpen}
         onClose={() => setTplOpen(false)}

+ 110 - 0
dashboard-v6/src/components/article/hooks/useArticle.ts

@@ -0,0 +1,110 @@
+// ─────────────────────────────────────────────
+// useArticle.ts
+// ─────────────────────────────────────────────
+/**
+ * useArticle
+ *
+ * 获取单篇文章详情
+ *
+ * @param articleId 文章 ID
+ * @param params    可选请求参数(频道、文集、渲染模式等)
+ *
+ * @returns
+ *   - data         文章数据,未请求或失败时为 null
+ *   - loading      请求进行中
+ *   - errorCode    HTTP 错误码,无错误时为 null
+ *   - errorMessage 后端错误信息,无错误时为 null
+ *   - refresh      手动重新请求
+ *
+ * @example
+ * // 普通阅读
+ * const { data, loading, errorCode } = useArticle(id);
+ *
+ * // 带文集上下文(返回 path / toc)
+ * const { data } = useArticle(id, { anthologyId });
+ *
+ * // 取原文纯文本
+ * const { data } = useArticle(id, { format: 'text' });
+ *
+ * // 编辑模式 + 频道
+ * const { data } = useArticle(id, { mode: 'edit', channelIds: ['ch1'] });
+ */
+// ─────────────────────────────────────────────
+// useArticle.ts
+// ─────────────────────────────────────────────
+import { useState, useEffect, useCallback, useRef } from "react";
+
+import { fetchArticle } from "../../../api/Article";
+import type {
+  IArticleDataResponse,
+  IFetchArticleParams,
+} from "../../../api/Article";
+
+interface IUseArticleReturn {
+  data: IArticleDataResponse | null;
+  loading: boolean;
+  errorCode: number | null;
+  errorMessage: string | null;
+  refresh: () => void;
+}
+
+export const useArticle = (
+  articleId?: string,
+  params: IFetchArticleParams = {}
+): IUseArticleReturn => {
+  const [data, setData] = useState<IArticleDataResponse | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [tick, setTick] = useState(0);
+
+  // 用 JSON 序列化做稳定的依赖比较,避免每次 render 传入新对象引用导致无限循环
+  const paramsKey = JSON.stringify(params);
+  const paramsRef = useRef<IFetchArticleParams>(params);
+  useEffect(() => {
+    paramsRef.current = params;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [paramsKey]);
+
+  const refresh = useCallback(() => setTick((t) => t + 1), []);
+
+  useEffect(() => {
+    if (!articleId) return;
+
+    let active = true;
+
+    const fetchData = async () => {
+      setLoading(true);
+      setErrorCode(null);
+      setErrorMessage(null);
+
+      try {
+        const res = await fetchArticle(articleId, paramsRef.current);
+        if (!active) return;
+
+        if (!res.ok) {
+          setErrorCode(400);
+          setErrorMessage(res.message);
+          return;
+        }
+
+        setData(res.data);
+      } catch (e) {
+        if (!active) return;
+        setErrorCode(e as number);
+        setErrorMessage("Unknown error");
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+    // paramsKey 代替 params 对象作为依赖,值相同时不会触发重新请求
+  }, [articleId, paramsKey, tick]);
+
+  return { data, loading, errorCode, errorMessage, refresh };
+};

+ 147 - 0
dashboard-v6/src/components/article/hooks/useArticleList.ts

@@ -0,0 +1,147 @@
+// ─────────────────────────────────────────────
+// useArticleList.ts
+// ─────────────────────────────────────────────
+/**
+ * useArticleList
+ *
+ * 内部控制模式的文章列表,适用于弹窗、抽屉等局部场景。
+ * 分页、搜索、排序状态由 hook 内部维护,外部通过回调函数操作。
+ *
+ * @param initialParams 初始请求参数,仅在 mount 时生效
+ *
+ * @returns
+ *   - data            列表数据 { rows, count }
+ *   - loading         请求进行中
+ *   - errorCode       HTTP 错误码,无错误时为 null
+ *   - errorMessage    后端错误信息,无错误时为 null
+ *   - params          当前实际生效的请求参数
+ *   - onPageChange    翻页回调,重置 current / pageSize
+ *   - onSearch        搜索回调,自动重置到第一页
+ *   - onSortChange    排序回调
+ *   - refresh         手动重新请求(保持当前 params)
+ *
+ * @example
+ * const {
+ *   data,
+ *   loading,
+ *   onPageChange,
+ *   onSearch,
+ *   onSortChange,
+ * } = useArticleList({ view: 'studio', studioName: 'my-studio' });
+ *
+ * <Table
+ *   dataSource={data.rows}
+ *   pagination={{ total: data.count, onChange: onPageChange }}
+ *   onChange={(_, __, sorter) => {
+ *     const s = sorter as SorterResult<IArticleDataResponse>;
+ *     onSortChange(s.field as TArticleSortField, s.order ?? 'descend');
+ *   }}
+ * />
+ * <Input.Search onSearch={onSearch} />
+ */
+
+import { useState, useEffect, useCallback } from "react";
+
+import type { SortOrder } from "antd/es/table/interface";
+
+import { fetchArticleList } from "../../../api/Article";
+import type {
+  IArticleDataResponse,
+  IListArticleParams,
+  TArticleSortField,
+} from "../../../api/Article";
+
+interface IArticleListData {
+  rows: IArticleDataResponse[];
+  count: number;
+}
+
+interface IUseArticleListReturn {
+  data: IArticleListData;
+  loading: boolean;
+  errorCode: number | null;
+  errorMessage: string | null;
+  params: IListArticleParams;
+  onPageChange: (page: number, pageSize: number) => void;
+  onSearch: (keyword: string) => void;
+  onSortChange: (orderBy: TArticleSortField, sortOrder: SortOrder) => void;
+  refresh: () => void;
+}
+
+export const useArticleList = (
+  initialParams: IListArticleParams
+): IUseArticleListReturn => {
+  const [params, setParams] = useState<IListArticleParams>(initialParams);
+  const [data, setData] = useState<IArticleListData>({ rows: [], count: 0 });
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [tick, setTick] = useState(0);
+
+  const refresh = useCallback(() => setTick((t) => t + 1), []);
+
+  const paramsKey = JSON.stringify(params);
+
+  useEffect(() => {
+    let active = true;
+
+    const fetchData = async () => {
+      setLoading(true);
+      setErrorCode(null);
+      setErrorMessage(null);
+
+      try {
+        const res = await fetchArticleList(JSON.parse(paramsKey));
+        if (!active) return;
+
+        if (!res.ok) {
+          setErrorCode(400);
+          setErrorMessage(res.message);
+          return;
+        }
+
+        setData(res.data);
+      } catch (e) {
+        if (!active) return;
+        setErrorCode(e as number);
+        setErrorMessage("Unknown error");
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [paramsKey, tick]);
+
+  const onPageChange = useCallback((page: number, pageSize: number) => {
+    setParams((prev) => ({ ...prev, current: page, pageSize }));
+  }, []);
+
+  const onSearch = useCallback((keyword: string) => {
+    // 搜索时重置到第一页
+    setParams((prev) => ({ ...prev, keyword, current: 1 }));
+  }, []);
+
+  const onSortChange = useCallback(
+    (orderBy: TArticleSortField, sortOrder: SortOrder) => {
+      setParams((prev) => ({ ...prev, orderBy, sortOrder }));
+    },
+    []
+  );
+
+  return {
+    data,
+    loading,
+    errorCode,
+    errorMessage,
+    params,
+    onPageChange,
+    onSearch,
+    onSortChange,
+    refresh,
+  };
+};

+ 108 - 0
dashboard-v6/src/components/article/hooks/useArticleListControlled.ts

@@ -0,0 +1,108 @@
+// ─────────────────────────────────────────────
+// useArticleListControlled.ts
+// ─────────────────────────────────────────────
+/**
+ * useArticleListControlled
+ *
+ * 外部控制模式的文章列表,适用于需要 URL 同步或跨组件共享筛选条件的场景。
+ * hook 内部不维护任何 params 状态,params 变化时直接触发请求。
+ *
+ * @param params 完整请求参数,由外部控制,变化时自动重新请求
+ *
+ * @returns
+ *   - data         列表数据 { rows, count }
+ *   - loading      请求进行中
+ *   - errorCode    HTTP 错误码,无错误时为 null
+ *   - errorMessage 后端错误信息,无错误时为 null
+ *   - refresh      手动重新请求(使用当前 params)
+ *
+ * @example
+ * // 从 URL 读取参数(view / studioName 也可能来自路由)
+ * const [searchParams, setSearchParams] = useSearchParams();
+ *
+ * const params: IListArticleParams = {
+ *   view: 'studio',
+ *   studioName: searchParams.get('studio') ?? '',
+ *   current: Number(searchParams.get('page') ?? 1),
+ *   keyword: searchParams.get('q') ?? undefined,
+ * };
+ *
+ * const { data, loading } = useArticleListControlled(params);
+ *
+ * // 翻页时更新 URL,hook 自动重新请求
+ * const onPageChange = (page: number) => {
+ *   setSearchParams((prev) => { prev.set('page', String(page)); return prev; });
+ * };
+ */
+
+import { useState, useEffect, useCallback } from "react";
+
+import { fetchArticleList } from "../../../api/Article";
+import type {
+  IArticleDataResponse,
+  IListArticleParams,
+} from "../../../api/Article";
+
+interface IArticleListData {
+  rows: IArticleDataResponse[];
+  count: number;
+}
+
+interface IUseArticleListControlledReturn {
+  data: IArticleListData;
+  loading: boolean;
+  errorCode: number | null;
+  errorMessage: string | null;
+  refresh: () => void;
+}
+
+export const useArticleListControlled = (
+  params: IListArticleParams
+): IUseArticleListControlledReturn => {
+  const [data, setData] = useState<IArticleListData>({ rows: [], count: 0 });
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [tick, setTick] = useState(0);
+
+  const refresh = useCallback(() => setTick((t) => t + 1), []);
+
+  const paramsKey = JSON.stringify(params);
+
+  useEffect(() => {
+    let active = true;
+
+    const fetchData = async () => {
+      setLoading(true);
+      setErrorCode(null);
+      setErrorMessage(null);
+
+      try {
+        const res = await fetchArticleList(JSON.parse(paramsKey));
+        if (!active) return;
+
+        if (!res.ok) {
+          setErrorCode(400);
+          setErrorMessage(res.message);
+          return;
+        }
+
+        setData(res.data);
+      } catch (e) {
+        if (!active) return;
+        setErrorCode(e as number);
+        setErrorMessage("Unknown error");
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [paramsKey, tick]);
+
+  return { data, loading, errorCode, errorMessage, refresh };
+};

+ 226 - 0
dashboard-v6/src/components/article/hooks/useArticleMutations.ts

@@ -0,0 +1,226 @@
+// ─────────────────────────────────────────────
+// useArticleMutations.ts
+// ─────────────────────────────────────────────
+/**
+ * useArticleMutations
+ *
+ * 文章的创建、更新、删除操作,通过 callbacks 将结果回传给调用方。
+ * 不包含任何 UI 反馈(toast、跳转等),由 feature 层的 callbacks 负责。
+ *
+ * @returns
+ *   - submitting.creating  创建请求进行中
+ *   - submitting.updating  更新请求进行中
+ *   - submitting.deleting  删除请求进行中
+ *   - errorCode            HTTP 错误码,无错误时为 null
+ *   - errorMessage         后端错误信息,无错误时为 null
+ *   - createArticle        创建文章
+ *   - updateArticle        更新文章
+ *   - deleteArticle        删除文章
+ *
+ * @example
+ * const { submitting, createArticle, updateArticle, deleteArticle } =
+ *   useArticleMutations();
+ *
+ * // 创建
+ * createArticle(
+ *   { title: '新文章', lang: 'zh', studio: 'my-studio' },
+ *   {
+ *     onSuccess: (data) => {
+ *       notification.success({ message: '创建成功' });
+ *       navigate(`/article/${data.uid}`);
+ *     },
+ *     onError: (code, message) => notification.error({ message }),
+ *   }
+ * );
+ *
+ * // 更新
+ * updateArticle(
+ *   articleId,
+ *   { ...formValues },
+ *   { onSuccess: () => notification.success({ message: '保存成功' }) }
+ * );
+ *
+ * // 删除
+ * deleteArticle(id, {
+ *   onSuccess: () => {
+ *     notification.success({ message: '删除成功' });
+ *     refresh(); // 刷新列表
+ *   },
+ *   onError: (code, message) => notification.error({ message }),
+ * });
+ *
+ * <Button loading={submitting.creating}>创建</Button>
+ * <Button loading={submitting.deleting}>删除</Button>
+ */
+
+import { useState, useCallback } from "react";
+
+import {
+  createArticle,
+  updateArticle,
+  deleteArticle,
+} from "../../../api/Article";
+import type {
+  IArticleDataResponse,
+  IArticleCreateRequest,
+  IArticleDataRequest,
+} from "../../../api/Article";
+
+// ─────────────────────────────────────────────
+// Callbacks
+// ─────────────────────────────────────────────
+
+interface IArticleMutationCallbacks {
+  onSuccess?: (data: IArticleDataResponse) => void;
+  onError?: (errorCode: number, errorMessage: string) => void;
+}
+
+interface IDeleteMutationCallbacks {
+  onSuccess?: (data: number) => void;
+  onError?: (errorCode: number, errorMessage: string) => void;
+}
+
+interface IMutationErrorCallbacks {
+  onError?: (errorCode: number, errorMessage: string) => void;
+}
+
+// ─────────────────────────────────────────────
+// Types
+// ─────────────────────────────────────────────
+
+interface ISubmitting {
+  creating: boolean;
+  updating: boolean;
+  deleting: boolean;
+}
+
+interface IUseArticleMutationsReturn {
+  submitting: ISubmitting;
+  errorCode: number | null;
+  errorMessage: string | null;
+  createArticle: (
+    data: IArticleCreateRequest,
+    callbacks?: IArticleMutationCallbacks
+  ) => Promise<void>;
+  updateArticle: (
+    id: string,
+    data: IArticleDataRequest,
+    callbacks?: IArticleMutationCallbacks
+  ) => Promise<void>;
+  deleteArticle: (
+    id: string,
+    callbacks?: IDeleteMutationCallbacks
+  ) => Promise<void>;
+}
+
+// ─────────────────────────────────────────────
+// Hook
+// ─────────────────────────────────────────────
+
+export const useArticleMutations = (): IUseArticleMutationsReturn => {
+  const [submitting, setSubmitting] = useState<ISubmitting>({
+    creating: false,
+    updating: false,
+    deleting: false,
+  });
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+
+  const clearError = () => {
+    setErrorCode(null);
+    setErrorMessage(null);
+  };
+
+  const applyError = (e: unknown, callbacks?: IMutationErrorCallbacks) => {
+    const code =
+      typeof (e as Record<string, unknown>)?.status === "number"
+        ? ((e as Record<string, unknown>).status as number)
+        : 500;
+    const message = e instanceof Error ? e.message : "Unknown error";
+    setErrorCode(code);
+    setErrorMessage(message);
+    callbacks?.onError?.(code, message);
+  };
+
+  const handleCreate = useCallback(
+    async (
+      data: IArticleCreateRequest,
+      callbacks?: IArticleMutationCallbacks
+    ) => {
+      clearError();
+      setSubmitting((prev) => ({ ...prev, creating: true }));
+      try {
+        const res = await createArticle(data);
+        if (res.ok) {
+          callbacks?.onSuccess?.(res.data);
+        } else {
+          setErrorCode(400);
+          setErrorMessage(res.message);
+          callbacks?.onError?.(400, res.message);
+        }
+      } catch (e: unknown) {
+        applyError(e, callbacks);
+      } finally {
+        setSubmitting((prev) => ({ ...prev, creating: false }));
+      }
+    },
+    []
+  );
+
+  const handleUpdate = useCallback(
+    async (
+      id: string,
+      data: IArticleDataRequest,
+      callbacks?: IArticleMutationCallbacks
+    ) => {
+      clearError();
+      setSubmitting((prev) => ({ ...prev, updating: true }));
+      try {
+        const res = await updateArticle(id, data);
+        if (res.ok) {
+          callbacks?.onSuccess?.(res.data);
+        } else {
+          setErrorCode(400);
+          setErrorMessage(res.message);
+          callbacks?.onError?.(400, res.message);
+        }
+      } catch (e: unknown) {
+        applyError(e, callbacks);
+      } finally {
+        setSubmitting((prev) => ({ ...prev, updating: false }));
+      }
+    },
+    []
+  );
+
+  const handleDelete = useCallback(
+    async (id: string, callbacks?: IDeleteMutationCallbacks) => {
+      clearError();
+      setSubmitting((prev) => ({ ...prev, deleting: true }));
+      try {
+        const res = await deleteArticle(id);
+        if (res.ok) {
+          callbacks?.onSuccess?.(res.data);
+        } else {
+          setErrorCode(400);
+          setErrorMessage(res.message);
+          callbacks?.onError?.(400, res.message);
+        }
+      } catch (e: unknown) {
+        applyError(e, callbacks);
+      } finally {
+        setSubmitting((prev) => ({ ...prev, deleting: false }));
+      }
+    },
+    []
+  );
+
+  return {
+    submitting,
+    errorCode,
+    errorMessage,
+    createArticle: handleCreate,
+    updateArticle: handleUpdate,
+    deleteArticle: handleDelete,
+  };
+};

+ 429 - 0
dashboard-v6/src/components/general/NetworkStatus.tsx

@@ -0,0 +1,429 @@
+/**
+ * NetworkStatus 组件
+ *
+ * 依赖:
+ *   - React 18
+ *   - antd v6
+ *   - @reduxjs/toolkit + react-redux
+ *
+ * 使用方式:
+ *   <NetworkStatus />
+ *
+ * 将 netStatusSlice reducer 注册到 store:
+ *   import netStatusReducer from "./netStatusSlice";
+ *   // store.ts
+ *   reducer: { netStatus: netStatusReducer }
+ */
+
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { Badge, Popover, Spin, Tooltip, Typography } from "antd";
+import {
+  ApiOutlined,
+  CheckCircleFilled,
+  ClockCircleOutlined,
+  CloseCircleFilled,
+  DisconnectOutlined,
+  ExclamationCircleFilled,
+  LoadingOutlined,
+  MinusCircleOutlined,
+  SyncOutlined,
+  WifiOutlined,
+} from "@ant-design/icons";
+
+import { apiServerHealth } from "../../api/api-health";
+import {
+  selectNetStatus,
+  statusChange,
+  type ENetStatus,
+  type INetStatus,
+} from "../../reducers/net-status";
+
+const { Text } = Typography;
+
+// ─── 常量 ───────────────────────────────────────────────────────────────────
+
+/** API 轮询间隔(毫秒),默认 60 秒 */
+const POLL_INTERVAL_MS = 60_000;
+
+/** API 请求超时阈值(毫秒) */
+const API_TIMEOUT_MS = 8_000;
+
+// ─── 辅助函数 ─────────────────────────────────────────────────────────────────
+
+function formatTime(iso?: string): string {
+  if (!iso) return "—";
+  return new Date(iso).toLocaleTimeString("zh-CN", {
+    hour: "2-digit",
+    minute: "2-digit",
+    second: "2-digit",
+  });
+}
+
+interface StatusMeta {
+  color: string;
+  dotStatus: "success" | "processing" | "error" | "warning" | "default";
+  icon: React.ReactNode;
+  label: string;
+}
+
+function resolveStatusMeta(status: ENetStatus): StatusMeta {
+  switch (status) {
+    case "idle":
+      return {
+        color: "#8c8c8c",
+        dotStatus: "default",
+        icon: <MinusCircleOutlined style={{ color: "#8c8c8c" }} />,
+        label: "未检测",
+      };
+    case "checking":
+      return {
+        color: "#1677ff",
+        dotStatus: "processing",
+        icon: <LoadingOutlined style={{ color: "#1677ff" }} spin />,
+        label: "检测中…",
+      };
+    case "online":
+      return {
+        color: "#52c41a",
+        dotStatus: "success",
+        icon: <CheckCircleFilled style={{ color: "#52c41a" }} />,
+        label: "连接正常",
+      };
+    case "offline":
+      return {
+        color: "#ff4d4f",
+        dotStatus: "error",
+        icon: <DisconnectOutlined style={{ color: "#ff4d4f" }} />,
+        label: "网络断开",
+      };
+    case "api_error":
+      return {
+        color: "#fa8c16",
+        dotStatus: "warning",
+        icon: <ExclamationCircleFilled style={{ color: "#fa8c16" }} />,
+        label: "API 异常",
+      };
+    case "api_timeout":
+      return {
+        color: "#fa8c16",
+        dotStatus: "warning",
+        icon: <CloseCircleFilled style={{ color: "#fa8c16" }} />,
+        label: "API 超时",
+      };
+  }
+}
+
+// ─── 子组件:Popover 内容 ──────────────────────────────────────────────────────
+
+interface PopoverContentProps {
+  netStatus: INetStatus;
+  onRefresh: () => void;
+  refreshing: boolean;
+}
+
+const PopoverContent: React.FC<PopoverContentProps> = ({
+  netStatus,
+  onRefresh,
+  refreshing,
+}) => {
+  const { status, isNetworkOnline, isApiOnline, message, lastCheckedAt } =
+    netStatus;
+  const meta = resolveStatusMeta(status);
+
+  const rowStyle: React.CSSProperties = {
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "space-between",
+    gap: 24,
+    padding: "6px 0",
+    borderBottom: "1px solid #f0f0f0",
+  };
+
+  const labelStyle: React.CSSProperties = {
+    display: "flex",
+    alignItems: "center",
+    gap: 6,
+    color: "#595959",
+    fontSize: 13,
+  };
+
+  const valueStyle: React.CSSProperties = {
+    display: "flex",
+    alignItems: "center",
+    gap: 4,
+    fontSize: 13,
+    fontWeight: 500,
+  };
+
+  // 网络行
+  const networkOnline =
+    typeof isNetworkOnline === "boolean" ? isNetworkOnline : null;
+  const apiOnline = typeof isApiOnline === "boolean" ? isApiOnline : null;
+
+  return (
+    <div style={{ width: 260 }}>
+      {/* 总状态 */}
+      <div
+        style={{
+          display: "flex",
+          alignItems: "center",
+          gap: 8,
+          marginBottom: 12,
+        }}
+      >
+        {meta.icon}
+        <Text strong style={{ fontSize: 14, color: meta.color }}>
+          {meta.label}
+        </Text>
+        <Tooltip title="立即检测">
+          <SyncOutlined
+            spin={refreshing}
+            onClick={onRefresh}
+            style={{
+              marginLeft: "auto",
+              color: "#1677ff",
+              cursor: "pointer",
+              fontSize: 14,
+            }}
+          />
+        </Tooltip>
+      </div>
+
+      {/* 网络状态行 */}
+      <div style={rowStyle}>
+        <span style={labelStyle}>
+          <WifiOutlined />
+          网络连接
+        </span>
+        <span style={valueStyle}>
+          {status === "checking" ? (
+            <Spin size="small" />
+          ) : networkOnline === null ? (
+            <Text type="secondary">—</Text>
+          ) : networkOnline ? (
+            <>
+              <CheckCircleFilled style={{ color: "#52c41a" }} />
+              <Text style={{ color: "#52c41a" }}>正常</Text>
+            </>
+          ) : (
+            <>
+              <CloseCircleFilled style={{ color: "#ff4d4f" }} />
+              <Text style={{ color: "#ff4d4f" }}>断开</Text>
+            </>
+          )}
+        </span>
+      </div>
+
+      {/* API 状态行 */}
+      <div style={{ ...rowStyle, borderBottom: "none" }}>
+        <span style={labelStyle}>
+          <ApiOutlined />
+          API 服务
+        </span>
+        <span style={valueStyle}>
+          {status === "checking" ? (
+            <Spin size="small" />
+          ) : apiOnline === null ? (
+            <Text type="secondary">—</Text>
+          ) : apiOnline ? (
+            <>
+              <CheckCircleFilled style={{ color: "#52c41a" }} />
+              <Text style={{ color: "#52c41a" }}>正常</Text>
+            </>
+          ) : (
+            <>
+              <CloseCircleFilled style={{ color: "#ff4d4f" }} />
+              <Text style={{ color: "#ff4d4f" }}>
+                {status === "api_timeout" ? "超时" : "异常"}
+              </Text>
+            </>
+          )}
+        </span>
+      </div>
+
+      {/* 错误消息 */}
+      {message && (
+        <div
+          style={{
+            marginTop: 8,
+            padding: "4px 8px",
+            background: "#fff7e6",
+            borderRadius: 4,
+            fontSize: 12,
+            color: "#d46b08",
+          }}
+        >
+          {message}
+        </div>
+      )}
+
+      {/* 上次检测时间 */}
+      <div
+        style={{
+          marginTop: 10,
+          display: "flex",
+          alignItems: "center",
+          gap: 4,
+          color: "#bfbfbf",
+          fontSize: 12,
+        }}
+      >
+        <ClockCircleOutlined />
+        上次检测:{formatTime(lastCheckedAt)}
+      </div>
+    </div>
+  );
+};
+
+// ─── 主组件 ───────────────────────────────────────────────────────────────────
+
+export const NetworkStatus: React.FC = () => {
+  const dispatch = useDispatch();
+  const netStatus = useSelector(selectNetStatus);
+  const [open, setOpen] = useState(false);
+  const [refreshing, setRefreshing] = useState(false);
+  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
+
+  // ── 核心检测逻辑 ──────────────────────────────────────────────────────────
+
+  const runCheck = useCallback(async () => {
+    // 标记:检测中
+    dispatch(
+      statusChange({
+        status: "checking",
+        isNetworkOnline: navigator.onLine,
+        isApiOnline: undefined,
+        lastCheckedAt: netStatus.lastCheckedAt,
+      })
+    );
+
+    // Step 1:浏览器网络检测
+    const isNetworkOnline = navigator.onLine;
+
+    if (!isNetworkOnline) {
+      dispatch(
+        statusChange({
+          status: "offline",
+          isNetworkOnline: false,
+          isApiOnline: false,
+          message: "设备网络已断开,请检查网络连接",
+          lastCheckedAt: new Date().toISOString(),
+        })
+      );
+      return;
+    }
+
+    // Step 2:API 健康检测(带超时)
+    let isApiOnline = false;
+    let message: string | undefined;
+    let finalStatus: ENetStatus = "api_error";
+
+    try {
+      const controller = new AbortController();
+      const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
+
+      await apiServerHealth();
+      clearTimeout(timeoutId);
+
+      isApiOnline = true;
+      finalStatus = "online";
+    } catch (err: unknown) {
+      if (err instanceof Error && err.name === "AbortError") {
+        finalStatus = "api_timeout";
+        message = `API 请求超时(>${API_TIMEOUT_MS / 1000}s)`;
+      } else {
+        finalStatus = "api_error";
+        message =
+          err instanceof Error ? err.message : "API 服务异常,请联系管理员";
+      }
+    }
+
+    dispatch(
+      statusChange({
+        status: finalStatus,
+        isNetworkOnline: true,
+        isApiOnline,
+        message,
+        lastCheckedAt: new Date().toISOString(),
+      })
+    );
+  }, [dispatch, netStatus.lastCheckedAt]);
+
+  // ── 手动刷新 ──────────────────────────────────────────────────────────────
+
+  const handleRefresh = useCallback(async () => {
+    if (refreshing) return;
+    setRefreshing(true);
+    await runCheck();
+    setRefreshing(false);
+  }, [refreshing, runCheck]);
+
+  // ── 轮询 & 网络事件监听 ───────────────────────────────────────────────────
+
+  useEffect(() => {
+    // 首次立即检测
+    runCheck();
+
+    // 定时轮询
+    timerRef.current = setInterval(runCheck, POLL_INTERVAL_MS);
+
+    // 监听浏览器网络事件(离线时立即触发)
+    const handleOffline = () => {
+      dispatch(
+        statusChange({
+          status: "offline",
+          isNetworkOnline: false,
+          isApiOnline: false,
+          message: "网络已断开",
+          lastCheckedAt: new Date().toISOString(),
+        })
+      );
+    };
+    const handleOnline = () => runCheck();
+
+    window.addEventListener("offline", handleOffline);
+    window.addEventListener("online", handleOnline);
+
+    return () => {
+      if (timerRef.current) clearInterval(timerRef.current);
+      window.removeEventListener("offline", handleOffline);
+      window.removeEventListener("online", handleOnline);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // ── 渲染 ──────────────────────────────────────────────────────────────────
+
+  const meta = resolveStatusMeta(netStatus.status);
+
+  return (
+    <Popover
+      open={open}
+      onOpenChange={setOpen}
+      trigger="hover"
+      placement="bottomRight"
+      arrow={false}
+      content={
+        <PopoverContent
+          netStatus={netStatus}
+          onRefresh={handleRefresh}
+          refreshing={refreshing}
+        />
+      }
+    >
+      <Badge dot status={meta.dotStatus} offset={[-2, 2]}>
+        <WifiOutlined
+          style={{
+            fontSize: 18,
+            color: meta.color,
+            cursor: "pointer",
+            transition: "color 0.2s",
+          }}
+        />
+      </Badge>
+    </Popover>
+  );
+};
+
+export default NetworkStatus;

+ 4 - 2
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -130,18 +130,20 @@ const items: MenuItem[] = [
   },
 
   {
-    key: "/workspace/articles/",
+    key: "/workspace/articles",
     icon: <DocumentIcon />,
     label: "文章",
     children: [
       {
-        key: "/workspace/articles",
+        key: "/workspace/article",
         label: "全部文章",
+        activeId: "workspace.article",
         icon: <FileOutlined />,
       },
       {
         key: "/workspace/anthology",
         label: "文集",
+        activeId: "workspace.anthology",
         icon: <FolderOutlined />,
       },
     ],

+ 5 - 3
dashboard-v6/src/layouts/workspace/index.tsx

@@ -1,4 +1,4 @@
-import { Button, Layout } from "antd";
+import { Button, Layout, Space } from "antd";
 import { Outlet } from "react-router";
 import { useState } from "react";
 import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
@@ -6,6 +6,7 @@ import MainMenu from "../../components/navigation/MainMenu";
 import SignInAvatar from "../../components/auth/SignInAvatar";
 import HeaderBreadcrumb from "../../components/navigation/HeaderBreadcrumb";
 import ThemeSwitch from "../../components/theme/ThemeSwitch";
+import { NetworkStatus } from "../../components/general/NetworkStatus";
 
 const { Sider, Content } = Layout;
 const Widget = () => {
@@ -39,9 +40,10 @@ const Widget = () => {
           }}
         >
           <HeaderBreadcrumb />
-          <div>
+          <Space>
+            <NetworkStatus />
             <ThemeSwitch />
-          </div>
+          </Space>
         </div>
 
         <Content style={{ padding: 12 }}>

+ 0 - 13
dashboard-v6/src/pages/workspace/anthology/anthology.tsx

@@ -1,13 +0,0 @@
-import { useAppSelector } from "../../../hooks";
-import { currentUser } from "../../../reducers/current-user";
-import AnthologyList from "../../../components/anthology/AnthologyList";
-
-const Widget = () => {
-  const user = useAppSelector(currentUser);
-  const studioName = user?.realName;
-
-  console.debug("channel list", studioName);
-  return <AnthologyList studioName={studioName} />;
-};
-
-export default Widget;

+ 18 - 0
dashboard-v6/src/pages/workspace/anthology/edit.tsx

@@ -0,0 +1,18 @@
+import { useParams } from "react-router";
+
+import AnthologyTocEdit from "../../../components/anthology/AnthologyTocEdit";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const { id } = useParams(); //url 参数
+  const user = useAppSelector(currentUser);
+
+  return (
+    <>
+      <AnthologyTocEdit id={id} editorStudioName={user?.realName} />
+    </>
+  );
+};
+
+export default Widget;

+ 54 - 0
dashboard-v6/src/pages/workspace/anthology/index.tsx

@@ -0,0 +1,54 @@
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+import AnthologyList from "../../../components/anthology/AnthologyList";
+import { useNavigate, useSearchParams } from "react-router";
+
+const Widget = () => {
+  const user = useAppSelector(currentUser);
+  const studioName = user?.realName;
+  const navigate = useNavigate();
+
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const tab = searchParams.get("tab") ?? "my";
+  const page = Number(searchParams.get("page") ?? 1);
+  const pageSize = Number(searchParams.get("pagesize") ?? 10);
+
+  const handleTabChange = (newTab: string) => {
+    setSearchParams((prev) => {
+      const next = new URLSearchParams(prev);
+      next.set("tab", newTab);
+      next.set("page", "1"); // 切 tab 重置页码
+      return next;
+    });
+  };
+
+  const handlePageChange = (newPage: number, newPageSize: number) => {
+    setSearchParams((prev) => {
+      const next = new URLSearchParams(prev);
+      next.set("page", String(newPage));
+      next.set("pagesize", String(newPageSize));
+      return next;
+    });
+  };
+
+  console.debug("anthology list", studioName);
+  return (
+    <>
+      <title>anthology</title>
+      <AnthologyList
+        tab={tab}
+        page={page}
+        pageSize={pageSize}
+        onTabChange={handleTabChange}
+        onPageChange={handlePageChange}
+        studioName={studioName}
+        onTitleClick={(id) => {
+          navigate(`/workspace/anthology/${id}`);
+        }}
+      />
+    </>
+  );
+};
+
+export default Widget;

+ 30 - 0
dashboard-v6/src/pages/workspace/anthology/show.tsx

@@ -0,0 +1,30 @@
+import { useNavigate, useParams, useSearchParams } from "react-router";
+
+import AnthologyDetail from "../../../components/anthology/AnthologyDetail";
+
+const Widget = () => {
+  const { id } = useParams(); //url 参数
+  const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
+
+  const channelId = searchParams.get("channel");
+  const channels = channelId ? channelId.split("_") : undefined;
+
+  return (
+    <>
+      <title>{"anthology-"}</title>
+      <AnthologyDetail
+        channels={channels}
+        id={id}
+        onArticleClick={(anthologyId, articleId, target) => {
+          console.log("click", target);
+          navigate(
+            `/workspace/article/${articleId}?anthology=${anthologyId}&channel=${articleId}`
+          );
+        }}
+      />
+    </>
+  );
+};
+
+export default Widget;

+ 54 - 0
dashboard-v6/src/pages/workspace/article/index.tsx

@@ -0,0 +1,54 @@
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+import { useNavigate, useSearchParams } from "react-router";
+import ArticleList from "../../../components/article/ArticleList";
+
+const Widget = () => {
+  const user = useAppSelector(currentUser);
+  const studioName = user?.realName;
+  const navigate = useNavigate();
+
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const tab = searchParams.get("tab") ?? "my";
+  const page = Number(searchParams.get("page") ?? 1);
+  const pageSize = Number(searchParams.get("pagesize") ?? 10);
+
+  const handleTabChange = (newTab: string) => {
+    setSearchParams((prev) => {
+      const next = new URLSearchParams(prev);
+      next.set("tab", newTab);
+      next.set("page", "1"); // 切 tab 重置页码
+      return next;
+    });
+  };
+
+  const handlePageChange = (newPage: number, newPageSize: number) => {
+    setSearchParams((prev) => {
+      const next = new URLSearchParams(prev);
+      next.set("page", String(newPage));
+      next.set("pagesize", String(newPageSize));
+      return next;
+    });
+  };
+
+  console.debug("article list", studioName);
+  return (
+    <>
+      <title>article</title>
+      <ArticleList
+        tab={tab}
+        page={page}
+        pageSize={pageSize}
+        onTabChange={handleTabChange}
+        onPageChange={handlePageChange}
+        studioName={studioName}
+        onSelect={(id) => {
+          navigate(`/workspace/article/${id}`);
+        }}
+      />
+    </>
+  );
+};
+
+export default Widget;

+ 36 - 7
dashboard-v6/src/reducers/net-status.ts

@@ -1,19 +1,48 @@
+import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
+import type { RootState } from "../store";
+
 /**
+ * 网络状态枚举
+ *
+ * idle        - 初始状态,尚未进行任何检测
+ * checking    - 检测中(网络 & API 均在探测)
+ * online      - 网络已连接,API 正常
+ * offline     - 网络断开,无法访问互联网
+ * api_error   - 网络正常,但 API 返回非 2xx 响应
+ * api_timeout - 网络正常,但 API 请求超时未响应
  *
+ * @deprecated 旧值 "loading" | "success" | "fail" 已由上述状态替代
  */
-import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
+export type ENetStatus =
+  | "idle" // 初始未检测
+  | "checking" // 检测中
+  | "online" // 网络 + API 均正常
+  | "offline" // 网络断开
+  | "api_error" // 网络正常,API 异常
+  | "api_timeout"; // 网络正常,API 超时
 
-import type { RootState } from "../store";
-export type ENetStatus = "loading" | "success" | "fail";
 export interface INetStatus {
+  /** 当前状态 */
+  status: ENetStatus;
+  /** 可选描述信息(错误原因、HTTP 状态码等) */
   message?: string;
-  status?: ENetStatus;
+  /** 上次成功检测时间(ISO string) */
+  lastCheckedAt?: string;
+  /** 网络是否在线(navigator.onLine 或 ping 结果) */
+  isNetworkOnline?: boolean;
+  /** API 是否可达 */
+  isApiOnline?: boolean;
 }
+
 interface IState {
-  status?: INetStatus;
+  status: INetStatus;
 }
 
-const initialState: IState = {};
+const initialState: IState = {
+  status: {
+    status: "idle",
+  },
+};
 
 export const slice = createSlice({
   name: "netStatus",
@@ -27,7 +56,7 @@ export const slice = createSlice({
 
 export const { statusChange } = slice.actions;
 
-export const netStatus = (state: RootState): INetStatus | undefined =>
+export const selectNetStatus = (state: RootState): INetStatus =>
   state.netStatus.status;
 
 export default slice.reducer;

+ 9 - 0
dashboard-v6/src/routes/testRoutes.tsx

@@ -19,6 +19,10 @@ const TypePaliTest = lazy(() => import("../components/article/TypePaliTest"));
 const SplitLayoutTest = lazy(
   () => import("../components/general/SplitLayout/SplitLayoutTest")
 );
+
+const ArticleReader = lazy(
+  () => import("../components/article/ArticleReaderTest")
+);
 // 你可以继续添加更多测试组件
 // const TestButtonDemo = lazy(() => import("../components/button/ButtonDemo"));
 
@@ -71,6 +75,11 @@ export const testRoutes: TestRouteObject[] = [
         label: "TypePaliTest",
         Component: TypePaliTest,
       },
+      {
+        path: "ArticleReader",
+        label: "ArticleReader",
+        Component: ArticleReader,
+      },
     ],
   },
 ];

+ 1 - 0
dashboard-v6/src/types/index.ts

@@ -0,0 +1 @@
+export type TTarget = "_blank" | "_self";